Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions backend/cli/src/cli/cmd/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,16 @@ const ConnectLogoutCommand = cmd({
return
}

// Revoke this device's key server-side while it can still authenticate
// the call, then clear every local credential artifact.
const revoked = await OpenScience.revokeCurrentDevice()
await OpenScience.clearSession()
prompts.log.success("Disconnected")
if (!revoked) {
prompts.log.info(
"Could not revoke this device's key server-side — remove it from the Devices tab at app.syntheticsciences.ai if needed",
)
}
prompts.outro("Done")
},
})
Expand Down
142 changes: 137 additions & 5 deletions backend/cli/src/openscience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,11 +417,123 @@ export namespace OpenScience {
})()
}

/** Read the on-disk synced-env snapshot (what preload-env.ts replayed into
* process.env at boot). Returns an empty map when missing or corrupt. */
async function readSyncedSnapshot(): Promise<Map<string, string>> {
const result = new Map<string, string>()
try {
const raw = await fs.readFile(path.join(getSyncedConfigDir(), "synced-env.json"), "utf-8")
const parsed: unknown = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return result
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") result.set(key, value)
}
} catch {}
return result
}

/** Drop a synced env var from the live process, but only when its current
* value is still the one sync injected — an explicit shell export wins. */
function unsetSyncedVar(key: string, value: string) {
if (process.env[key] !== value) return
delete process.env[key]
try {
Env.remove(key)
} catch {
/* Instance not initialized — process.env delete is enough */
}
}

/** Clear the api_key this CLI seeded into the bundled atlas CLI's config
* (see ensureAtlasCliConfig). Only removes the key when it is the one the
* session seeded (or, with no readable session, when the profile points at
* our backend), so a hand-configured atlas profile survives. Best-effort. */
async function clearAtlasCliConfig(session: OpenScienceSession | null): Promise<void> {
try {
const configPath =
process.env.ATLAS_CLI_CONFIG_PATH || path.join(os.homedir(), ".config", "atlas-cli", "config.json")
const existing: unknown = JSON.parse(await fs.readFile(configPath, "utf8"))
if (!existing || typeof existing !== "object") return
const profiles = (existing as Record<string, unknown>).profiles
if (!profiles || typeof profiles !== "object") return
const profile = (profiles as Record<string, unknown>).default
if (!profile || typeof profile !== "object") return
const record = profile as Record<string, unknown>
if (typeof record.api_key !== "string" || !record.api_key) return
const seeded = session?.api_key
? record.api_key === session.api_key
: record.base_url === `${API_BASE}/api/v1`
if (!seeded) return
delete record.api_key
await fs.writeFile(configPath, JSON.stringify(existing, null, 2) + "\n", { mode: 0o600 })
} catch {
/* missing/unreadable config — nothing to clear */
}
}

/** Delete queued usage rows. They were produced under the signed-out
* account's key; flushing them after a different account logs in would
* bill that account for someone else's usage. */
async function dropUsageQueue(): Promise<void> {
try {
const raw = await fs.readFile(pendingQueuePath, "utf-8")
const rows = raw.split("\n").filter(Boolean).length
await fs.unlink(pendingQueuePath)
if (rows) log.info("dropped queued usage on sign-out so it cannot bill a different account", { rows })
} catch {
/* no queue — nothing to drop */
}
}

/**
* Sign out locally: remove the session file and every credential artifact
* the sync path created. Without this, `synced-env.json` is replayed into
* process.env on every boot (preload-env.ts) and the still-valid managed
* key keeps debiting the signed-out account's wallet. Covers both explicit
* logout and the 401-triggered clear. Best-effort; never throws.
*/
export async function clearSession() {
const session = await getSession()
try {
const fs = await import("fs/promises")
await fs.unlink(filepath)
} catch {}
// Union of what this process synced (in-memory map) and what the last
// sync persisted (disk snapshot, replayed by preload-env.ts at boot) —
// a fresh `connect logout` process has only the latter.
const synced = await readSyncedSnapshot()
for (const [key, value] of syncedSecretValues.entries()) synced.set(key, value)
for (const name of ["synced-env.json", "openscience-synced.json"]) {
try {
await fs.unlink(path.join(getSyncedConfigDir(), name))
} catch {}
}
for (const [key, value] of synced.entries()) unsetSyncedVar(key, value)
syncedSecretValues.clear()
await clearAtlasCliConfig(session)
await dropUsageQueue()
}

/**
* Best-effort server-side revocation of THIS device's key, for logout paths.
* The session stores only the raw api_key (never its key_id), so the device
* is identified by a unique `key_prefix` match against the devices list —
* when zero or several devices match, we skip rather than guess. Call
* BEFORE clearSession(); returns whether the key was revoked.
*/
export async function revokeCurrentDevice(): Promise<boolean> {
try {
const session = await getSession()
if (!session) return false
const devices = await listDevices()
if (!devices) return false
const matches = devices.filter(
(d) => d.key_prefix.length > "thk_".length && session.api_key.startsWith(d.key_prefix),
)
if (matches.length !== 1) return false
return await revokeDevice(matches[0].key_id)
} catch {
return false
}
}

export async function isAuthenticated(): Promise<boolean> {
Expand Down Expand Up @@ -638,20 +750,40 @@ export namespace OpenScience {
// those are one credential, not four.
const credentialValues = new Set<string>()

// Rebuild the synced snapshot from THIS response only. Accumulating
// across syncs meant a provider disconnected (or a key rotated) on the
// dashboard stayed live in the CLI forever.
const fresh = new Map<string, string>()
for (const [, svc] of Object.entries(data.services)) {
if (svc.connected && svc.env) {
for (const [key, value] of Object.entries(svc.env)) {
if (value) {
try { Env.set(key, value) } catch { /* Instance not initialized */ }
process.env[key] = value
syncedSecretValues.set(key, value)
fresh.set(key, value)
if (!key.endsWith("_BASE_URL")) credentialValues.add(value)
}
}
}
}
const credentials = credentialValues.size

// Unset previously-synced vars that are absent from the new response —
// mirrors the ownedKeys cleanup in server/routes/settings/credentials.ts.
// "Previously synced" is the union of this process's map and the on-disk
// snapshot preload-env.ts replayed at boot; a var is only removed when
// its live value still matches, so shell exports survive.
const previous = await readSyncedSnapshot()
for (const [key, value] of syncedSecretValues.entries()) previous.set(key, value)
for (const [key, value] of previous.entries()) {
if (fresh.has(key)) continue
unsetSyncedVar(key, value)
}
syncedSecretValues.clear()
for (const [key, value] of fresh.entries()) {
try { Env.set(key, value) } catch { /* Instance not initialized */ }
process.env[key] = value
syncedSecretValues.set(key, value)
}

// Write model lockdown config to managed config dir (highest priority config layer)
if (data.config) {
try {
Expand Down Expand Up @@ -681,7 +813,7 @@ export namespace OpenScience {
const managedDir = getSyncedConfigDir()
await fs.mkdir(managedDir, { recursive: true })
const envSnapshot: Record<string, string> = {}
for (const [k, v] of syncedSecretValues.entries()) {
for (const [k, v] of fresh.entries()) {
envSnapshot[k] = v
}
await Bun.write(
Expand Down
4 changes: 4 additions & 0 deletions backend/cli/src/server/routes/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ export const AccountRoutes = lazy(() =>
},
}),
async (c) => {
// Best-effort server-side revocation of this device's key while the
// session can still authenticate the call; local cleanup follows
// regardless of the outcome.
await OpenScience.revokeCurrentDevice()
await OpenScience.clearSession()
Provider.invalidate()
await Instance.disposeAll()
Expand Down
110 changes: 110 additions & 0 deletions backend/cli/test/openscience-logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { test, expect, afterEach } from "bun:test"
import os from "os"
import path from "path"
import fs from "fs/promises"
import { Global } from "../src/global"
import { OpenScience, API_BASE } from "../src/openscience"

// XDG dirs are isolated per test run by test/preload.ts, so these paths all
// live under the throwaway temp tree — never the developer's real config.
const session = path.join(Global.Path.data, "openscience-session.json")
const synced = path.join(process.env.XDG_CONFIG_HOME!, "openscience")
const snapshot = path.join(synced, "synced-env.json")
const managed = path.join(synced, "openscience-synced.json")
const queue = path.join(Global.Path.data, "usage-queue.jsonl")
const atlas = path.join(os.tmpdir(), `openscience-test-atlas-${process.pid}`, "config.json")

const INJECTED = "OPENSCIENCE_TEST_SYNCED_VAR"
const EXPORTED = "OPENSCIENCE_TEST_EXPORTED_VAR"

afterEach(async () => {
delete process.env[INJECTED]
delete process.env[EXPORTED]
delete process.env.ATLAS_CLI_CONFIG_PATH
for (const file of [session, snapshot, managed, queue, atlas]) {
await fs.rm(file, { force: true }).catch(() => {})
}
})

test("clearSession removes every synced credential artifact", async () => {
await fs.mkdir(Global.Path.data, { recursive: true })
await fs.mkdir(synced, { recursive: true })
await fs.mkdir(path.dirname(atlas), { recursive: true })

await Bun.write(session, JSON.stringify({ api_key: "thk_test.secret", user_id: "user-1" }))
// The persisted snapshot preload-env.ts replays into process.env at boot.
await Bun.write(
snapshot,
JSON.stringify({ [INJECTED]: "thk_injected_value", [EXPORTED]: "thk_synced_value" }),
)
await Bun.write(managed, JSON.stringify({ model: "synsci/some-model" }))
await Bun.write(queue, JSON.stringify({ service: "llm", event_type: "chat", tokens_used: 10 }) + "\n")

process.env.ATLAS_CLI_CONFIG_PATH = atlas
await Bun.write(
atlas,
JSON.stringify({
active_profile: "default",
profiles: {
default: { api_key: "thk_test.secret", base_url: `${API_BASE}/api/v1` },
personal: { api_key: "thk_other.key", base_url: "https://example.test/api/v1" },
},
}),
)

// Simulate preload-env.ts having injected the synced value at boot…
process.env[INJECTED] = "thk_injected_value"
// …and a key the user exported in their own shell with a different value.
process.env[EXPORTED] = "user-exported-value"

await OpenScience.clearSession()

expect(await Bun.file(session).exists()).toBe(false)
expect(await Bun.file(snapshot).exists()).toBe(false)
expect(await Bun.file(managed).exists()).toBe(false)
expect(await Bun.file(queue).exists()).toBe(false)

// The injected var is gone; the shell export survives.
expect(process.env[INJECTED]).toBeUndefined()
expect(process.env[EXPORTED]).toBe("user-exported-value")

// The seeded atlas-cli profile lost its api_key; everything else intact.
const config = JSON.parse(await Bun.file(atlas).text())
expect(config.profiles.default.api_key).toBeUndefined()
expect(config.profiles.default.base_url).toBe(`${API_BASE}/api/v1`)
expect(config.profiles.personal.api_key).toBe("thk_other.key")
})

test("clearSession without a session still clears the seeded atlas profile by base_url", async () => {
await fs.mkdir(path.dirname(atlas), { recursive: true })
process.env.ATLAS_CLI_CONFIG_PATH = atlas
await Bun.write(
atlas,
JSON.stringify({
active_profile: "default",
profiles: { default: { api_key: "thk_stale.secret", base_url: `${API_BASE}/api/v1` } },
}),
)

await OpenScience.clearSession()

const config = JSON.parse(await Bun.file(atlas).text())
expect(config.profiles.default.api_key).toBeUndefined()
})

test("clearSession leaves a hand-configured atlas profile alone", async () => {
await fs.mkdir(path.dirname(atlas), { recursive: true })
process.env.ATLAS_CLI_CONFIG_PATH = atlas
await Bun.write(
atlas,
JSON.stringify({
active_profile: "default",
profiles: { default: { api_key: "thk_mine.secret", base_url: "https://selfhosted.example/api/v1" } },
}),
)

await OpenScience.clearSession()

const config = JSON.parse(await Bun.file(atlas).text())
expect(config.profiles.default.api_key).toBe("thk_mine.secret")
})
Loading