import type { ChildProcessWithoutNullStreams } from 'node:child_process'; import { spawn } from 'node:child_process'; import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { listAnthropicAccounts, markAnthropicAccountUsed, purgeExpiredAnthropicAccounts, revealAnthropicAccount, saveAnthropicAccount, } from 'officerdb'; import { createRouter } from '../../create-router'; import { getHomeDir, getOwnerHomeDir } from '../../data-path'; import { claudeBinIn } from '../../os-user-claude'; import { runAsArgv } from '../../os-user'; /* `/login` in the chat composer, doing what it does in a terminal. Officer's members and its owner both authenticate `claude` with a Claude SUBSCRIPTION over OAuth, not with an Anthropic API key — see `sidecar/claude/state.ts`. That flow is a browser round trip: the CLI prints a URL, you authorise in claude.com, and you paste a code back. It is the one part of running an agent that a browser-only user could not do from the browser: they had to open a terminal panel and drive the CLI by hand, which works and is exactly the thing this removes. It needs no pty. `claude auth login` behaves identically through plain pipes — verified 2026-08-21 by capturing its raw bytes — so this is an ordinary subprocess with two reads and one write: spawn ─► read stdout until the URL ─► [person authorises] ─► write the code to stdin ─► exit The one non-obvious piece is the parsing. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it again as visible text, so a naive `https://\S+` match returns two URLs concatenated with no separator. Stripping the escape wrappers first leaves exactly one. */ /** OSC-8 hyperlink: `ESC ] 8 ; ; BEL` … `ESC ] 8 ; ; BEL`. Both halves go. */ const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g; const SGR = /\x1b\[[0-9;]*m/g; const visibleText = (raw: string): string => raw.replace(OSC8, '').replace(SGR, ''); /** The URL the person has to open, or null while the CLI has not printed it yet. */ export function extractLoginUrl(raw: string): string | null { const m = visibleText(raw).match(/https:\/\/\S+/); return m ? m[0] : null; } /** The CLI is waiting on stdin. Distinct from "printed a URL" — it prints the URL first. */ export const awaitingCode = (raw: string): boolean => /Paste code here/i.test(visibleText(raw)); type Pending = { userId: number; child: ChildProcessWithoutNullStreams; url: string; out: string; reaper: ReturnType; }; /* In memory, and deliberately not in Postgres. A pending login is worth nothing after the process holding its PKCE verifier dies, so persisting it would store a row that can only ever be stale. A restart losing them is correct: the code in the person's clipboard is already useless, and the honest recovery is to type `/login` again. */ const pending = new Map(); /* The authorisation code expires and so does the person's patience. Ten minutes is long enough to find a password manager and short enough that a forgotten tab does not leave a `claude` process parked on a read of stdin for the rest of the week. */ const TTL_MS = 10 * 60 * 1000; function abandon(id: string): void { const p = pending.get(id); if (!p) return; clearTimeout(p.reaper); pending.delete(id); try { p.child.kill('SIGTERM'); } catch { // Already gone. Nothing to do and nothing worth logging. } } type Who = { id: number; isOwner: boolean; osUser: string | null; email: string }; /** * The login process for one account. * * The owner runs it as themselves, in their real home, so it refreshes the very credentials * `officer-anthropic-proxy` reads. A member runs through `runAsArgv` — `setpriv --reset-env` — as their own * Linux user, writing into their own home, which is the whole point: their turns bill their subscription * rather than the owner's, and that only holds if the credential is theirs. * * Nothing Anthropic-shaped is inherited in either case. For the owner those variables point at the local * proxy, and the proxy exists to swap a shared secret for the owner's bearer token — handing them to a * login is how you get a "success" that authenticates nothing. */ function spawnLogin(who: Who): ChildProcessWithoutNullStreams { const stripped = { ...process.env }; for (const name of [ 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_FOUNDRY_API_KEY', '_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL', 'CLAUDE_CODE_OAUTH_TOKEN', ]) delete stripped[name]; if (who.isOwner) { return spawn('claude', ['auth', 'login', '--claudeai'], { env: stripped, stdio: ['pipe', 'pipe', 'pipe'], }) as ChildProcessWithoutNullStreams; } if (!who.osUser) throw new Error('no Linux account: an agent for this member cannot hold its own credentials'); const home = getHomeDir(who.email); // `env` inside the argv, because `--reset-env` discards anything handed to `setpriv` itself. Only PATH, // and only so `claude` can find `node`; nothing here carries a credential. const [launcher, ...args] = runAsArgv(who.osUser, [claudeBinIn(home), 'auth', 'login', '--claudeai']); if (!launcher) throw new Error('runAsArgv returned an empty argv'); return spawn(launcher, args, { cwd: home, stdio: ['pipe', 'pipe', 'pipe'] }) as ChildProcessWithoutNullStreams; } /** Reads until the URL appears, or the process dies, or we run out of patience waiting for it. */ function urlFrom(child: ChildProcessWithoutNullStreams): Promise<{ url: string; out: string }> { return new Promise((resolve, reject) => { let out = ''; // Separate from TTL_MS: this is the CLI failing to start, which shows up as silence rather than an error. const giveUp = setTimeout( () => reject(new Error(`no login URL after 25s. Output so far: ${visibleText(out)}`)), 25_000, ); const settle = (fn: () => void) => { clearTimeout(giveUp); fn(); }; const onChunk = (b: Buffer) => { out += b.toString(); const url = extractLoginUrl(out); if (url) settle(() => resolve({ url, out })); }; child.stdout.on('data', onChunk); // The CLI prints the URL to stdout, but its warnings go to stderr and one of them explains a failure. child.stderr.on('data', (b: Buffer) => { out += b.toString(); }); child.on('error', (err) => settle(() => reject(err))); child.on('exit', (code) => settle(() => reject(new Error(`claude auth login exited ${code} before printing a URL: ${visibleText(out)}`))), ); }); } export const claudeLoginRouter = createRouter(); // POST /auth/claude-login — start one, and hand back the URL to open. claudeLoginRouter.post('/', async (ctx) => { const user = ctx.get('user'); const who: Who = { id: user.id, isOwner: user.id === 1, osUser: user.osUser ?? null, email: user.email }; // One at a time per account. A second `/login` while one is open is a person retrying, not a person // wanting two, and two processes racing to write one credentials file is a corrupt file. for (const [id, p] of pending) if (p.userId === who.id) abandon(id); let child: ChildProcessWithoutNullStreams; try { child = spawnLogin(who); } catch (err) { return ctx.json({ error: err instanceof Error ? err.message : 'could not start the login' }, 400); } try { const { url, out } = await urlFrom(child); const id = crypto.randomUUID(); pending.set(id, { userId: who.id, child, url, out, reaper: setTimeout(() => abandon(id), TTL_MS) }); // Keep reading, or the pipe fills and the child blocks — and we want its later output for the result. child.stdout.on('data', (b: Buffer) => { const p = pending.get(id); if (p) p.out += b.toString(); }); return ctx.json({ id, url, expiresInMs: TTL_MS }); } catch (err) { try { child.kill('SIGTERM'); } catch { // Nothing to clean up. } return ctx.json({ error: err instanceof Error ? err.message : 'the login did not start' }, 502); } }); // POST /auth/claude-login/:id/code — hand over what claude.com gave them. claudeLoginRouter.post('/:id/code', async (ctx) => { const user = ctx.get('user'); const id = ctx.req.param('id'); const p = pending.get(id); // Same answer for "expired", "never existed" and "somebody else's": all three are equally not-yours. if (!p || p.userId !== user.id) return ctx.json({ error: 'that login is no longer open — run /login again' }, 404); const body = await ctx.req.json().catch(() => ({}) as Record); const code = typeof body.code === 'string' ? body.code.trim() : ''; if (!code) return ctx.json({ error: 'no code' }, 400); const finished = new Promise((resolve) => p.child.once('exit', (c) => resolve(c))); p.child.stdin.write(`${code}\n`); // The exchange is a network round trip on the CLI's side. const exit = await Promise.race([finished, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 45_000))]); const tail = visibleText(p.out).slice(-600); abandon(id); if (exit === 'timeout') return ctx.json({ ok: false, error: 'the CLI did not finish in 45s', detail: tail }, 504); if (exit !== 0) return ctx.json({ ok: false, error: `claude auth login exited ${exit}`, detail: tail }, 400); // Record what was just signed into, so it can be switched BACK to later without another round trip. Only // for the owner: a member's credential is in their own home and this module only ever reads the owner's. if (user.id === 1) await captureCurrentAnthropicAccount(user.id); const now = user.id === 1 ? await readCurrentAccount() : null; // The account is the answer the person opened this for. Two accounts on one machine means "signed in" on // its own is not enough to act on. return ctx.json({ ok: true, detail: tail, email: now?.emailAccount ?? null, orgName: now?.orgName ?? null }); }); // DELETE /auth/claude-login/:id — they closed the dialog. claudeLoginRouter.delete('/:id', (ctx) => { const user = ctx.get('user'); const id = ctx.req.param('id'); const p = pending.get(id); if (p && p.userId === user.id) abandon(id); return ctx.json({ ok: true }); }); /* ──────────────────────────────────────────────────────────────────────────── Switching between accounts that have already been signed into. The owner keeps a personal account and a company one and moves between them during a day. `/login` above handles arriving at an account; this handles going back to one, without a browser round trip, using the credential kept from last time. Owner only, and structurally: `getOwnerHomeDir` ignores the email it is passed, so every path below is the owner's whatever the caller claims. A member's `claude` reads their own home and is untouched by any of this — the isolation is the same as it was, and this feature does not widen it. ──────────────────────────────────────────────────────────────────────────── */ /** `.credentials.json` holds one key. `.claude.json` holds a whole config and must be PATCHED, not written. */ const credentialsPath = () => join(getOwnerHomeDir(''), '.claude', '.credentials.json'); const configPath = () => join(getOwnerHomeDir(''), '.claude.json'); async function readJson(path: string): Promise | null> { try { return JSON.parse(await readFile(path, 'utf-8')) as Record; } catch { return null; } } /** * What the CLI is signed in as right now, read from its own two files. * * Both are needed and neither is sufficient: the credential lives in `.claude/.credentials.json` and the * identity — email, org, account uuid — lives in `.claude.json` beside it. */ async function readCurrentAccount(): Promise<{ accountUuid: string; emailAccount: string; orgName: string | null; plan: string | null; credentialsJson: string; oauthAccountJson: string; refreshExpiresAt: Date | null; } | null> { const creds = await readJson(credentialsPath()); const config = await readJson(configPath()); const oauth = creds?.claudeAiOauth as Record | undefined; const account = config?.oauthAccount as Record | undefined; if (!oauth || !account) return null; const accountUuid = typeof account.accountUuid === 'string' ? account.accountUuid : ''; const emailAccount = typeof account.emailAddress === 'string' ? account.emailAddress : ''; if (!accountUuid || !emailAccount) return null; const sub = typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : ''; const refreshMs = typeof oauth.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null; return { accountUuid, emailAccount, orgName: typeof account.organizationName === 'string' ? account.organizationName : null, plan: sub ? sub[0]!.toUpperCase() + sub.slice(1) : null, credentialsJson: JSON.stringify(oauth), oauthAccountJson: JSON.stringify(account), refreshExpiresAt: refreshMs ? new Date(refreshMs) : null, }; } /** * Keep whatever the CLI is currently signed in as. * * Called after a `/login` here AND whenever the account list is read, which is what makes an account signed * into from a TERMINAL switchable too. Without that second call this would only know about logins that * happened to go through this dialog — a switcher that silently omits half your accounts. * * Never throws into a caller: failing to record a credential must not fail the request that noticed it. */ export async function captureCurrentAnthropicAccount(userId: number): Promise { try { const now = await readCurrentAccount(); if (!now) return; await saveAnthropicAccount({ userId, ...now }); } catch (err) { console.warn('[claude-auth] could not record the current account:', err instanceof Error ? err.message : err); } } // GET /claude-auth/accounts — who can be switched to, and who is current. No secrets. claudeLoginRouter.get('/accounts', async (ctx) => { const user = ctx.get('user'); if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403); // Record before listing, so an account signed into elsewhere appears the first time the panel is opened // rather than only after the next login through this dialog. await captureCurrentAnthropicAccount(user.id); await purgeExpiredAnthropicAccounts(user.id).catch(() => 0); const current = await readCurrentAccount(); const accounts = (await listAnthropicAccounts(user.id)).map((a) => ({ ...a, current: a.accountUuid === current?.accountUuid, })); return ctx.json({ accounts }); }); // POST /claude-auth/switch — become one of them. claudeLoginRouter.post('/switch', async (ctx) => { const user = ctx.get('user'); if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403); const body = await ctx.req.json().catch(() => ({}) as Record); const accountUuid = typeof body.accountUuid === 'string' ? body.accountUuid : ''; if (!accountUuid) return ctx.json({ error: 'no account' }, 400); // Keep the outgoing credential before overwriting it, or switching away from an account signed into // outside this UI loses it — the one-way trip this feature exists to remove. await captureCurrentAnthropicAccount(user.id); const target = await revealAnthropicAccount(user.id, accountUuid); if (!target) return ctx.json({ error: 'that sign-in has expired — run /login for it again' }, 409); try { // `.credentials.json` is replaced wholesale: it holds this one key and nothing else of ours. // `.claude.json` is PATCHED — it is the CLI's whole config, and overwriting it would take out every // project entry, tip counter and preference in it. const existingCreds = (await readJson(credentialsPath())) ?? {}; await writeFile( credentialsPath(), JSON.stringify({ ...existingCreds, claudeAiOauth: JSON.parse(target.credentialsJson) }), { mode: 0o600, }, ); const config = (await readJson(configPath())) ?? {}; await writeFile( configPath(), JSON.stringify({ ...config, oauthAccount: JSON.parse(target.oauthAccountJson) }, null, 2), { mode: 0o600, }, ); } catch (err) { return ctx.json({ error: `could not write the credential: ${err instanceof Error ? err.message : err}` }, 500); } await markAnthropicAccountUsed(user.id, accountUuid); // A turn already running keeps the credential it was spawned with. Said here rather than guessed at in // the UI, because "it did not take effect" and "it takes effect next turn" look identical from outside. return ctx.json({ ok: true, email: target.emailAccount, appliesTo: 'the next turn, and every new claude' }); });