/login works in the chat window

Officer authenticates claude with a Claude SUBSCRIPTION over OAuth, and that is a browser round trip: open
a URL, authorise, paste a code back. It was the one part of running an agent a browser-only user could not
do from the browser — the answer was "open a terminal panel and drive the CLI by hand", which works, and is
a strange thing to ask of somebody whose entire session is a web page.

It needs no pty. `claude auth login` behaves identically through plain pipes, verified by capturing its raw
bytes, so this is an ordinary subprocess with two reads and one write: spawn, read until the URL, wait for
the person, write the code to stdin.

The parsing is the only non-obvious part. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
again as visible text, so `https://\S+` returns two URLs concatenated with no separator between them.
Stripping the escape wrappers first leaves exactly one. Written against captured bytes rather than against
what the terminal appeared to show.

Three decisions worth keeping:

NOT under /auth. totality.ts exempts that prefix from the permission gate entirely, and correctly — it is
where a caller goes to BECOME authenticated. Mounted there, an unauthenticated POST could have started a
login as the owner. Verified after moving it: unauthenticated POST /api/claude-auth is 401.

On the `chat` grant rather than its own. The pairing is 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.

Nothing Anthropic-shaped is inherited by the child. For the owner those variables point at the local proxy,
whose whole job is swapping a shared secret for the owner's bearer token — handing them to a login is how
you get a success that authenticates nothing. A member's login runs through setpriv --reset-env as their own
Linux user, into their own home, because their turns bill their own subscription and that only holds if the
credential is theirs.

Pending logins are in memory on purpose: one is worthless once the process holding its PKCE verifier dies,
so persisting it would store a row that can only ever be stale. Ten-minute reaper, one per account, and the
dialog abandons its own on close so a dismissed dialog does not leave claude parked on a read of stdin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 20:47:10 +00:00
co-authored by Claude Opus 5
parent 530e5880e1
commit 8917f36be5
5 changed files with 417 additions and 3 deletions
+221
View File
@@ -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 ; ; <uri> 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<typeof setTimeout>;
};
/*
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<string, Pending>();
/*
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<string, unknown>);
const code = typeof body.code === 'string' ? body.code.trim() : '';
if (!code) return ctx.json({ error: 'no code' }, 400);
const finished = new Promise<number | null>((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 });
});
+2
View File
@@ -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<typeof createRouter>
['/queue', queueRouter],
['/bug-report', bugReportRouter],
['/agent-status', agentStatusRouter],
['/claude-auth', claudeLoginRouter],
['/chat', chatRouter],
['/command-hq', commandHqRouter],
['/pipeline-jobs', pipelineJobsRouter],
+11 -1
View File
@@ -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`
@@ -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<Started | null>(null);
const [code, setCode] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
const codeRef = useRef<HTMLInputElement>(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<Started>('/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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Sign in to Claude</DialogTitle>
</DialogHeader>
{done ? (
<div className="flex items-center gap-2 py-4 text-sm">
<Check className="h-4 w-4 text-success" />
Signed in. New turns use these credentials — no restart needed.
</div>
) : !started ? (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
{error ? (
<span className="text-destructive">{error}</span>
) : (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Asking the CLI for a sign-in link…
</>
)}
</div>
) : (
<div className="space-y-4 py-2">
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Open this, authorise, and paste back the code it gives you.
</p>
{/* 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. */}
<a
href={started.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90"
>
Authorise in claude.com
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
<div className="space-y-1">
<Input
ref={codeRef}
autoFocus
value={code}
onChange={(ev) => setCode(ev.target.value)}
onKeyDown={(ev) => {
if (ev.key === 'Enter') void submit();
}}
placeholder="paste the code"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Expires in {Math.round(started.expiresInMs / 60_000)} minutes. Run <code>/login</code> again if it does.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground"
>
Cancel
</button>
<button
type="button"
disabled={!code.trim() || busy}
onClick={() => void submit()}
className="inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-40"
>
{busy && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
Sign in
</button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -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) => {
</Button>
)}
<Button
onClick={handleSend}
onClick={() => {
// `/login` never reaches the agent. It is not a prompt — it is the browser-only user doing the
// one thing that used to need a terminal panel, so the composer answers it locally.
if (input.trim() === '/login') {
setInput('');
setLoginOpen(true);
return;
}
handleSend();
}}
disabled={!input.trim() || !isConnected}
size="icon"
title={isGenerating ? 'Queue — sends when the current turn finishes' : 'Send'}
@@ -169,6 +190,7 @@ export const InputArea = ({ manager }: InputAreaProps) => {
/>
<WebpageDialog open={urlDialogOpen} onOpenChange={setUrlDialogOpen} onSubmit={attachWebpage} />
<ClaudeLoginDialog open={loginOpen} onOpenChange={setLoginOpen} />
<BackgroundJobDialog
open={jobDialogOpen}
onOpenChange={setJobDialogOpen}