diff --git a/src/accounts.ts b/src/accounts.ts index 098a09f..1a17551 100644 --- a/src/accounts.ts +++ b/src/accounts.ts @@ -27,9 +27,11 @@ export interface SessionAccount { /** * How `profile` was resolved. `"layout"` means it was read off the dir path * rather than the registry, which is authoritative about the dir but can lag - * a rename — see `layoutProfile`. Surfaced for diagnostics, not for display. + * a rename — see `layoutProfile`. `"switched"` means an in-place + * `switch-account` marker named another profile's account as the dir's + * current occupant. Surfaced for diagnostics, not for display. */ - source: "registry" | "layout" | null; + source: "registry" | "layout" | "switched" | null; } /** Env var Claude Code uses to point a process at an isolated config dir. */ @@ -51,6 +53,21 @@ const DEFAULT_ACCOUNTS_HOME = [".hasna", "accounts"]; const STORE_FILE = "accounts.json"; const PROFILES_SUBDIR = "profiles"; +/** + * `accounts switch-account` switches a session's account IN PLACE: it swaps + * the live auth files inside the config dir and records the new occupant in + * this marker, without moving the dir or re-pointing the registry. So both the + * registry entry and the dir's path name keep describing the dir's *owner* + * after a switch — only the marker knows who is actually logged in now. It is + * cleared when the owner's account is restored or a fresh login lands. + * + * The filenames mirror `@hasna/accounts`'s claude-layout module, which owns + * them but does not export a public reader; if one appears it should replace + * these constants via the same lazy import used for `accountsPaths`. + */ +const AUTH_STATE_SUBDIR = ".accounts-auth"; +const SWITCHED_ACCOUNT_MARKER_FILE = "switched-account.json"; + /** * Where the host agent records which account is logged in. Mirrors Claude * Code's own resolution: a `.config.json` in the dir wins, otherwise @@ -206,6 +223,25 @@ function layoutProfile(profilesRoot: string, configDir: string, env: Env): Sessi return { configDir, profile, tool, source: "layout" }; } +interface SwitchedOccupant { + profile: string | null; + email: string | null; +} + +/** + * The account currently occupying this config dir after an in-place + * `switch-account`, or null when the dir carries its own account. A marker + * with no usable fields is treated as absent rather than as an anonymous + * occupant. + */ +function switchedOccupant(configDir: string): SwitchedOccupant | null { + const raw = readJson(join(configDir, AUTH_STATE_SUBDIR, SWITCHED_ACCOUNT_MARKER_FILE)); + if (!raw) return null; + const profile = str(raw.profile); + const email = str(raw.email); + return profile || email ? { profile, email } : null; +} + /** Config dir the current process is bound to, for settings and registry lookup. */ export function sessionConfigDir(env: Env = process.env): string { return canonical(str(env[CONFIG_DIR_ENV]) ?? join(home(env), DEFAULT_CONFIG_SUBDIR), env); @@ -238,8 +274,15 @@ export async function sessionAccount(env: Env = process.env, tool?: string): Pro const paths = await accountsPaths(env); const entry = registryEntry(paths.store, configDir, env, tool); const name = str(entry?.name); - if (name) return { configDir, profile: name, tool: str(entry?.tool), source: "registry" }; - return layoutProfile(paths.profiles, configDir, env) ?? unresolved; + const owner: SessionAccount = name + ? { configDir, profile: name, tool: str(entry?.tool), source: "registry" } + : (layoutProfile(paths.profiles, configDir, env) ?? unresolved); + // An in-place switch overrides the owner. A nameless occupant still + // suppresses the owner's name — reporting it would be a stale identity — + // and lets callers fall back to `sessionAccountEmail`. + const occupant = switchedOccupant(configDir); + if (occupant) return { configDir, profile: occupant.profile, tool: owner.tool, source: "switched" }; + return owner; } catch { return unresolved; } @@ -256,8 +299,13 @@ export async function sessionAccountEmail(env: Env = process.env, tool?: string) const stateFile = sessionStateFile(env); const live = stateFile ? str(readJson(stateFile)?.oauthAccount?.emailAddress) : null; if (live) return live; + const configDir = sessionConfigDir(env); + // After an in-place switch the registry's email describes the dir's + // parked owner, not this session — the marker's copy is the occupant's. + const occupant = switchedOccupant(configDir); + if (occupant) return occupant.email; const paths = await accountsPaths(env); - return str(registryEntry(paths.store, sessionConfigDir(env), env, tool)?.email); + return str(registryEntry(paths.store, configDir, env, tool)?.email); } catch { return null; } diff --git a/test/accounts.test.ts b/test/accounts.test.ts index 90b378a..ffe10a1 100644 --- a/test/accounts.test.ts +++ b/test/accounts.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync, symlinkSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { sessionAccount, sessionAccountEmail, sessionConfigDir, sessionStateFile } from "../src/accounts"; @@ -137,6 +137,80 @@ describe("sessionAccount", () => { }); }); +/** Simulate `accounts switch-account`: the dir's live auth now carries another account. */ +function switchTo(configDir: string, profile: string | null, email: string | null) { + const authDir = join(configDir, ".accounts-auth"); + mkdirSync(authDir, { recursive: true }); + writeFileSync( + join(authDir, "switched-account.json"), + JSON.stringify({ profile, email, switchedAt: new Date().toISOString() }), + ); +} + +describe("sessionAccount after an in-place switch-account", () => { + test("shows the occupant, not the dir owner — and flips when the switch lands", async () => { + const { root, entries } = accountsHome([ + { name: "account088", tool: "claude", email: "owner@example.com" }, + { name: "account033", tool: "claude", email: "occupant@example.com" }, + ]); + const dir = entries[0]!.dir; + // positive control: before the switch this same input resolves to the owner + expect((await sessionAccount(env(root, dir))).profile).toBe("account088"); + switchTo(dir, "account033", "occupant@example.com"); + const after = await sessionAccount(env(root, dir)); + expect(after.profile).toBe("account033"); + expect(after.source).toBe("switched"); + }); + + test("the occupant also wins over the layout name for an unregistered dir", async () => { + const { root } = accountsHome([{ name: "account001", tool: "claude" }]); + const unregistered = join(root, "profiles", "claude", "account042"); + mkdirSync(unregistered, { recursive: true }); + switchTo(unregistered, "account007", "occupant@example.com"); + const account = await sessionAccount(env(root, unregistered)); + expect(account.profile).toBe("account007"); + expect(account.source).toBe("switched"); + }); + + test("a nameless occupant yields no profile rather than the stale owner name", async () => { + const { root, entries } = accountsHome([{ name: "account088", tool: "claude" }]); + switchTo(entries[0]!.dir, null, "occupant@example.com"); + const account = await sessionAccount(env(root, entries[0]!.dir)); + expect(account.profile).toBeNull(); + expect(account.source).toBe("switched"); + }); + + test("clearing the marker restores the owner, so a switch back is visible too", async () => { + const { root, entries } = accountsHome([{ name: "account088", tool: "claude" }]); + const dir = entries[0]!.dir; + switchTo(dir, "account033", "occupant@example.com"); + expect((await sessionAccount(env(root, dir))).profile).toBe("account033"); + rmSync(join(dir, ".accounts-auth", "switched-account.json")); + const restored = await sessionAccount(env(root, dir)); + expect(restored.profile).toBe("account088"); + expect(restored.source).toBe("registry"); + }); + + test("a corrupt marker degrades to the owner instead of failing", async () => { + const { root, entries } = accountsHome([{ name: "account088", tool: "claude" }]); + const dir = entries[0]!.dir; + mkdirSync(join(dir, ".accounts-auth"), { recursive: true }); + writeFileSync(join(dir, ".accounts-auth", "switched-account.json"), "{not json"); + expect((await sessionAccount(env(root, dir))).profile).toBe("account088"); + }); + + test("two dirs render different occupants simultaneously", async () => { + const { root, entries } = accountsHome([ + { name: "account001", tool: "claude" }, + { name: "account088", tool: "claude" }, + ]); + switchTo(entries[0]!.dir, "account010", "ten@example.com"); + switchTo(entries[1]!.dir, "account033", "occupant@example.com"); + expect((await sessionAccount(env(root, entries[0]!.dir))).profile).toBe("account010"); + expect((await sessionAccount(env(root, entries[1]!.dir))).profile).toBe("account033"); + }); +}); + describe("sessionAccountEmail", () => { test("prefers the agent's own record over the registry copy", async () => { const { root, entries } = accountsHome([{ name: "account006", tool: "claude", email: "stale@example.com" }]); @@ -144,6 +218,20 @@ describe("sessionAccountEmail", () => { expect(await sessionAccountEmail(env(root, entries[0]!.dir))).toBe("live@example.com"); }); + test("after a switch, the occupant's email beats the owner's registry copy", async () => { + const { root, entries } = accountsHome([{ name: "account088", tool: "claude", email: "owner@example.com" }]); + switchTo(entries[0]!.dir, "account033", "occupant@example.com"); + expect(await sessionAccountEmail(env(root, entries[0]!.dir))).toBe("occupant@example.com"); + }); + + test("the agent's own record still wins over the switch marker", async () => { + // the live state file is what the session actually authenticates as + const { root, entries } = accountsHome([{ name: "account088", tool: "claude", email: "owner@example.com" }]); + login(entries[0]!.dir, "live@example.com"); + switchTo(entries[0]!.dir, "account033", "marker@example.com"); + expect(await sessionAccountEmail(env(root, entries[0]!.dir))).toBe("live@example.com"); + }); + test("falls back to the registry when the agent has no record", async () => { const { root, entries } = accountsHome([{ name: "account006", tool: "claude", email: "registry@example.com" }]); expect(await sessionAccountEmail(env(root, entries[0]!.dir))).toBe("registry@example.com");