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
58 changes: 53 additions & 5 deletions src/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
90 changes: 89 additions & 1 deletion test/accounts.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -137,13 +137,101 @@ 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" }]);
login(entries[0]!.dir, "live@example.com");
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");
Expand Down
Loading