diff --git a/src/servers/api/auth/claude-login.ts b/src/servers/api/auth/claude-login.ts new file mode 100644 index 00000000..a13e02f0 --- /dev/null +++ b/src/servers/api/auth/claude-login.ts @@ -0,0 +1,221 @@ +import type { ChildProcessWithoutNullStreams } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { createRouter } from '../../create-router'; +import { getHomeDir } 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); + return ctx.json({ ok: true, detail: tail }); +}); + +// 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 }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 1c4cf3c2..8293d397 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -41,6 +41,7 @@ import { queueRouter } from './api/queue/queue'; // registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot. import { bugReportRouter } from './api/bug-report/bug-report'; import { agentStatusRouter } from './api/agent-status/router'; +import { claudeLoginRouter } from './api/auth/claude-login'; import { chatRouter } from './api/chat/chat'; import { commandHqRouter } from './api/command-hq/router'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; @@ -149,6 +150,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/queue', queueRouter], ['/bug-report', bugReportRouter], ['/agent-status', agentStatusRouter], + ['/claude-auth', claudeLoginRouter], ['/chat', chatRouter], ['/command-hq', commandHqRouter], ['/pipeline-jobs', pipelineJobsRouter], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index fe1f0f45..0a59d572 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -278,7 +278,17 @@ const CORE_REGISTRY: Permission[] = [ // non-owners wholesale — so an endpoint there could not be read by the accounts that need it. It reports // two booleans about the caller's own home and nothing about anyone else, which is what lets a member be // told "run `claude` once to sign in" instead of being shown a tile that 403s. - api: ['/chat', '/agent-status'], + // `/claude-auth` is the `/login` flow — it spawns `claude auth login` AS THE CALLER and writes the + // credential into the caller's own home. It rides this permission rather than earning its own for the + // reason that makes the pairing exact: an account that may use the agent must be able to authenticate + // the agent, and an account that may not has nothing to authenticate. `confined` then carries the rest + // for free — `authorize.ts` drops a confined grant for an account with no `osUser`, which is precisely + // the account that cannot hold credentials of its own. + // + // NOT under `/auth`, which `totality.ts` exempts from the gate entirely because it is where a caller + // goes to BECOME authenticated. Mounted there, an unauthenticated request could start a login as the + // owner. + api: ['/chat', '/agent-status', '/claude-auth'], // 'chat-notify' is a session-independent socket (the bell feature): it carries no sessionId and no // transcript, just a ping that a chat this account can already reach has finished a turn or needs // input. Riding this permission rather than a new one, because it is meaningless without `chat` diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ClaudeLoginDialog.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ClaudeLoginDialog.tsx new file mode 100644 index 00000000..043d0294 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/ClaudeLoginDialog.tsx @@ -0,0 +1,159 @@ +import { useEffect, useRef, useState } from 'react'; +import { Check, ExternalLink, Loader2 } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { useClient } from 'hooks/useClient'; +import { errorText } from 'helpers/error-text'; + +/* + `/login` — the same flow as the terminal, without the terminal. + + Officer authenticates `claude` with a Claude SUBSCRIPTION over OAuth, which is a browser round trip: + open a URL, authorise, paste a code back. Until now the only way to do that here was to open a terminal + panel and drive the CLI by hand — which works, and is a strange thing to ask of somebody whose whole + session is a browser. + + Three states, and the middle one is the person leaving to do something else: + + starting → waiting for the code → done +*/ + +type Started = { id: string; url: string; expiresInMs: number }; + +type Props = { open: boolean; onOpenChange: (open: boolean) => void }; + +export const ClaudeLoginDialog = ({ open, onOpenChange }: Props) => { + const client = useClient(); + const [started, setStarted] = useState(null); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [done, setDone] = useState(false); + const codeRef = useRef(null); + + // Start on open, and abandon on close. The server holds a live `claude` process per pending login, so a + // dialog dismissed without telling it leaves that process parked on a read of stdin. + useEffect(() => { + if (!open) return; + let cancelled = false; + setStarted(null); + setCode(''); + setError(null); + setDone(false); + void (async () => { + try { + const res = await client.post('/claude-auth', {}); + if (!cancelled) setStarted(res); + } catch (err) { + if (!cancelled) setError(errorText(err, 'could not start the login')); + } + })(); + return () => { + cancelled = true; + }; + }, [open]); + + useEffect(() => { + if (!open && started) void client.delete(`/claude-auth/${started.id}`).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + const submit = async () => { + if (!started || !code.trim() || busy) return; + setBusy(true); + setError(null); + try { + await client.post(`/claude-auth/${started.id}/code`, { code: code.trim() }); + setDone(true); + } catch (err) { + setError(errorText(err, 'that code was not accepted')); + } finally { + setBusy(false); + } + }; + + return ( + + + + Sign in to Claude + + + {done ? ( +
+ + Signed in. New turns use these credentials — no restart needed. +
+ ) : !started ? ( +
+ {error ? ( + {error} + ) : ( + <> + + Asking the CLI for a sign-in link… + + )} +
+ ) : ( +
+
+

+ Open this, authorise, and paste back the code it gives you. +

+ {/* A real anchor. Every link here opens a new tab, which is what you want for this one — + losing the dialog would lose the process holding the other half of the exchange. */} + + Authorise in claude.com + + +
+ +
+ setCode(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void submit(); + }} + placeholder="paste the code" + className="font-mono text-sm" + /> +

+ Expires in {Math.round(started.expiresInMs / 60_000)} minutes. Run /login again if it does. +

+
+ + {error &&

{error}

} + +
+ + +
+
+ )} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx index 9e3b5e8c..4d319da1 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/InputArea.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import type { QueuedPrompt, UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat'; import { ModelSelector } from './ModelSelector'; import { BackgroundJobDialog } from './BackgroundJobDialog'; +import { ClaudeLoginDialog } from './ClaudeLoginDialog'; import { armBackgroundJob } from '../../../background-job-origin'; import { useWorkspace } from '../../../components/Workspace'; import { AttachmentList } from './AttachmentList'; @@ -17,6 +18,7 @@ type InputAreaProps = { }; export const InputArea = ({ manager }: InputAreaProps) => { + const [loginOpen, setLoginOpen] = useState(false); const { input, setInput, @@ -105,7 +107,17 @@ export const InputArea = ({ manager }: InputAreaProps) => { ref={textareaRef} value={input} onChange={(ev) => setInput(ev.target.value)} - onKeyDown={handleKeyDown} + onKeyDown={(ev) => { + // Enter is how anybody actually sends, so the interception has to be here as well as on the + // button. Shift-Enter is a newline and stays one. + if (ev.key === 'Enter' && !ev.shiftKey && input.trim() === '/login') { + ev.preventDefault(); + setInput(''); + setLoginOpen(true); + return; + } + handleKeyDown(ev); + }} onPaste={(ev) => { // Let an image paste fall through to the browser's default (nothing) rather than attaching // something this model will discard. @@ -141,7 +153,16 @@ export const InputArea = ({ manager }: InputAreaProps) => { )}