diff --git a/backend/cli/src/cli/cmd/connect.ts b/backend/cli/src/cli/cmd/connect.ts index 5ad5dce..3106143 100644 --- a/backend/cli/src/cli/cmd/connect.ts +++ b/backend/cli/src/cli/cmd/connect.ts @@ -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") }, }) diff --git a/backend/cli/src/openscience/index.ts b/backend/cli/src/openscience/index.ts index 8d9e15f..61dfaef 100644 --- a/backend/cli/src/openscience/index.ts +++ b/backend/cli/src/openscience/index.ts @@ -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> { + const result = new Map() + 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 { + 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).profiles + if (!profiles || typeof profiles !== "object") return + const profile = (profiles as Record).default + if (!profile || typeof profile !== "object") return + const record = profile as Record + 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 { + 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 { + 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 { @@ -638,13 +750,15 @@ export namespace OpenScience { // those are one credential, not four. const credentialValues = new Set() + // 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() 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) } } @@ -652,6 +766,24 @@ export namespace OpenScience { } 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 { @@ -681,7 +813,7 @@ export namespace OpenScience { const managedDir = getSyncedConfigDir() await fs.mkdir(managedDir, { recursive: true }) const envSnapshot: Record = {} - for (const [k, v] of syncedSecretValues.entries()) { + for (const [k, v] of fresh.entries()) { envSnapshot[k] = v } await Bun.write( diff --git a/backend/cli/src/server/routes/account.ts b/backend/cli/src/server/routes/account.ts index 82ec600..85f075d 100644 --- a/backend/cli/src/server/routes/account.ts +++ b/backend/cli/src/server/routes/account.ts @@ -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() diff --git a/backend/cli/test/openscience-logout.test.ts b/backend/cli/test/openscience-logout.test.ts new file mode 100644 index 0000000..5f70eb2 --- /dev/null +++ b/backend/cli/test/openscience-logout.test.ts @@ -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") +})