diff --git a/packages/adapter-utils/src/setup-token-transport.test.ts b/packages/adapter-utils/src/setup-token-transport.test.ts new file mode 100644 index 0000000000..3644e35581 --- /dev/null +++ b/packages/adapter-utils/src/setup-token-transport.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + createSetupTokenPtyTransport, + type SetupTokenPtySession, +} from "./setup-token-transport.js"; + +// The Enter byte the terminal login UI reads to submit the browser code. The +// login runner appends this byte to the code. The transport forwards the bytes +// unchanged, so the pseudo-terminal receives the code and then the Enter byte. +const ENTER = "\r"; + +/** + * A fake pseudo-terminal session. It records each input write, drives the output + * stream on demand, and records the stop and the close. The tests use it in + * place of a real pseudo-terminal, so the transport runs with no sandbox. + */ +function createFakePtySession(): SetupTokenPtySession & { + writes: string[]; + emit: (chunk: string) => void; + finish: (exitCode: number | null) => void; + killed: number; + closed: number; + // Resolves when the transport registers the output listener. The session + // streams only after it opens, so a test waits on this before it drives the + // output or stops the child. + ready: Promise; +} { + const writes: string[] = []; + let listener: ((chunk: string) => void) | null = null; + let resolveWait: ((value: { exitCode: number | null }) => void) | null = null; + const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => { + resolveWait = resolve; + }); + let markReady: (() => void) | null = null; + const ready = new Promise((resolve) => { + markReady = resolve; + }); + let killed = 0; + let closed = 0; + return { + ready, + onData(next: (chunk: string) => void): void { + listener = next; + markReady?.(); + }, + write(data: string): void { + writes.push(data); + }, + wait(): Promise<{ exitCode: number | null }> { + return waitPromise; + }, + kill(): void { + killed += 1; + }, + async close(): Promise { + closed += 1; + }, + // Test control below. The transport never reads these fields. + emit(chunk: string): void { + listener?.(chunk); + }, + finish(exitCode: number | null): void { + resolveWait?.({ exitCode }); + }, + get writes(): string[] { + return writes; + }, + get killed(): number { + return killed; + }, + get closed(): number { + return closed; + }, + }; +} + +describe("createSetupTokenPtyTransport", () => { + it("delivers delayed input to the process", async () => { + const session = createFakePtySession(); + const transport = createSetupTokenPtyTransport(async () => session); + + const started = transport.start("claude setup-token", () => {}); + await session.ready; + // The input arrives after the start and after the first output, so the + // transport delivers it to the running process, not before it. + session.emit("... the url below to sign in ..."); + transport.write("ABCD"); + session.finish(0); + await started; + + expect(session.writes).toEqual(["ABCD"]); + }); + + it("returns incremental terminal output to the runner", async () => { + const session = createFakePtySession(); + const received: string[] = []; + const transport = createSetupTokenPtyTransport(async () => session); + + const started = transport.start("claude setup-token", (chunk) => { + received.push(chunk); + }); + await session.ready; + + // Each output chunk reaches the runner as the pseudo-terminal emits it, not + // as one final batch at the end. + session.emit("first "); + expect(received).toEqual(["first "]); + session.emit("second"); + expect(received).toEqual(["first ", "second"]); + + session.finish(0); + await started; + }); + + it("delivers the Enter byte after the browser code", async () => { + const session = createFakePtySession(); + const transport = createSetupTokenPtyTransport(async () => session); + + const started = transport.start("claude setup-token", () => {}); + await session.ready; + // The runner writes the code plus the Enter byte in one write. The transport + // forwards the bytes unchanged, so the code comes first and the Enter byte + // comes last. + transport.write("WXYZ" + ENTER); + session.finish(0); + await started; + + const delivered = session.writes.join(""); + expect(delivered).toBe("WXYZ" + ENTER); + expect(delivered.endsWith(ENTER)).toBe(true); + expect(delivered.indexOf("WXYZ")).toBeLessThan(delivered.lastIndexOf(ENTER)); + }); + + it("resolves start with the child exit code", async () => { + const session = createFakePtySession(); + const transport = createSetupTokenPtyTransport(async () => session); + + const started = transport.start("claude setup-token", () => {}); + session.finish(7); + + await expect(started).resolves.toEqual({ exitCode: 7 }); + }); + + it("stops the child with a direct kill", async () => { + const session = createFakePtySession(); + const transport = createSetupTokenPtyTransport(async () => session); + + const started = transport.start("claude setup-token", () => {}); + await session.ready; + transport.stop(); + expect(session.killed).toBe(1); + + session.finish(null); + await started; + }); + + it("disposes the session resources", async () => { + const session = createFakePtySession(); + const transport = createSetupTokenPtyTransport(async () => session); + + const started = transport.start("claude setup-token", () => {}); + session.finish(0); + await started; + + await transport.dispose(); + expect(session.closed).toBe(1); + }); + + it("stays safe when stop and dispose run before start", async () => { + const session = createFakePtySession(); + const transport = createSetupTokenPtyTransport(async () => session); + + // The runner may stop or dispose before it starts the child. The transport + // must not throw, and it must not open a session for a run it already stopped. + transport.stop(); + await transport.dispose(); + + expect(session.killed).toBe(0); + expect(session.closed).toBe(0); + }); + + it("kills a session that opens after an early stop", async () => { + const session = createFakePtySession(); + let resolveOpen!: () => void; + const openGate = new Promise((resolve) => { + resolveOpen = resolve; + }); + const transport = createSetupTokenPtyTransport(async () => { + await openGate; + return session; + }); + + const started = transport.start("claude setup-token", () => {}); + // The stop arrives while the session is still opening. The transport must + // kill the child as soon as the session exists. + transport.stop(); + resolveOpen(); + session.finish(null); + await started; + + expect(session.killed).toBe(1); + }); + + it("buffers input that arrives before the session opens", async () => { + const session = createFakePtySession(); + let resolveOpen!: () => void; + const openGate = new Promise((resolve) => { + resolveOpen = resolve; + }); + const transport = createSetupTokenPtyTransport(async () => { + await openGate; + return session; + }); + + const started = transport.start("claude setup-token", () => {}); + transport.write("EARLY" + ENTER); + expect(session.writes).toEqual([]); + + resolveOpen(); + // Let the pending open resolve and flush the buffered input. + await Promise.resolve(); + await Promise.resolve(); + expect(session.writes).toEqual(["EARLY" + ENTER]); + + session.finish(0); + await started; + }); +}); diff --git a/packages/adapter-utils/src/setup-token-transport.ts b/packages/adapter-utils/src/setup-token-transport.ts new file mode 100644 index 0000000000..af8b0c856c --- /dev/null +++ b/packages/adapter-utils/src/setup-token-transport.ts @@ -0,0 +1,146 @@ +// The setup-token pseudo-terminal transport. It gives the Claude `setup-token` +// login runner a child that runs on a real pseudo-terminal (PTY). The command +// needs a PTY: pipe stdio emits no login prompt. The transport starts the +// command on a PTY, streams the incremental terminal output, delivers delayed +// input, and stops the child for a terminal state. +// +// This module is provider-agnostic and pure. It holds no Node built-in import, +// so the browser-safe root entry can re-export it. A sandbox provider (for +// example the Daytona plugin) opens the concrete pseudo-terminal through a +// {@link SetupTokenPtySessionOpener}. A unit test opens a fake pseudo-terminal +// through the same opener. +// +// Boundary (ANSI and OSC 8): the transport forwards the raw terminal bytes +// unchanged. It runs no ANSI or OSC 8 handling. The setup-token parser owns that +// handling, so the transport keeps every terminal byte intact for the parser. + +/** + * A live pseudo-terminal session for one setup-token login command. A sandbox + * provider opens it. The session allocates a real pseudo-terminal, streams the + * raw terminal output, accepts delayed input, and stops the child. The session + * forwards the raw terminal bytes; it runs no ANSI or OSC 8 handling. + */ +export interface SetupTokenPtySession { + /** + * Registers the one output listener. The session streams each raw terminal + * output chunk to `listener`, in order, as the pseudo-terminal emits it. + */ + onData(listener: (chunk: string) => void): void; + /** + * Writes raw input bytes to the pseudo-terminal. The runner writes the browser + * code plus the Enter byte here, after the command starts. The session + * forwards the bytes unchanged, so the code reaches the pseudo-terminal first + * and the Enter byte reaches it last. + */ + write(data: string): void; + /** Resolves with the child exit code when the command ends. */ + wait(): Promise<{ exitCode: number | null }>; + /** + * Stops the child process with a direct child stop. The characterization + * showed that the child needs `SIGKILL`. The method must be safe to call more + * than one time. + */ + kill(): void; + /** Releases the session resources. The method must be safe to call more than one time. */ + close(): Promise; +} + +/** + * Opens a pseudo-terminal session for `command`. A provider binds this to its + * sandbox. The transport calls it one time, on start. + */ +export type SetupTokenPtySessionOpener = (command: string) => Promise; + +/** + * The child side of the setup-token run, in the shape the login runner needs. + * The transport starts the command on a pseudo-terminal, streams the terminal + * output, delivers delayed input, and stops the child for a terminal state. + * + * The shape matches the runner `SetupTokenPtyDriver` interface, so the runner + * accepts the transport with no adapter. The transport never imports the runner, + * so the runner package keeps its one-way dependency on this package. + */ +export interface SetupTokenPtyTransport { + /** + * Opens the pseudo-terminal session for `command` and streams the terminal + * output to `onData` in order. Resolves with the child exit code when the + * command ends. The transport calls this one time. + */ + start(command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }>; + /** + * Writes `input` to the pseudo-terminal. The runner writes the browser code + * plus the Enter byte one time, after it matches the prompt. An input that + * arrives before the session opens waits in a small buffer, and the transport + * flushes it in order when the session opens. + */ + write(input: string): void; + /** + * Stops the child with a direct child stop. The method is safe to call before + * start and safe to call more than one time. A stop before the session opens + * marks the run, and the transport kills the child as soon as the session + * opens. + */ + stop(): void; + /** Releases the transport resources. The method is safe to call more than one time. */ + dispose(): Promise; +} + +/** + * Creates a {@link SetupTokenPtyTransport} over `open`. The transport adapts a + * pseudo-terminal session into the runner driver shape. It buffers an early + * input write, it honors an early stop, and it forwards the raw terminal bytes + * with no ANSI or OSC 8 handling. + */ +export function createSetupTokenPtyTransport( + open: SetupTokenPtySessionOpener, +): SetupTokenPtyTransport { + let session: SetupTokenPtySession | null = null; + let started = false; + let stopped = false; + // Input that the runner writes before the session opens. The transport flushes + // it in order when the session opens. In the normal flow the runner writes + // only after the first output, so this buffer stays empty. + const pendingInput: string[] = []; + + const flushPendingInput = (): void => { + if (!session) return; + while (pendingInput.length > 0) { + const next = pendingInput.shift(); + if (next !== undefined) session.write(next); + } + }; + + return { + async start(command, onData): Promise<{ exitCode: number | null }> { + if (started) { + throw new Error("setup-token PTY transport already started."); + } + started = true; + const opened = await open(command); + session = opened; + // Register the output listener before any delayed input, so no output + // chunk is missed between the open and the first write. + opened.onData(onData); + flushPendingInput(); + // A stop that arrived while the session was opening kills the child now. + if (stopped) opened.kill(); + return opened.wait(); + }, + write(input): void { + if (session) { + session.write(input); + return; + } + pendingInput.push(input); + }, + stop(): void { + stopped = true; + session?.kill(); + }, + async dispose(): Promise { + // Release only a session that opened. A run that stopped before start + // opens no session, so there is nothing to close. + if (session) await session.close(); + }, + }; +} diff --git a/packages/adapters/claude-local/src/server/__fixtures__/setup-token-success.md b/packages/adapters/claude-local/src/server/__fixtures__/setup-token-success.md new file mode 100644 index 0000000000..fddd92f71b --- /dev/null +++ b/packages/adapters/claude-local/src/server/__fixtures__/setup-token-success.md @@ -0,0 +1,155 @@ +# Claude `setup-token` success characterization fixture + +- Characterized: 2026-08-12 +- Claude Code: `2.1.205` +- Environment: Daytona sandbox (Ubuntu, kernel 6.8), headless, no browser. +- Intended repository path: `packages/adapters/claude-local/src/server/__fixtures__/setup-token-success.md` +- Safety: no real authorization URL, browser code, or token is recorded here. Every + secret is replaced by a `` placeholder. The raw PTY capture that + produced this document was written only to a sandbox-local file and shredded + after redaction. + +This fixture resolves the "Deferred success-token assumption" in +[`setup-token.md`](./setup-token.md): it records a real, end-to-end successful +login through a PTY, with the human completing the browser authorization out of +band. It is the success counterpart to that document, which characterized the +prompt, the URL, and the invalid-code retry path only. + +## Full successful PTY session + +Normalized rendered text (spinner frames, cursor-movement sequences, and other +terminal control sequences omitted; word spacing restored from cursor-forward +sequences): + +```text +Welcome to Claude Code v2.1.205 + +Opening browser to sign in… + +Browser didn't open? Use the url below to sign in (c to copy) + + + +Paste code here if prompted > + +✓ Long-lived authentication token created successfully! + +Your OAuth token (valid for 1 year): + + + +Store this token securely. You won't be able to see it again. + +Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN= +``` + +The command reached the success screen and then closed the PTY and exited on its +own — no host-side kill was required (unlike the invalid-code path in +`setup-token.md`, which stayed at a retry UI and had to be terminated). + +## Authorization URL shape + +Unchanged from `setup-token.md`; confirmed again on the success run: + +- Origin: `https://claude.com` +- Path: `/cai/oauth/authorize` +- Query keys (values redacted): `client_id`, `code`, `code_challenge`, + `code_challenge_method`, `redirect_uri`, `response_type`, `scope`, `state` +- Observed `redirect_uri` value host: `https://platform.claude.com/oauth/code/callback`. +- Observed `scope` value: `user:inference`. Observed `response_type`: `code`. +- Fragment: absent. +- The terminal emits the URL through an OSC 8 hyperlink plus wrapped display + text; consumers must tolerate ANSI/OSC sequences and line wrapping. + +## Code submission mechanics (PTY) + +This is the operational detail that a driver must get right, and it is not +obvious from the prompt characterization alone. + +- The browser code is the value the sign-in page shows after authorization. Its + observed shape is `#` — the `#` delimiter and the + trailing `state` match the `state` query value from the authorization URL. +- **A single PTY write of `code + "\r"` did not submit.** The Claude Code input + is an Ink text field with paste handling: when the code and the carriage + return arrive glued together in one write burst, the trailing `\r` is folded + into the pasted text instead of being read as a Return (submit) key. The stream + stalls at the prompt with the code masked but unsubmitted, and no error is + emitted. +- **A separate, standalone `\r` written after the paste submits on the first + Return.** The reliable sequence is: write the code bytes, let the paste buffer + settle briefly, then write `\r` as its own write. +- Echo: input is masked as `*` characters; the code itself is never rendered by + Claude. (The non-secret `state` suffix may remain visible at the tail of the + masked echo, because it is public — it is the same `state` carried in the URL.) + +> Implementation note for `setup-token-runner.ts`: the runner now writes the +> code and `CODE_SUBMISSION_TERMINATOR` as two separate writes — `driver.write(code)`, +> a short settle delay (`CODE_SUBMIT_SETTLE_MS`), then `driver.write(CODE_SUBMISSION_TERMINATOR)`. +> A single glued `code + "\r"` burst did not submit against this Claude Code +> build: the trailing `\r` folded into the pasted text. The separate Return, +> after the paste buffer settles, submits on the first Return. Confirm the exact +> settle delay in the live end-to-end test. + +## Success token shape (resolves the deferred assumption) + +The provisional assumption in `setup-token.md` — an opaque token shaped like +`sk-ant-oat01-` on the interactive terminal output stream after +successful authorization — is **confirmed**: + +- Prefix: `sk-ant-oat01-` (literal; hyphen-delimited segments `sk`, `ant`, + `oat01`, then the opaque secret). +- The opaque tail is a long secret over the character class `[A-Za-z0-9_-]`; it + may contain `-`, so a parser must not assume the token stops at the first + hyphen or non-alphanumeric after the prefix. +- **Line wrapping:** like the authorization URL, the token is wrapped across + physical terminal lines at the PTY width (an ~80-column PTY split it into two + lines). A parser must de-wrap — join across the wrap boundary — to recover the + full token. Capturing with a wide PTY (e.g. many hundreds of columns) avoids + the split at the source. +- The token is introduced by the exact line `Your OAuth token (valid for 1 + year):` and followed by `Store this token securely. You won't be able to see + it again.` — these bracket the token region and are stable anchors for a + parser. + +## Token delivery and persistence + +- The success token is delivered **only on the interactive terminal (PTY) + stream.** As with the earlier characterization, stdout/stderr are not + independently observable once attached to one PTY. +- `claude setup-token` does **not** persist the token. After a successful run: + - `~/.claude.json` gained no `oauthAccount` and no token-bearing field (it held + only machine/user metadata: `userID`, `machineID`, migration flags, etc.). + - No `~/.claude/.credentials.json` was written. +- Therefore the runner must capture the credential from the terminal stream at + the moment it is printed; there is no on-disk artifact to read afterward, and + the UI explicitly warns the token cannot be shown again. + +## Deferred items now resolved / still open + +Resolved by this capture: + +- Token prefix, delimiter structure, character class, and line-wrapping behavior. +- Success-screen wording and the anchor lines around the token. +- Delivery channel (terminal-only) and the absence of any persisted credential. +- The self-initiated, no-kill exit on success. +- The PTY code-submission gotcha (glued `code + "\r"` vs. a separate Return). + +Still worth confirming in the live end-to-end implementation test before locking +parser/runner assertions: + +- The exact process exit code on success. The child reached EOF and exited on its + own immediately after printing the token (a self-initiated exit consistent with + success); the capture harness issued its normal post-run stop during cleanup, + so an exact numeric code was not force-observed here. `setup-token-runner.ts` + treats `exitCode === 0` as success — assert this against a live run. +- The exact opaque-tail length. It is fixed-format per account but was not pinned + here to avoid recording token-derived specifics. + +## Smallest proof + +One PTY harness drove `claude setup-token` to completion: it captured the +redacted authorization URL, a human completed the browser authorization out of +band, the harness typed the returned code and submitted it with a separate +Return, and Claude Code printed a `sk-ant-oat01-…` token and exited on its own. A +literal scan of this document confirms it contains no authorization query values, +browser code, or live token. diff --git a/packages/adapters/claude-local/src/server/__fixtures__/setup-token.md b/packages/adapters/claude-local/src/server/__fixtures__/setup-token.md new file mode 100644 index 0000000000..88d2f0020b --- /dev/null +++ b/packages/adapters/claude-local/src/server/__fixtures__/setup-token.md @@ -0,0 +1,64 @@ +# Claude `setup-token` characterization fixture + +- Characterized: 2026-08-11 +- Claude Code: `2.1.205` +- Intended repository path: `packages/adapters/claude-local/src/server/__fixtures__/setup-token.md` +- Safety: no real authorization URL, browser code, or token is recorded here. + +## Pipe stdio + +Invocation: all three child streams were pipes; stdin remained open for 8 seconds, then received a syntactically shaped synthetic code followed by LF (`\ +`). + +- Exact emitted prompt text: none. +- stdout: 0 bytes. +- stderr: 0 bytes. +- Input echo: none (the pipe path emitted nothing). +- Result: still running after 23 seconds; controlled termination was required. +- Wrapper exit: `124` from `timeout`; direct child termination in the equivalent harness: `SIGKILL` (`-9`). +- Conclusion: `setup-token` requires a PTY to expose and drive its interactive login UI. Supplying pipe stdin alone is insufficient. + +## PTY stdio + +Normalized rendered text (spinner frames and terminal control sequences omitted): + +```text +Welcome to Claude Code v2.1.205 +Opening browser to sign in… +Browser didn’t open? Use the url below to sign in (c to copy) + +Paste code here if prompted > +``` + +Authorization URL shape: + +- Origin: `https://claude.com` +- Path: `/cai/oauth/authorize` +- Query keys (values redacted): `client_id`, `code`, `code_challenge`, `code_challenge_method`, `redirect_uri`, `response_type`, `scope`, `state` +- Fragment: absent +- The terminal emits the same URL through an OSC 8 hyperlink plus wrapped display text; consumers must tolerate ANSI/OSC sequences and line wrapping. + +Code-entry behavior: + +- Observed code delimiter: `#`. +- Submission terminator: carriage return/Enter in the PTY; the synthetic pipe attempt used LF. +- Echo: input is masked as `*` characters; the synthetic code itself is not rendered by Claude. +- Prompt stream: the PTY’s terminal output stream (stdout/stderr are not independently observable once attached to one PTY). + +Invalid-code result: + +```text +OAuth error: Request failed with status code 400 +Press Enter to retry. +``` + +- The invalid code does not make the command exit; it remains at the retry UI. +- Controlled PTY termination: wrapper exit `124`; direct child termination in the harness: `SIGKILL` (`-9`). + +## Deferred success-token assumption + +No real login or token was captured in this phase. Implementation should provisionally expect an opaque setup token shaped like `sk-ant-oat01-` on the interactive terminal output stream after successful authorization. Both the exact prefix/length/delimiters and whether a non-PTY capture classifies the success line as stdout or stderr remain explicit assumptions. Confirm them once in the final live end-to-end implementation test before locking parser assertions. + +## Smallest proof + +`claude --version`; one pipe harness with a synthetic invalid code; and one PTY harness with bracketed-paste input showed the stream split, redacted URL structure, masked echo, HTTP 400 retry behavior, and controlled terminal exits. A literal scan of this document confirms it contains no authorization query values, browser code, or live token. \ No newline at end of file diff --git a/packages/adapters/claude-local/src/server/index.ts b/packages/adapters/claude-local/src/server/index.ts index 17fea96235..801923f395 100644 --- a/packages/adapters/claude-local/src/server/index.ts +++ b/packages/adapters/claude-local/src/server/index.ts @@ -28,6 +28,41 @@ export { fetchWithTimeout, claudeConfigDir, } from "./quota.js"; +// The Claude `setup-token` login parser. It reads the interactive login output +// and returns the authorization URL and the browser-code prompt, or the minted +// OAuth token from the success record. Both functions fail closed and keep every +// input byte out of each log and each thrown error. +export { + parseSetupTokenPrompt, + parseSetupTokenCredential, + SETUP_TOKEN_PROMPT, + SETUP_TOKEN_URL_ORIGIN, + SETUP_TOKEN_URL_PATH, + SETUP_TOKEN_URL_QUERY_KEYS, + SETUP_TOKEN_PREFIX, + SETUP_TOKEN_BEFORE_ANCHOR, + SETUP_TOKEN_AFTER_ANCHOR, +} from "./setup-token-parse.js"; +export type { SetupTokenPrompt } from "./setup-token-parse.js"; +// The Claude `setup-token` login runner. A server-side factory binds it to a +// sandbox pseudo-terminal driver to drive the two-way login round-trip and to +// deliver the minted token one time in memory. +export { + runSetupTokenLogin, + CLAUDE_SETUP_TOKEN_COMMAND, + CODE_SUBMISSION_TERMINATOR, + CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS, + SETUP_TOKEN_CREDENTIAL_RELEASE_GATE, +} from "./setup-token-runner.js"; +export type { + SetupTokenPtyDriver, + SetupTokenPromptSink, + SetupTokenCodeProvider, + SetupTokenCredentialSink, + SetupTokenOutcome, + SetupTokenLoginResult, + RunSetupTokenLoginOptions, +} from "./setup-token-runner.js"; import type { AdapterSessionCodec } from "@paperclipai/adapter-utils"; import { sessionCodec as acpxSessionCodec } from "@paperclipai/adapter-utils/acpx-engine/session-codec"; diff --git a/packages/adapters/claude-local/src/server/setup-token-parse.test.ts b/packages/adapters/claude-local/src/server/setup-token-parse.test.ts new file mode 100644 index 0000000000..85171dc4b2 --- /dev/null +++ b/packages/adapters/claude-local/src/server/setup-token-parse.test.ts @@ -0,0 +1,295 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + SETUP_TOKEN_AFTER_ANCHOR, + SETUP_TOKEN_BEFORE_ANCHOR, + SETUP_TOKEN_PREFIX, + SETUP_TOKEN_PROMPT, + SETUP_TOKEN_URL_PATH, + SETUP_TOKEN_URL_QUERY_KEYS, + parseSetupTokenCredential, + parseSetupTokenPrompt, +} from "./setup-token-parse.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__"); + +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), "utf8"); +} + +// A well-formed authorization URL that carries the exact query keys of the +// contract. The values are synthetic; the fixture redacts the real values. +const VALID_URL = + "https://claude.com/cai/oauth/authorize" + + "?client_id=cid-000" + + "&code=redacted" + + "&code_challenge=chal-000" + + "&code_challenge_method=S256" + + "&redirect_uri=https%3A%2F%2Fclaude.com%2Fcallback" + + "&response_type=code" + + "&scope=user%3Ainference" + + "&state=state-000"; + +// The URL preamble line and the browser-code prompt line, exactly as the +// fixture records them. +const PREAMBLE_LINE = "Browser didn’t open? Use the url below to sign in (c to copy)"; +const PROMPT_LINE = "Paste code here if prompted >"; + +// Builds a complete, plain-text login output around a URL. +function completeOutput(url: string): string { + return [ + "Welcome to Claude Code v2.1.205", + "Opening browser to sign in…", + PREAMBLE_LINE, + url, + PROMPT_LINE, + ].join("\n"); +} + +// Wraps a URL in an OSC 8 hyperlink. The URI field carries the true URL. The +// display text is a wrapped, truncated form, so the test proves the parser reads +// the URI and not the display text. +function osc8Hyperlink(url: string, display: string): string { + const ESC = "\x1b"; + return `${ESC}]8;;${url}${ESC}\\${display}${ESC}]8;;${ESC}\\`; +} + +describe("parseSetupTokenPrompt", () => { + it("returns the exact URL and prompt from a complete output", () => { + const result = parseSetupTokenPrompt(completeOutput(VALID_URL)); + expect(result).not.toBeNull(); + expect(result?.url).toBe(VALID_URL); + expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT); + }); + + it("returns the URL when the output arrives split across chunks", () => { + // The streaming caller accumulates chunks and re-parses the whole buffer. + // The first chunk holds a partial URL, so the parser returns null. The joined + // buffer holds the whole prompt, so the parser returns the URL. + const chunkA = ["Opening browser to sign in…", PREAMBLE_LINE, "https://claude.com/cai/oauth/aut"].join( + "\n", + ); + const chunkB = ["horize?client_id=cid-000&code=redacted&code_challenge=chal-000&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fclaude.com%2Fcallback&response_type=code&scope=user%3Ainference&state=state-000", PROMPT_LINE, ""].join( + "\n", + ); + expect(parseSetupTokenPrompt(chunkA)).toBeNull(); + const joined = parseSetupTokenPrompt(chunkA + chunkB); + expect(joined).not.toBeNull(); + expect(joined?.url).toBe(VALID_URL); + expect(joined?.prompt).toBe(SETUP_TOKEN_PROMPT); + }); + + it("reads the URL through ANSI and OSC 8 sequences with wrapped display text", () => { + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + // The display text wraps across two lines and truncates the URL. The parser + // must ignore it and read the URI field of the hyperlink. + const wrappedDisplay = "https://claude.com/cai/oauth/aut\nhorize?client_id=cid-000&code=…"; + const text = [ + `${cyan}Opening browser to sign in…${reset}`, + PREAMBLE_LINE, + `${cyan}${osc8Hyperlink(VALID_URL, wrappedDisplay)}${reset}`, + `${cyan}${PROMPT_LINE}${reset}`, + ].join("\n"); + const result = parseSetupTokenPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(VALID_URL); + expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT); + }); + + it("returns null for a wrong origin", () => { + const badOrigin = VALID_URL.replace("https://claude.com", "https://claude.example.com"); + expect(parseSetupTokenPrompt(completeOutput(badOrigin))).toBeNull(); + }); + + it("returns null for a wrong path", () => { + const badPath = VALID_URL.replace(SETUP_TOKEN_URL_PATH, "/cai/oauth/authorise"); + expect(parseSetupTokenPrompt(completeOutput(badPath))).toBeNull(); + }); + + it("returns null for a missing query key", () => { + const missingKey = VALID_URL.replace("&state=state-000", ""); + expect(parseSetupTokenPrompt(completeOutput(missingKey))).toBeNull(); + }); + + it("returns null for an extra query key", () => { + const extraKey = `${VALID_URL}&extra=1`; + expect(parseSetupTokenPrompt(completeOutput(extraKey))).toBeNull(); + }); + + it("returns null for a URL with a fragment", () => { + const withFragment = `${VALID_URL}#section`; + expect(parseSetupTokenPrompt(completeOutput(withFragment))).toBeNull(); + }); + + it("returns null when a code-like value sits on the line after the URL", () => { + // The line right after the URL must hold the browser-code prompt. A code-like + // value on that line cannot bind, so the parser returns null. + const text = [PREAMBLE_LINE, VALID_URL, "ABCD-EFGHJ", PROMPT_LINE].join("\n"); + expect(parseSetupTokenPrompt(text)).toBeNull(); + }); + + it("returns null when the URL is absent from the login context", () => { + const text = [PREAMBLE_LINE, "no url here", PROMPT_LINE].join("\n"); + expect(parseSetupTokenPrompt(text)).toBeNull(); + }); + + it("returns null when the login preamble is absent", () => { + const text = ["Some unrelated output", VALID_URL, PROMPT_LINE].join("\n"); + expect(parseSetupTokenPrompt(text)).toBeNull(); + }); + + it("returns null for an API-key-shaped URL value", () => { + const withKey = VALID_URL.replace("code=redacted", "code=sk-ant-oat01-abc123"); + expect(parseSetupTokenPrompt(completeOutput(withKey))).toBeNull(); + }); + + it("returns null for a non-string input", () => { + expect(parseSetupTokenPrompt(undefined as unknown as string)).toBeNull(); + expect(parseSetupTokenPrompt("")).toBeNull(); + }); + + it("keeps its contract in sync with the characterization fixture", () => { + // The fixture documents the prompt text, the URL path, and the query keys. + // This test fails if the parser contract drifts from the fixture. + const fixture = readFixture("setup-token.md"); + expect(fixture).toContain(SETUP_TOKEN_PROMPT); + expect(fixture).toContain(SETUP_TOKEN_URL_PATH); + for (const key of SETUP_TOKEN_URL_QUERY_KEYS) { + expect(fixture).toContain(`\`${key}\``); + } + }); +}); + +// A synthetic OAuth token. The terminal wraps a real token across two physical +// lines at the pseudo-terminal width. The two fragments join with no separator +// to form the full token. The tail carries a `-` and a `_`, so the test proves +// the parser does not stop at the first hyphen. No real token is present. +const TOKEN_FRAGMENT_A = `${SETUP_TOKEN_PREFIX}AAAABBBBCCCCDDDDEEEE1111`; +const TOKEN_FRAGMENT_B = "2222FFFFGGGG_HHHH-IIII"; +const FULL_TOKEN = `${TOKEN_FRAGMENT_A}${TOKEN_FRAGMENT_B}`; + +// Builds the success screen around a token body. The body holds the token +// fragment lines, already split the way the terminal wraps them. +function successScreen(body: string[]): string { + return [ + "✓ Long-lived authentication token created successfully!", + "", + SETUP_TOKEN_BEFORE_ANCHOR, + "", + ...body, + "", + SETUP_TOKEN_AFTER_ANCHOR, + "", + "Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=", + ].join("\n"); +} + +describe("parseSetupTokenCredential", () => { + it("returns the de-wrapped token from the exact success record once", () => { + const text = successScreen([TOKEN_FRAGMENT_A, TOKEN_FRAGMENT_B]); + expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN); + }); + + it("returns a single-line token from the success record", () => { + const text = successScreen([FULL_TOKEN]); + expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN); + }); + + it("reads the token through ANSI color sequences", () => { + const cyan = "\x1b[36m"; + const reset = "\x1b[0m"; + const text = successScreen([`${cyan}${TOKEN_FRAGMENT_A}`, `${TOKEN_FRAGMENT_B}${reset}`]); + expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN); + }); + + it("returns null for a token-shaped value before submit", () => { + // The prompt screen holds a token-shaped value but no success anchors. The + // parser fails closed, so an early value never delivers. + const text = [ + "Browser didn’t open? Use the url below to sign in (c to copy)", + VALID_URL, + "Paste code here if prompted >", + FULL_TOKEN, + ].join("\n"); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null for a token-shaped value in retry or error text", () => { + const text = [ + "Invalid code. Please try again.", + `error context ${FULL_TOKEN} more context`, + "Paste code here if prompted >", + ].join("\n"); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null for a token-shaped value after cancellation", () => { + const text = ["Login cancelled.", FULL_TOKEN, "Goodbye."].join("\n"); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null when extra text sits on the token line", () => { + // The token line holds the token and extra prose. The parser reads only a + // token fragment on each line, so it fails closed. + const text = successScreen([`${FULL_TOKEN} keep this secret`]); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null when a prose line sits between the anchors", () => { + const text = successScreen([TOKEN_FRAGMENT_A, "a stray note", TOKEN_FRAGMENT_B]); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null for a duplicate success block", () => { + const text = `${successScreen([FULL_TOKEN])}\n${successScreen([FULL_TOKEN])}`; + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null when the after-anchor comes before the before-anchor", () => { + const text = [ + SETUP_TOKEN_AFTER_ANCHOR, + "", + FULL_TOKEN, + "", + SETUP_TOKEN_BEFORE_ANCHOR, + ].join("\n"); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null when the before-anchor is absent", () => { + const text = [FULL_TOKEN, "", SETUP_TOKEN_AFTER_ANCHOR].join("\n"); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null when the after-anchor is absent", () => { + const text = [SETUP_TOKEN_BEFORE_ANCHOR, "", FULL_TOKEN].join("\n"); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null for a wrong token prefix between the anchors", () => { + const text = successScreen(["sk-ant-api03-AAAABBBBCCCCDDDDEEEE1111"]); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null for a bare prefix with no opaque tail", () => { + const text = successScreen([SETUP_TOKEN_PREFIX]); + expect(parseSetupTokenCredential(text)).toBeNull(); + }); + + it("returns null for a non-string input", () => { + expect(parseSetupTokenCredential(undefined as unknown as string)).toBeNull(); + expect(parseSetupTokenCredential("")).toBeNull(); + }); + + it("keeps its token contract in sync with the success fixture", () => { + // The success fixture records the anchor lines and the token prefix. This + // test fails if the token parser contract drifts from the fixture. + const fixture = readFixture("setup-token-success.md"); + expect(fixture).toContain(SETUP_TOKEN_BEFORE_ANCHOR); + expect(fixture).toContain(SETUP_TOKEN_AFTER_ANCHOR); + expect(fixture).toContain(SETUP_TOKEN_PREFIX); + }); +}); diff --git a/packages/adapters/claude-local/src/server/setup-token-parse.ts b/packages/adapters/claude-local/src/server/setup-token-parse.ts new file mode 100644 index 0000000000..0ad6b14bd5 --- /dev/null +++ b/packages/adapters/claude-local/src/server/setup-token-parse.ts @@ -0,0 +1,330 @@ +// The Claude `setup-token` parsers. They read the interactive login output of +// Claude Code. `parseSetupTokenPrompt` returns the authorization URL and the +// browser-code prompt, or null. `parseSetupTokenCredential` returns the minted +// OAuth token from the success screen, or null. +// +// Security (strict validation): the prompt parser accepts only the exact origin +// and path of the authorization URL, and only the exact set of query keys. It +// rejects any other origin, path, query, or fragment. It binds the browser-code +// prompt to a dedicated line right after the URL line, so an unrelated code-like +// value on the wrong line cannot form a prompt. It rejects an API-key-shaped +// value inside the URL. +// +// The token parser binds the token to the exact success record: it requires the +// before-anchor line and the after-anchor line, in that order, exactly one time +// each, and it reads only the lines between them. It de-wraps the token across +// the physical terminal lines. It fails closed for any other input. Both parsers +// never log any input byte, and they keep every input byte out of each thrown +// error. Both parsers are pure functions. + +export interface SetupTokenPrompt { + // The validated authorization URL, exactly as the terminal emitted it. + url: string; + // The canonical browser-code prompt. The parser returns the constant + // {@link SETUP_TOKEN_PROMPT}, so the output is never a caller-controlled value. + prompt: string; +} + +// The canonical browser-code prompt text. The Claude login UI prints this line +// to ask the user to paste the code from the browser. The parser returns this +// exact constant on a match. +export const SETUP_TOKEN_PROMPT = "Paste code here if prompted"; + +// The one and only accepted authorization URL origin. +export const SETUP_TOKEN_URL_ORIGIN = "https://claude.com"; + +// The one and only accepted authorization URL path. +export const SETUP_TOKEN_URL_PATH = "/cai/oauth/authorize"; + +// The exact set of query keys of the authorization URL. The parser accepts the +// URL only when its query keys equal this set: no missing key, no extra key, and +// no duplicate key. The parser does not validate the query values, because the +// values are the real OAuth PKCE parameters that the user must visit. +export const SETUP_TOKEN_URL_QUERY_KEYS = [ + "client_id", + "code", + "code_challenge", + "code_challenge_method", + "redirect_uri", + "response_type", + "scope", + "state", +] as const; + +// An ANSI Control Sequence Introducer (CSI): the ESC control byte, a `[`, zero +// or more parameter bytes (0x30-0x3F), zero or more intermediate bytes +// (0x20-0x2F), and one final byte (0x40-0x7E). The Claude terminal wraps the +// output in CSI color sequences. The parser removes every CSI sequence first, so +// a colored prompt reads the same as a plain one. +// eslint-disable-next-line no-control-regex +const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g; + +// An OSC 8 hyperlink. The terminal emits the URL through an OSC 8 hyperlink plus +// wrapped display text. The hyperlink carries the true, unwrapped URL in its URI +// field, while the display text may wrap across lines. The parser replaces the +// whole hyperlink (open sequence, display text, close sequence) with the URI, so +// it reads the unwrapped URL and drops the wrapped display text. The URI field +// is the first capture group. The terminator of each OSC sequence is the String +// Terminator (ESC `\`) or the BEL byte. +// eslint-disable-next-line no-control-regex +const OSC8_HYPERLINK_RE = + /\x1b\]8;[^;\x1b\x07]*;([^\x1b\x07]*)(?:\x1b\\|\x07)[\s\S]*?\x1b\]8;[^;\x1b\x07]*;(?:\x1b\\|\x07)/g; + +// Any remaining OSC sequence. A partial output chunk can hold an OSC 8 open +// sequence without its close sequence. The parser removes each leftover OSC +// sequence after it replaces the complete hyperlinks. +// eslint-disable-next-line no-control-regex +const OSC_ANY_RE = /\x1b\][^\x1b\x07]*(?:\x1b\\|\x07)/g; + +// A candidate URL token: a run of non-space characters that starts with an http +// or https scheme. The parser validates each candidate with the `URL` class; the +// regular expression only splits tokens out of the text. +const URL_TOKEN_RE = /https?:\/\/\S+/g; + +// Trailing punctuation that prose commonly puts right after a URL. The parser +// strips only these characters. It never strips `?` or `#`, so a URL with a +// stray query or a fragment stays malformed and the parser rejects it. +const TRAILING_PUNCTUATION_RE = /[)\].,;:!]+$/; + +// The line that introduces the URL. The Claude login UI prints this phrase right +// before the URL. The parser anchors the URL search on this phrase, so a URL far +// from the login context cannot bind. +const URL_PREAMBLE_RE = /the url below to sign in/i; + +// The browser-code prompt on a dedicated line. The line starts with the exact +// prompt phrase. The line can end with the input caret `>` and masked echo `*` +// characters and spaces. The pattern rejects the phrase when other prose shares +// the line. +const PROMPT_LINE_RE = /^Paste code here if prompted\b[\s>*]*$/; + +// An API-key-shaped value. The Claude setup token and API key start with the +// `sk-ant-` prefix. The parser rejects a URL that embeds such a value, so a +// secret cannot ride inside the authorization URL. +const API_KEY_RE = /sk-ant-[a-z0-9-]{2,}/i; + +// The maximum number of characters between the end of the URL preamble line and +// the start of the URL. The Claude UI prints the URL right after the preamble. +// The parser looks for the URL only inside this window, so a URL far from the +// preamble cannot bind. +const MAX_PREAMBLE_TO_URL_GAP = 512; + +// The maximum number of characters after the end of the URL line that the parser +// reads for the browser-code prompt. The Claude UI prints the prompt one line +// after the URL. The window is large enough for the real prompt line and small +// enough to reject distant noise. +const MAX_URL_TO_PROMPT_GAP = 256; + +// The result of a URL search: the validated URL and the index of the first +// character after the matched URL token in the search window. +interface SetupTokenUrlMatch { + url: string; + end: number; +} + +/** + * Removes the terminal control sequences from `text`. Replaces each OSC 8 + * hyperlink with its true URI, so the parser reads the unwrapped URL and drops + * the wrapped display text. Removes each leftover OSC sequence and each ANSI CSI + * sequence. Returns plain text. The function only normalizes the input; the URL + * and the prompt still pass the strict validation below. + */ +function stripTerminalControls(text: string): string { + return text + .replace(OSC8_HYPERLINK_RE, "$1") + .replace(OSC_ANY_RE, "") + .replace(ANSI_CSI_RE, ""); +} + +/** + * Returns the slice of `text` that starts at `start` and holds at most + * `maxLength` characters. The parser uses this window to bound a gap between two + * anchors, so a distant match cannot bind. + */ +function gapWindow(text: string, start: number, maxLength: number): string { + return text.slice(start, start + maxLength); +} + +/** + * Returns true when the query keys of `parsed` equal + * {@link SETUP_TOKEN_URL_QUERY_KEYS} exactly: no missing key, no extra key, and + * no duplicate key. + */ +function hasExactQueryKeys(parsed: URL): boolean { + const keys = [...parsed.searchParams.keys()]; + if (keys.length !== SETUP_TOKEN_URL_QUERY_KEYS.length) return false; + const seen = new Set(keys); + if (seen.size !== keys.length) return false; + return SETUP_TOKEN_URL_QUERY_KEYS.every((key) => seen.has(key)); +} + +/** + * Returns the authorization URL and its end index when `window` holds it with + * the exact origin {@link SETUP_TOKEN_URL_ORIGIN}, the exact path + * {@link SETUP_TOKEN_URL_PATH}, the exact query keys, no fragment, and no + * credentials. Rejects a URL that embeds an API-key-shaped value. Returns null + * otherwise. The `end` index is the position of the first character after the + * matched token in `window`. + */ +function findExactUrl(window: string): SetupTokenUrlMatch | null { + for (const match of window.matchAll(URL_TOKEN_RE)) { + const token = match[0]; + if (API_KEY_RE.test(token)) continue; + const cleaned = token.replace(TRAILING_PUNCTUATION_RE, ""); + let parsed: URL; + try { + parsed = new URL(cleaned); + } catch { + continue; + } + if ( + parsed.protocol === "https:" && + parsed.host === "claude.com" && + parsed.pathname === SETUP_TOKEN_URL_PATH && + parsed.hash === "" && + parsed.username === "" && + parsed.password === "" && + hasExactQueryKeys(parsed) + ) { + return { url: cleaned, end: (match.index ?? 0) + token.length }; + } + } + return null; +} + +/** + * Returns the canonical browser-code prompt when `text` holds it on a dedicated + * line after the URL line. The parser advances past the URL line to the first + * newline, then reads the first non-blank line inside a window after it. That + * line must match the exact prompt shape. So the prompt binds to the dedicated + * line that the UI prints right after the URL. A code-like value on that line, or + * the prompt phrase mixed with other prose, cannot bind. Returns null when the + * URL line has no line break after it, or when the first non-blank line is not + * the prompt. + */ +function findBrowserCodePrompt(text: string, urlEnd: number): string | null { + const lineBreak = text.indexOf("\n", urlEnd); + if (lineBreak === -1) return null; + const window = gapWindow(text, lineBreak + 1, MAX_URL_TO_PROMPT_GAP); + for (const line of window.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + return PROMPT_LINE_RE.test(trimmed) ? SETUP_TOKEN_PROMPT : null; + } + return null; +} + +/** + * Parses Claude `setup-token` login output. Removes ANSI CSI sequences and OSC 8 + * hyperlink sequences first, so colored and hyperlinked output reads the same as + * plain output. The parser finds the URL preamble, then the exact authorization + * URL inside a window after the preamble, then the browser-code prompt on the + * dedicated line after the URL line. So the URL binds to the login context and + * the prompt binds to the URL. Returns the authorization URL and the browser-code + * prompt when both are present and valid. Returns null for any other input, + * including a non-string input, an absent preamble, a URL with a wrong origin, + * path, query, or fragment, an API-key-shaped URL, and a missing or misplaced + * prompt. Never throws on input, and never puts any input byte into a log or an + * error. This phase returns no token. + */ +export function parseSetupTokenPrompt(text: string): SetupTokenPrompt | null { + if (typeof text !== "string" || text.length === 0) return null; + const clean = stripTerminalControls(text); + const preamble = URL_PREAMBLE_RE.exec(clean); + if (!preamble) return null; + const afterPreamble = preamble.index + preamble[0].length; + const urlWindow = gapWindow(clean, afterPreamble, MAX_PREAMBLE_TO_URL_GAP); + const urlMatch = findExactUrl(urlWindow); + if (!urlMatch) return null; + const urlEnd = afterPreamble + urlMatch.end; + const prompt = findBrowserCodePrompt(clean, urlEnd); + if (!prompt) return null; + return { url: urlMatch.url, prompt }; +} + +// --- The success token parser ------------------------------------------------ + +// The literal prefix of the Claude setup-token OAuth token. The success screen +// prints a token that starts with this exact prefix. The opaque tail follows. +export const SETUP_TOKEN_PREFIX = "sk-ant-oat01-"; + +// The line that introduces the token. The success screen prints this exact line +// right before the token block. The token parser binds the token to this anchor. +export const SETUP_TOKEN_BEFORE_ANCHOR = "Your OAuth token (valid for 1 year):"; + +// The line that follows the token. The success screen prints this exact line +// right after the token block. The token parser binds the token to this anchor. +export const SETUP_TOKEN_AFTER_ANCHOR = + "Store this token securely. You won't be able to see it again."; + +// One physical line of the token. The terminal wraps the token at the +// pseudo-terminal width, so one physical line holds one fragment of the token +// character class. The token character class is `[A-Za-z0-9_-]`; the opaque tail +// can contain `-`, so a line fragment can contain `-`. +const TOKEN_FRAGMENT_RE = /^[A-Za-z0-9_-]+$/; + +// The full token shape after the parser joins the wrapped fragments. The token +// starts with the exact prefix and continues over the token character class. The +// minimum tail length rejects a bare prefix and a short, noisy candidate. A real +// opaque tail is far longer than this floor. +const FULL_TOKEN_RE = /^sk-ant-oat01-[A-Za-z0-9_-]{20,}$/; + +/** + * Returns the index of the one line that equals `anchor` after a trim. Returns + * null when no line matches. Returns null when more than one line matches, so a + * duplicate success block fails closed. + */ +function singleAnchorIndex(lines: string[], anchor: string): number | null { + let found: number | null = null; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].trim() !== anchor) continue; + if (found !== null) return null; + found = i; + } + return found; +} + +/** + * Parses the Claude `setup-token` success screen and returns the minted OAuth + * token, or null. The parser removes the terminal control sequences first, then + * binds the token to the exact success record. It requires the before-anchor + * line {@link SETUP_TOKEN_BEFORE_ANCHOR} and the after-anchor line + * {@link SETUP_TOKEN_AFTER_ANCHOR}, in that order, exactly one time each. It + * reads only the lines between the two anchors. It de-wraps the token: it joins + * the fragment lines with no separator, the same way the terminal split one + * token across the physical lines. It validates the joined value against the + * token shape. + * + * The parser fails closed for any other input. It returns null for a + * token-shaped value outside the two anchors (before submit, in retry or error + * text, or after a cancellation), a duplicate success block, an anchor out of + * order, a missing anchor, extra text on a token line, a stray prose line + * between the anchors, a wrong prefix, and a value that does not match the token + * shape. It never logs any input byte and never puts any input byte into a + * thrown error. The parser is a pure function. + */ +export function parseSetupTokenCredential(text: string): string | null { + if (typeof text !== "string" || text.length === 0) return null; + const clean = stripTerminalControls(text); + const lines = clean.split("\n"); + + const beforeIndex = singleAnchorIndex(lines, SETUP_TOKEN_BEFORE_ANCHOR); + const afterIndex = singleAnchorIndex(lines, SETUP_TOKEN_AFTER_ANCHOR); + if (beforeIndex === null || afterIndex === null) return null; + if (afterIndex <= beforeIndex) return null; + + // Read only the non-blank lines strictly between the two anchors. Each line + // must be one token fragment. A stray prose line, or extra text on a token + // line, fails the fragment test and the parser returns null. + const fragments: string[] = []; + for (let i = beforeIndex + 1; i < afterIndex; i += 1) { + const trimmed = lines[i].trim(); + if (trimmed.length === 0) continue; + if (!TOKEN_FRAGMENT_RE.test(trimmed)) return null; + fragments.push(trimmed); + } + if (fragments.length === 0) return null; + + const token = fragments.join(""); + if (!FULL_TOKEN_RE.test(token)) return null; + return token; +} diff --git a/packages/adapters/claude-local/src/server/setup-token-runner.test.ts b/packages/adapters/claude-local/src/server/setup-token-runner.test.ts new file mode 100644 index 0000000000..7e4cd2db2e --- /dev/null +++ b/packages/adapters/claude-local/src/server/setup-token-runner.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, vi } from "vitest"; +import { SETUP_TOKEN_PROMPT } from "./setup-token-parse.js"; +import { + CLAUDE_SETUP_TOKEN_COMMAND, + CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS, + CODE_SUBMISSION_TERMINATOR, + runSetupTokenLogin, + type SetupTokenPtyDriver, +} from "./setup-token-runner.js"; + +// A well-formed authorization URL. The query keys match the contract exactly. +// The values are synthetic; no real value is present. +const VALID_URL = + "https://claude.com/cai/oauth/authorize?client_id=cid&code=abc&code_challenge=chal&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fexample.test%2Fcb&response_type=code&scope=user&state=st"; + +// The rendered login output. The preamble line, the URL line, and the dedicated +// prompt line match the Phase 2 parser contract. +const PROMPT_OUTPUT = [ + "Welcome to Claude Code", + "Opening browser to sign in…", + "Browser didn't open? Use the url below to sign in (c to copy)", + VALID_URL, + "Paste code here if prompted >", + "", +].join("\n"); + +// A synthetic browser code. No test log, result, error, or metadata field may +// contain this value. +const BROWSER_CODE = "SENTINEL-CODE-9182"; + +// A synthetic OAuth token, wrapped across two physical lines the way the +// terminal wraps a real token. No real token is present. +const TOKEN_FRAGMENT_A = "sk-ant-oat01-AAAABBBBCCCCDDDDEEEE1111"; +const TOKEN_FRAGMENT_B = "2222FFFFGGGG_HHHH-IIII"; +const FULL_TOKEN = `${TOKEN_FRAGMENT_A}${TOKEN_FRAGMENT_B}`; + +// The rendered success screen. The anchor lines bracket the wrapped token, the +// same way the Phase 6a success record records them. +const SUCCESS_OUTPUT = [ + "✓ Long-lived authentication token created successfully!", + "", + "Your OAuth token (valid for 1 year):", + "", + TOKEN_FRAGMENT_A, + TOKEN_FRAGMENT_B, + "", + "Store this token securely. You won't be able to see it again.", + "", +].join("\n"); + +interface FakeDriverOptions { + // The chunks the driver streams to the runner in order. + chunks?: string[]; + // The exit code the driver returns at once. When absent, the driver waits for + // the test to resolve the exit, or for the runner to time out or cancel. + exitCode?: number; + // An error the driver throws from start(), to model a stream failure. + startError?: Error; +} + +function createFakeDriver(options: FakeDriverOptions = {}) { + const writes: string[] = []; + const stops = { count: 0 }; + const disposes = { count: 0 }; + let resolveExit: (exit: { exitCode: number | null }) => void = () => {}; + const exitPromise = new Promise<{ exitCode: number | null }>((resolve) => { + resolveExit = resolve; + }); + const driver: SetupTokenPtyDriver = { + async start(_command, onData) { + if (options.startError) throw options.startError; + for (const chunk of options.chunks ?? []) { + onData(chunk); + } + if (options.exitCode !== undefined) { + return { exitCode: options.exitCode }; + } + return exitPromise; + }, + write(input) { + writes.push(input); + }, + stop() { + stops.count += 1; + }, + async dispose() { + disposes.count += 1; + }, + }; + return { driver, writes, stops, disposes, resolveExit: (code: number) => resolveExit({ exitCode: code }) }; +} + +// Lets the pending microtasks run, so the code-input routine writes to the PTY. +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("runSetupTokenLogin", () => { + it("sends one login URL through onPrompt", async () => { + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT], exitCode: 0 }); + const onPrompt = vi.fn(); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt, + provideCode: async () => BROWSER_CODE, + timeoutMs: 1000, + }); + expect(result.outcome).toBe("success"); + expect(result.promptSurfaced).toBe(true); + expect(onPrompt).toHaveBeenCalledTimes(1); + expect(onPrompt).toHaveBeenCalledWith({ url: VALID_URL, prompt: SETUP_TOKEN_PROMPT }); + }); + + it("accepts one browser code and sends it to the prompt", async () => { + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT] }); + const onPrompt = vi.fn(); + const provideCode = vi.fn(async () => BROWSER_CODE); + const promise = runSetupTokenLogin(fake.driver, { + onPrompt, + provideCode, + timeoutMs: 1000, + codeSubmitSettleMs: 0, + }); + await flush(); + // The runner writes the code once, only after it matches the prompt. It writes + // the code and the terminator as two separate writes, so the terminator reads + // as a distinct Return key against an Ink paste field. + expect(provideCode).toHaveBeenCalledTimes(1); + expect(fake.writes).toEqual([BROWSER_CODE, CODE_SUBMISSION_TERMINATOR]); + fake.resolveExit(0); + const result = await promise; + expect(result.outcome).toBe("success"); + expect(result.codeSubmitted).toBe(true); + }); + + it("never writes the code before it matches the prompt", async () => { + // The driver streams noise with no prompt, then exits. The runner must never + // send the code, because it never matched the prompt. + const fake = createFakeDriver({ chunks: ["unrelated output\n"], exitCode: 0 }); + const provideCode = vi.fn(async () => BROWSER_CODE); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode, + timeoutMs: 1000, + }); + expect(result.promptSurfaced).toBe(false); + expect(result.codeSubmitted).toBe(false); + expect(provideCode).not.toHaveBeenCalled(); + expect(fake.writes).toEqual([]); + }); + + it("stops the process on a timeout", async () => { + const fake = createFakeDriver(); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + timeoutMs: 20, + }); + expect(result.outcome).toBe("timeout"); + expect(fake.stops.count).toBe(1); + expect(fake.disposes.count).toBe(1); + }); + + it("stops the process on a cancellation", async () => { + const fake = createFakeDriver(); + const controller = new AbortController(); + const promise = runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + timeoutMs: 5000, + signal: controller.signal, + }); + controller.abort(); + const result = await promise; + expect(result.outcome).toBe("cancelled"); + expect(fake.stops.count).toBe(1); + expect(fake.disposes.count).toBe(1); + }); + + it("stops the process on a nonzero exit", async () => { + const fake = createFakeDriver({ exitCode: 7 }); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + timeoutMs: 1000, + }); + expect(result.outcome).toBe("failure"); + expect(result.exitCode).toBe(7); + expect(fake.stops.count).toBe(1); + expect(fake.disposes.count).toBe(1); + }); + + it("keeps the code out of every log, result, and error field", async () => { + const logs: string[] = []; + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT], exitCode: 0 }); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + timeoutMs: 1000, + log: (line) => { + logs.push(line); + }, + }); + const haystack = `${logs.join("\n")}\n${JSON.stringify(result)}`; + expect(haystack).not.toContain(BROWSER_CODE); + expect(haystack).not.toContain(VALID_URL); + }); + + it("keeps the code out of the thrown error on a stream failure", async () => { + const fake = createFakeDriver({ + startError: new Error(`stream failure with ${VALID_URL} ${BROWSER_CODE}`), + }); + let caught: unknown; + try { + await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + timeoutMs: 1000, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).not.toContain(VALID_URL); + expect(message).not.toContain(BROWSER_CODE); + expect(fake.stops.count).toBe(1); + expect(fake.disposes.count).toBe(1); + }); + + it("parses an early prompt inside one large chunk before truncation", async () => { + // One chunk holds the prompt at its start and a large volume of output after + // it. The chunk is larger than the retained-buffer limit. The runner parses + // the whole chunk before it trims the retained window, so it still finds the + // early prompt and never drops it. + const trailingNoise = "unrelated output line\n".repeat( + Math.ceil(CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS / 10), + ); + const fake = createFakeDriver({ chunks: [`${PROMPT_OUTPUT}${trailingNoise}`], exitCode: 0 }); + const onPrompt = vi.fn(); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt, + provideCode: async () => BROWSER_CODE, + timeoutMs: 1000, + }); + expect(result.promptSurfaced).toBe(true); + expect(onPrompt).toHaveBeenCalledTimes(1); + expect(onPrompt).toHaveBeenCalledWith({ url: VALID_URL, prompt: SETUP_TOKEN_PROMPT }); + }); + + it("delivers no token when the success stream has no token block", async () => { + // The stream holds only the prompt, so the token parser binds nothing. The + // runner delivers no credential and reports credentialDelivered false. + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT], exitCode: 0 }); + const onCredential = vi.fn(); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + onCredential, + timeoutMs: 1000, + }); + expect(result.outcome).toBe("success"); + expect(onCredential).not.toHaveBeenCalled(); + expect(result.credentialDelivered).toBe(false); + expect(result).not.toHaveProperty("token"); + expect(result).not.toHaveProperty("credential"); + }); + + it("delivers the de-wrapped token once through onCredential on a success stream", async () => { + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 }); + const onCredential = vi.fn(); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + onCredential, + timeoutMs: 1000, + }); + expect(result.outcome).toBe("success"); + expect(result.credentialDelivered).toBe(true); + expect(onCredential).toHaveBeenCalledTimes(1); + const bytes = onCredential.mock.calls[0][0] as Buffer; + expect(Buffer.isBuffer(bytes)).toBe(true); + expect(bytes.toString("utf8")).toBe(FULL_TOKEN); + }); + + it("keeps the token out of every log, result, and error field", async () => { + const logs: string[] = []; + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 }); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + onCredential: () => {}, + timeoutMs: 1000, + log: (line) => { + logs.push(line); + }, + }); + const haystack = `${logs.join("\n")}\n${JSON.stringify(result)}`; + expect(haystack).not.toContain(FULL_TOKEN); + expect(haystack).not.toContain(TOKEN_FRAGMENT_A); + expect(result.credentialDelivered).toBe(true); + }); + + it("never scans for the token when no onCredential sink is present", async () => { + // With no sink the runner never holds the post-prompt stream and delivers + // nothing, even when the success block is present. + const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 }); + const result = await runSetupTokenLogin(fake.driver, { + onPrompt: () => {}, + provideCode: async () => BROWSER_CODE, + timeoutMs: 1000, + }); + expect(result.outcome).toBe("success"); + expect(result.credentialDelivered).toBe(false); + }); + + it("exposes the fixed setup-token command", () => { + expect(CLAUDE_SETUP_TOKEN_COMMAND).toBe("claude setup-token"); + }); +}); diff --git a/packages/adapters/claude-local/src/server/setup-token-runner.ts b/packages/adapters/claude-local/src/server/setup-token-runner.ts new file mode 100644 index 0000000000..1d06a46e98 --- /dev/null +++ b/packages/adapters/claude-local/src/server/setup-token-runner.ts @@ -0,0 +1,416 @@ +import { + parseSetupTokenCredential, + parseSetupTokenPrompt, + type SetupTokenPrompt, +} from "./setup-token-parse.js"; + +// The Claude `setup-token` login runner. It starts `claude setup-token` through +// an injected {@link SetupTokenPtyDriver}, surfaces the sign-in prompt one time +// in memory, accepts one browser code, sends the code to the matched prompt, and +// delivers the minted OAuth token one time. It handles a timeout and a +// cancellation, and it stops the child for every terminal state. +// +// Security (secret handling): the runner treats every byte of the terminal +// stream as secret-bearing, untrusted input. It parses the stream in an in-memory +// buffer only. It drops the prompt buffer as soon as it finds the prompt, and it +// drops the token buffer as soon as it binds the token. It never forwards the raw +// text to a log or an artifact, and it never stores the raw text on the result. +// The runner reports only a fixed, non-secret status. It passes the prompt one +// time through the in-memory `onPrompt` callback. It reads the browser code one +// time through the in-memory `provideCode` callback and writes the code only to +// the child, only after it matches the prompt. It delivers the token one time +// through the in-memory `onCredential` callback. The runner keeps the URL, the +// code, and any token byte out of every log line and every thrown error. +// +// Token delivery binds to the success record. The runner scans the post-prompt +// stream with {@link parseSetupTokenCredential}, which returns the token only +// from the exact success record. The {@link SETUP_TOKEN_CREDENTIAL_RELEASE_GATE} +// stays open only while a caller provides an `onCredential` sink; without a sink +// the runner never scans for the token. + +/** The fixed Claude setup-token command. The login flow needs a PTY. */ +export const CLAUDE_SETUP_TOKEN_COMMAND = "claude setup-token"; + +/** + * The submission terminator for the browser code. The interactive login UI reads + * the code on a PTY and submits it on a carriage return. The runner writes this + * terminator as its own write, after the code write and a short settle delay. + * The live characterization showed that a single write of `code + "\r"` does not + * submit: the input is an Ink text field with paste handling, so a glued + * carriage return folds into the pasted text instead of a Return key, and the + * login stalls at the prompt. Two writes with a settle gap submit on the first + * Return. + */ +export const CODE_SUBMISSION_TERMINATOR = "\r"; + +/** + * The default settle delay in milliseconds between the code write and the + * terminator write. The delay lets the Ink paste buffer settle, so the terminator + * arrives as a distinct Return key. A caller can override it; a test sets it to 0. + */ +export const CODE_SUBMIT_SETTLE_MS = 150; + +/** + * The maximum number of characters the runner keeps for the next chunk. The + * login prompt is small and puts the URL and the prompt line close together. A + * sandbox can stream a large volume of output before the prompt. So after each + * parse the runner keeps only the most recent characters up to this limit. The + * retained buffer cannot grow without a bound across many chunks. The limit is + * far larger than the prompt, so the trailing window never drops a real prompt + * that spans a chunk boundary. + */ +export const CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS = 64 * 1024; + +/** + * The release gate for the credential seam. The gate is open, so the runner + * delivers the token that {@link parseSetupTokenCredential} binds from the + * success record. The runner still scans for the token only when a caller + * provides an `onCredential` sink. + */ +export const SETUP_TOKEN_CREDENTIAL_RELEASE_GATE: boolean = true; + +/** + * The child side of the setup-token run. The runner never spawns the PTY + * directly; a caller injects a concrete driver. A production driver binds these + * methods to a PTY child that runs {@link CLAUDE_SETUP_TOKEN_COMMAND}. + */ +export interface SetupTokenPtyDriver { + /** + * Starts `command` in a PTY and streams the terminal output to `onData` in + * memory. Resolves with the child exit code when the child ends. A driver must + * not persist the raw output to any durable log. + */ + start(command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }>; + /** + * Writes `input` to the child PTY. The runner writes the browser code plus the + * submission terminator one time, only after it matches the prompt. + */ + write(input: string): void; + /** + * Stops the child process with a direct child stop. The characterization showed + * that the child needs `SIGKILL`. The method must be safe to call before start + * and safe to call more than one time. + */ + stop(): void; + /** Releases the driver resources. */ + dispose(): Promise; +} + +/** Receives the parsed prompt one time in memory. The caller displays it. */ +export type SetupTokenPromptSink = (prompt: SetupTokenPrompt) => void; + +/** + * Returns the one browser code the user pastes from the sign-in page. The runner + * calls it one time, only after it surfaces the prompt. The runner passes an + * abort signal, so the provider can stop when the runner cancels or times out. + */ +export type SetupTokenCodeProvider = (signal: AbortSignal) => Promise; + +/** + * Receives the credential bytes one time in memory on success. The runner binds + * the token from the success record, then it invokes this sink one time with the + * token bytes. The runner never logs the bytes and never stores them on the + * result. + */ +export type SetupTokenCredentialSink = (authBytes: Buffer) => void | Promise; + +export type SetupTokenOutcome = "success" | "failure" | "timeout" | "cancelled"; + +/** The runner result. It never carries a URL, a code, or a token byte. */ +export interface SetupTokenLoginResult { + outcome: SetupTokenOutcome; + exitCode: number | null; + promptSurfaced: boolean; + codeSubmitted: boolean; + /** True when the runner bound the token and invoked `onCredential` one time. */ + credentialDelivered: boolean; +} + +export interface RunSetupTokenLoginOptions { + /** The login command. Defaults to {@link CLAUDE_SETUP_TOKEN_COMMAND}. */ + command?: string; + /** Receives the parsed prompt one time in memory. The caller displays it. */ + onPrompt: SetupTokenPromptSink; + /** Returns the one browser code. The runner writes it to the matched prompt. */ + provideCode: SetupTokenCodeProvider; + /** The credential sink. The runner invokes it one time with the token bytes. */ + onCredential?: SetupTokenCredentialSink; + /** + * The settle delay in milliseconds between the code write and the terminator + * write. Defaults to {@link CODE_SUBMIT_SETTLE_MS}. A test sets it to 0. + */ + codeSubmitSettleMs?: number; + /** The host-side timeout in milliseconds. */ + timeoutMs: number; + /** An optional cancellation signal. */ + signal?: AbortSignal; + /** A non-leaking progress sink. It receives only fixed status lines. */ + log?: (line: string) => void; +} + +/** + * Waits `ms` milliseconds, or resolves at once when the signal aborts. The + * routine lets the Ink paste buffer settle between the code write and the + * terminator write. It never rejects; a caller checks the signal after it + * resolves. A zero or negative delay resolves on the next microtask. + */ +function settleDelay(ms: number, signal: AbortSignal): Promise { + if (ms <= 0 || signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + signal.addEventListener("abort", done, { once: true }); + }); +} + +type RaceResult = + | { kind: "exit"; exitCode: number | null } + | { kind: "timeout" } + | { kind: "cancelled" }; + +/** + * Races the streaming start against the timeout and the cancellation signal. The + * start result resolves the race; the timeout and the signal resolve the race + * with a terminal status. A driver error rejects the race, so the caller can + * convert it to a fixed, non-secret error. A late start rejection after the race + * already settled is consumed here, so it never becomes an unhandled rejection. + */ +function raceStart( + start: Promise<{ exitCode: number | null }>, + timeoutMs: number, + signal: AbortSignal, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timer); + signal.removeEventListener("abort", onAbort); + }; + const finish = (run: () => void) => { + if (settled) return; + settled = true; + cleanup(); + run(); + }; + const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs); + const onAbort = () => finish(() => resolve({ kind: "cancelled" })); + signal.addEventListener("abort", onAbort, { once: true }); + start.then( + (value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })), + (error) => finish(() => reject(error)), + ); + }); +} + +/** + * Stops the child and releases the driver for a terminal state. The runner calls + * this on every exit path: a normal exit, a non-zero exit, a timeout, a + * cancellation, and an error. It first runs a direct child stop, then it disposes + * the driver. A stop error or a dispose error must not leak or mask the result, + * so the function swallows each error and logs a fixed line. + */ +async function stopAndDispose(driver: SetupTokenPtyDriver, log: (line: string) => void): Promise { + try { + driver.stop(); + } catch { + log("[paperclip] Setup-token login: the process stop step errored."); + } + try { + await driver.dispose(); + } catch { + log("[paperclip] Setup-token login: the driver dispose step errored."); + } +} + +/** + * Runs the setup-token login through `driver`. Surfaces the prompt one time + * through `onPrompt`. Reads the browser code one time through `provideCode` and + * writes it to the matched prompt. Binds the minted token from the success + * record and delivers it one time through `onCredential`. Returns a fixed status. + * Stops the child and disposes the driver for every terminal state. Never logs + * the raw stream, and never puts a URL, a code, or a token into a log line, the + * result, or a thrown error. + */ +export async function runSetupTokenLogin( + driver: SetupTokenPtyDriver, + options: RunSetupTokenLoginOptions, +): Promise { + const { onPrompt, provideCode, onCredential, timeoutMs, signal } = options; + const command = options.command ?? CLAUDE_SETUP_TOKEN_COMMAND; + const codeSubmitSettleMs = options.codeSubmitSettleMs ?? CODE_SUBMIT_SETTLE_MS; + const log = options.log ?? (() => {}); + + // A private controller that fans a timeout or a cancellation into the + // code-input routine, so a pending `provideCode` stops when the run ends. + const controller = new AbortController(); + const onExternalAbort = () => controller.abort(); + if (signal) { + if (signal.aborted) controller.abort(); + else signal.addEventListener("abort", onExternalAbort, { once: true }); + } + + let promptSurfaced = false; + let codeSubmitted = false; + let submitStarted = false; + let credentialDelivered = false; + // The code-input routine runs beside the race. It is self-guarding and never + // rejects, so an unresolved provider cannot become an unhandled rejection. + let submitPromise: Promise = Promise.resolve(); + // The credential-delivery routine runs beside the race. It is self-guarding + // and never rejects, so an async sink cannot become an unhandled rejection. + let credentialPromise: Promise = Promise.resolve(); + + // The in-memory prompt buffer. The runner drops it as soon as it finds the + // prompt, so the secret-bearing stream never lives longer than one parse. The + // runner parses the full buffer, and it includes the whole new chunk. So a + // prompt at the start of one large chunk still parses. The runner bounds only + // the buffer that it keeps for the next chunk to + // {@link CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS}. The runner keeps the trailing + // window and drops the oldest characters. + let buffer = ""; + // The in-memory token buffer. The runner accumulates the post-prompt stream + // here, parses the full buffer before any truncation, and drops it as soon as + // it binds the token. It bounds the retained window the same way as the prompt + // buffer, so the secret-bearing stream never grows without a bound. + let tokenBuffer = ""; + + // Reads the browser code and writes it to the matched prompt one time. The + // routine never throws: it logs a fixed line on a provider error or a write + // error, so the code never reaches a log or a thrown error. + const submitCode = async (): Promise => { + let code: string; + try { + code = await provideCode(controller.signal); + } catch { + log("[paperclip] Setup-token login: the code input step errored."); + return; + } + if (controller.signal.aborted) return; + try { + // Write the code, let the Ink paste buffer settle, then write the Return as + // its own write. A glued `code + "\r"` burst folds the Return into the + // pasted text and never submits (live characterization). Two writes with a + // settle gap submit on the first Return. + driver.write(code); + await settleDelay(codeSubmitSettleMs, controller.signal); + if (controller.signal.aborted) return; + driver.write(CODE_SUBMISSION_TERMINATOR); + codeSubmitted = true; + log("[paperclip] Setup-token login: sent the browser code to the prompt."); + } catch { + log("[paperclip] Setup-token login: the code input step errored."); + } + }; + + // Binds the token from the token buffer and delivers it one time. The runner + // scans only when a caller provides an `onCredential` sink and the gate is + // open. It drops the token buffer as soon as the parser binds the token. It + // never logs the token and never puts it into a thrown error. + const captureCredential = (): void => { + if (!SETUP_TOKEN_CREDENTIAL_RELEASE_GATE || !onCredential || credentialDelivered) return; + const token = parseSetupTokenCredential(tokenBuffer); + if (!token) return; + credentialDelivered = true; + tokenBuffer = ""; + const authBytes = Buffer.from(token, "utf8"); + try { + credentialPromise = Promise.resolve(onCredential(authBytes)).catch(() => { + log("[paperclip] Setup-token login: the credential delivery step errored."); + }); + log("[paperclip] Setup-token login: delivered the credential to the sink."); + } catch { + log("[paperclip] Setup-token login: the credential delivery step errored."); + } + }; + + const onData = (chunk: string): void => { + if (!promptSurfaced) { + // Parse the full buffer before any truncation. This order finds an early + // prompt inside one large chunk, so the runner never drops it. + buffer += chunk; + const prompt = parseSetupTokenPrompt(buffer); + if (prompt) { + promptSurfaced = true; + buffer = ""; + onPrompt(prompt); + log("[paperclip] Setup-token login: surfaced the sign-in prompt."); + if (!submitStarted) { + submitStarted = true; + submitPromise = submitCode(); + } + return; + } + if (buffer.length > CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS) { + buffer = buffer.slice(buffer.length - CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS); + } + return; + } + // The prompt already surfaced. Scan the post-prompt stream for the success + // token. The runner scans only when a caller provides an `onCredential` + // sink, so it never holds the secret-bearing stream without a need. + if (!SETUP_TOKEN_CREDENTIAL_RELEASE_GATE || !onCredential || credentialDelivered) return; + tokenBuffer += chunk; + captureCredential(); + if (!credentialDelivered && tokenBuffer.length > CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS) { + tokenBuffer = tokenBuffer.slice(tokenBuffer.length - CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS); + } + }; + + const result = (outcome: SetupTokenOutcome, exitCode: number | null): SetupTokenLoginResult => ({ + outcome, + exitCode, + promptSurfaced, + codeSubmitted, + credentialDelivered, + }); + + try { + if (signal?.aborted) { + log("[paperclip] Setup-token login cancelled before start."); + return result("cancelled", null); + } + + const start = driver.start(command, onData); + const raced = await raceStart(start, timeoutMs, controller.signal); + // Release a pending code-input routine, then let it settle. The routine is + // self-guarding, so this await never rejects. + controller.abort(); + await submitPromise; + + if (raced.kind === "timeout") { + log("[paperclip] Setup-token login timed out; stopping the process."); + return result("timeout", null); + } + if (raced.kind === "cancelled") { + log("[paperclip] Setup-token login cancelled; stopping the process."); + return result("cancelled", null); + } + + const exitCode = raced.exitCode; + if (exitCode !== 0) { + log("[paperclip] Setup-token login command ended with a non-zero exit code."); + return result("failure", exitCode); + } + + // The runner captured the token from the stream during `onData` and + // delivered it through `onCredential`. Await a pending async sink, so the + // credential fully lands before the runner reports success. + await credentialPromise; + + log("[paperclip] Setup-token login command ended successfully."); + return result("success", exitCode); + } catch { + // Convert any driver error to a fixed, non-secret error. The original error + // may embed streamed bytes, so the runner never propagates its message. + throw new Error("setup-token login failed: the sandbox login command errored."); + } finally { + if (signal) signal.removeEventListener("abort", onExternalAbort); + // Stop a pending code-input routine, then stop the child and dispose. + controller.abort(); + await stopAndDispose(driver, log); + } +} diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.ts index 9b8e160898..3114f32627 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.ts @@ -42,6 +42,25 @@ import type { } from "@paperclipai/plugin-sdk"; import { performSyncIn, performSyncOut, withProviderSpan } from "./file-sync.js"; +// The Claude `setup-token` login pseudo-terminal (PTY) session for this provider. +// The session runs the login command on a real pseudo-terminal, streams the +// terminal output, and delivers the delayed browser code plus the Enter byte. A +// later phase binds the opener to `sandbox.process` and wraps it with the +// `createSetupTokenPtyTransport` factory from `@paperclipai/adapter-utils` to +// build the transport the login runner drives. +export { + createDaytonaSetupTokenPtySessionOpener, + openDaytonaSetupTokenPtySession, +} from "./setup-token-pty.js"; +export type { + SetupTokenPtySession, + SetupTokenPtySessionOpener, + DaytonaPtyHandle, + DaytonaPtyProcess, + DaytonaPtyCreateOptions, + DaytonaSetupTokenPtyOptions, +} from "./setup-token-pty.js"; + // Injectable monotonic clock for provider-boundary timing (Open Q1). Defaults // to the real wall clock; `plugin.test.ts` overrides it via // `setDaytonaTimingClockForTest` so the measured `durationMs`/`getDurationMs` diff --git a/packages/plugins/sandbox-providers/daytona/src/setup-token-pty.test.ts b/packages/plugins/sandbox-providers/daytona/src/setup-token-pty.test.ts new file mode 100644 index 0000000000..63ca4bb114 --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/setup-token-pty.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it } from "vitest"; +import { + createDaytonaSetupTokenPtySessionOpener, + openDaytonaSetupTokenPtySession, + type DaytonaPtyCreateOptions, + type DaytonaPtyHandle, + type DaytonaPtyProcess, +} from "./setup-token-pty.js"; + +// The Enter byte the terminal login UI reads to submit the browser code. +const ENTER = "\r"; + +/** + * A fake Daytona PTY handle. It records each input write, drives the output + * stream on demand, and records the kill and the disconnect. The tests use it in + * place of the real SDK `PtyHandle`, so the session runs with no sandbox. + */ +function createFakePtyHandle(onData: (data: Uint8Array) => void | Promise): DaytonaPtyHandle & { + inputs: string[]; + emitText: (text: string) => void; + emitBytes: (bytes: Uint8Array) => void; + finish: (exitCode: number | undefined) => void; + killed: number; + disconnected: number; +} { + const encoder = new TextEncoder(); + const inputs: string[] = []; + let resolveWait: ((value: { exitCode?: number; error?: string }) => void) | null = null; + const waitPromise = new Promise<{ exitCode?: number; error?: string }>((resolve) => { + resolveWait = resolve; + }); + let killed = 0; + let disconnected = 0; + return { + async waitForConnection(): Promise {}, + async sendInput(data: string | Uint8Array): Promise { + inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data)); + }, + wait(): Promise<{ exitCode?: number; error?: string }> { + return waitPromise; + }, + async kill(): Promise { + killed += 1; + }, + async disconnect(): Promise { + disconnected += 1; + }, + // Test control below. The session never reads these fields. + emitText(text: string): void { + onData(encoder.encode(text)); + }, + emitBytes(bytes: Uint8Array): void { + onData(bytes); + }, + finish(exitCode: number | undefined): void { + resolveWait?.({ exitCode }); + }, + get inputs(): string[] { + return inputs; + }, + get killed(): number { + return killed; + }, + get disconnected(): number { + return disconnected; + }, + }; +} + +/** + * A fake Daytona process. It opens one fake PTY handle and records the create + * options, so a test asserts the terminal size and the command start. + */ +function createFakeProcess(): DaytonaPtyProcess & { + handle: ReturnType | null; + createOptions: DaytonaPtyCreateOptions | null; +} { + const state: { + handle: ReturnType | null; + createOptions: DaytonaPtyCreateOptions | null; + } = { handle: null, createOptions: null }; + return { + get handle() { + return state.handle; + }, + get createOptions() { + return state.createOptions; + }, + async createPty(options: DaytonaPtyCreateOptions): Promise { + state.createOptions = options; + const handle = createFakePtyHandle(options.onData); + state.handle = handle; + return handle; + }, + }; +} + +describe("openDaytonaSetupTokenPtySession", () => { + it("starts the login command on a pseudo-terminal with a fixed size", async () => { + const process = createFakeProcess(); + + await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + + expect(process.createOptions?.cols).toBe(120); + expect(process.createOptions?.rows).toBe(30); + // The session replaces the shell with the command through `exec`, so the + // pseudo-terminal runs the command directly and its exit code is the PTY + // exit code. + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + }); + + it("returns incremental terminal output to the listener", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + session.onData((chunk) => received.push(chunk)); + + process.handle?.emitText("the url below to sign in\n"); + process.handle?.emitText("Paste code here if prompted\n"); + + expect(received).toEqual(["the url below to sign in\n", "Paste code here if prompted\n"]); + }); + + it("buffers early output until the listener registers", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + // Output arrives before the transport registers the listener. + process.handle?.emitText("early output "); + process.handle?.emitText("more output"); + expect(received).toEqual([]); + + session.onData((chunk) => received.push(chunk)); + // The session flushes the buffered output in order on registration. + expect(received).toEqual(["early output more output"]); + }); + + it("delivers the browser code plus the Enter byte to the command", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + session.write("BROWSERCODE" + ENTER); + + // The first input is the command start; the second input is the delayed + // browser code plus the Enter byte. + expect(process.handle?.inputs[1]).toBe("BROWSERCODE" + ENTER); + expect(process.handle?.inputs[1]?.endsWith(ENTER)).toBe(true); + }); + + it("keeps a multibyte character whole across two output chunks", async () => { + const process = createFakeProcess(); + const received: string[] = []; + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + session.onData((chunk) => received.push(chunk)); + + // The euro sign is three UTF-8 bytes. Split it across two chunks, so the + // stream decoder must join the bytes. + const euro = new TextEncoder().encode("€"); + process.handle?.emitBytes(euro.subarray(0, 2)); + process.handle?.emitBytes(euro.subarray(2)); + + expect(received.join("")).toBe("€"); + }); + + it("resolves wait with the command exit code", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + process.handle?.finish(9); + + await expect(session.wait()).resolves.toEqual({ exitCode: 9 }); + }); + + it("maps an absent exit code to null", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + process.handle?.finish(undefined); + + await expect(session.wait()).resolves.toEqual({ exitCode: null }); + }); + + it("kills the child and closes the session", async () => { + const process = createFakeProcess(); + + const session = await openDaytonaSetupTokenPtySession(process, "claude setup-token"); + session.kill(); + expect(process.handle?.killed).toBe(1); + + await session.close(); + expect(process.handle?.disconnected).toBe(1); + }); + + it("passes the working directory to the pseudo-terminal", async () => { + const process = createFakeProcess(); + + await openDaytonaSetupTokenPtySession(process, "claude setup-token", { cwd: "/workspace" }); + + expect(process.createOptions?.cwd).toBe("/workspace"); + }); +}); + +describe("createDaytonaSetupTokenPtySessionOpener", () => { + it("opens a session for the command on each call", async () => { + const process = createFakeProcess(); + const opener = createDaytonaSetupTokenPtySessionOpener(process, { cwd: "/workspace" }); + + const session = await opener("claude setup-token"); + + expect(process.createOptions?.cwd).toBe("/workspace"); + expect(process.handle?.inputs[0]).toBe("exec claude setup-token" + ENTER); + // The opener returns a session the transport can drive. + expect(typeof session.onData).toBe("function"); + expect(typeof session.write).toBe("function"); + expect(typeof session.wait).toBe("function"); + expect(typeof session.kill).toBe("function"); + expect(typeof session.close).toBe("function"); + }); +}); diff --git a/packages/plugins/sandbox-providers/daytona/src/setup-token-pty.ts b/packages/plugins/sandbox-providers/daytona/src/setup-token-pty.ts new file mode 100644 index 0000000000..89bace5b5c --- /dev/null +++ b/packages/plugins/sandbox-providers/daytona/src/setup-token-pty.ts @@ -0,0 +1,194 @@ +// The Daytona pseudo-terminal (PTY) session for the Claude `setup-token` login. +// The login command needs a real pseudo-terminal: pipe stdio emits no login +// prompt. The Daytona SDK opens a PTY through `process.createPty`. This module +// binds that PTY to a small session that the setup-token transport consumes, so +// the login runner runs the command on a real terminal, streams the terminal +// output, and delivers the delayed browser code plus the Enter byte. +// +// Dependency boundary: this provider plugin ships standalone (the workspace +// excludes `packages/plugins/sandbox-providers/**`). So the module imports no +// workspace package. It declares the small session shape locally through +// {@link SetupTokenPtySession}. The shape matches the `SetupTokenPtySession` +// interface in `@paperclipai/adapter-utils`, so the transport factory +// `createSetupTokenPtyTransport` accepts the session opener from this module with +// no adapter. The runner drives the transport; the transport drives this session. +// +// SDK boundary: the module holds no Daytona SDK type import. It declares the +// small subset of the SDK PTY surface that it uses through +// {@link DaytonaPtyProcess} and {@link DaytonaPtyHandle}. The SDK `Process` and +// `PtyHandle` satisfy that subset, so a caller passes `sandbox.process` with no +// adapter. The narrow surface keeps the module unit-testable with a fake PTY. +// +// Security (secret handling): the login runner delivers the browser code and the +// Enter byte through {@link DaytonaPtyHandle.sendInput}. The SDK sends the input +// over the PTY socket, so the code never rides a command line and never reaches a +// process argument list. The session forwards the raw terminal bytes with no +// ANSI or OSC 8 handling; the setup-token parser owns that handling. + +import { randomUUID } from "node:crypto"; + +/** + * A live pseudo-terminal session for one setup-token login command. The session + * allocates a real pseudo-terminal, streams the raw terminal output, accepts + * delayed input, and stops the child. The shape matches the + * `SetupTokenPtySession` interface in `@paperclipai/adapter-utils`, so the + * transport factory there accepts a session opener that returns this session. + */ +export interface SetupTokenPtySession { + /** Registers the one output listener. The session streams each raw chunk in order. */ + onData(listener: (chunk: string) => void): void; + /** Writes raw input bytes to the pseudo-terminal. */ + write(data: string): void; + /** Resolves with the child exit code when the command ends. */ + wait(): Promise<{ exitCode: number | null }>; + /** Stops the child process. Safe to call more than one time. */ + kill(): void; + /** Releases the session resources. Safe to call more than one time. */ + close(): Promise; +} + +/** Opens a {@link SetupTokenPtySession} for `command`. The transport calls it one time. */ +export type SetupTokenPtySessionOpener = (command: string) => Promise; + +/** + * The subset of the Daytona SDK `PtyHandle` that the session uses. The SDK + * `PtyHandle` satisfies this interface, so a caller passes the real handle with + * no adapter. + */ +export interface DaytonaPtyHandle { + /** Resolves when the PTY socket connection is ready for input and output. */ + waitForConnection(): Promise; + /** Sends raw input bytes to the pseudo-terminal. */ + sendInput(data: string | Uint8Array): Promise; + /** Resolves with the exit result when the pseudo-terminal process ends. */ + wait(): Promise<{ exitCode?: number; error?: string }>; + /** Kills the pseudo-terminal process. */ + kill(): Promise; + /** Closes the PTY socket and releases the resources. */ + disconnect(): Promise; +} + +/** + * The options the session passes to `createPty`. The session sets a fixed + * terminal size and one output callback. It sets the working directory when the + * caller provides one. + */ +export interface DaytonaPtyCreateOptions { + id: string; + cwd?: string; + cols: number; + rows: number; + onData: (data: Uint8Array) => void | Promise; +} + +/** + * The subset of the Daytona SDK `Process` that the session uses. The SDK + * `Process` satisfies this interface, so a caller passes `sandbox.process`. + */ +export interface DaytonaPtyProcess { + createPty(options: DaytonaPtyCreateOptions): Promise; +} + +/** The options for the Daytona setup-token PTY session. */ +export interface DaytonaSetupTokenPtyOptions { + /** The working directory for the login PTY. Defaults to the sandbox default. */ + cwd?: string; +} + +// The terminal size for the login PTY. The login UI prints a short prompt, so a +// standard terminal size is enough. A fixed size keeps the output stable. +const SETUP_TOKEN_PTY_COLS = 120; +const SETUP_TOKEN_PTY_ROWS = 30; + +/** + * The input terminator that replaces the interactive shell with the login + * command. The `exec` builtin replaces the shell process image with the command, + * so the PTY runs the command directly. When the command ends, the PTY ends with + * the command exit code, and the delayed input reaches the command, not a shell. + * The terminal reads the carriage return as the Enter key. + */ +const PTY_COMMAND_TERMINATOR = "\r"; + +/** + * Opens a Daytona PTY session for `command` and returns it as a + * {@link SetupTokenPtySession}. The session allocates a real pseudo-terminal, + * streams the raw terminal output, delivers delayed input, and stops the child. + * + * The function starts the login command with `exec`, so the pseudo-terminal runs + * the command directly and the command exit code becomes the PTY exit code. The + * function decodes the terminal bytes as a UTF-8 stream, so a multibyte + * character that splits across two output chunks stays whole. It buffers the + * output until the transport registers the listener, so no early chunk is lost. + */ +export async function openDaytonaSetupTokenPtySession( + process: DaytonaPtyProcess, + command: string, + options?: DaytonaSetupTokenPtyOptions, +): Promise { + const decoder = new TextDecoder("utf-8"); + let listener: ((chunk: string) => void) | null = null; + let buffered = ""; + + const handle = await process.createPty({ + id: `paperclip-setup-token-${randomUUID()}`, + ...(options?.cwd ? { cwd: options.cwd } : {}), + cols: SETUP_TOKEN_PTY_COLS, + rows: SETUP_TOKEN_PTY_ROWS, + onData: (data: Uint8Array): void => { + // Decode the terminal bytes as a stream, so a split multibyte character + // stays whole across two chunks. Forward the raw text; the parser owns the + // ANSI and OSC 8 handling. + const text = decoder.decode(data, { stream: true }); + if (text.length === 0) return; + if (listener) listener(text); + else buffered += text; + }, + }); + + await handle.waitForConnection(); + // Replace the interactive shell with the login command, so the pseudo-terminal + // runs the command directly. The runner then writes the delayed browser code + // to the command, not to a shell. + await handle.sendInput(`exec ${command}${PTY_COMMAND_TERMINATOR}`); + + return { + onData(next: (chunk: string) => void): void { + listener = next; + if (buffered.length > 0) { + const pending = buffered; + buffered = ""; + next(pending); + } + }, + write(data: string): void { + // Fire the input write. A write error must not throw into the runner, so + // the runner's fixed status stays the single result path. + void handle.sendInput(data).catch(() => undefined); + }, + async wait(): Promise<{ exitCode: number | null }> { + const result = await handle.wait(); + return { exitCode: typeof result.exitCode === "number" ? result.exitCode : null }; + }, + kill(): void { + void handle.kill().catch(() => undefined); + }, + async close(): Promise { + await handle.disconnect().catch(() => undefined); + }, + }; +} + +/** + * Creates a {@link SetupTokenPtySessionOpener} bound to a Daytona `process`. Pass + * `sandbox.process` from the Daytona SDK. A later phase wraps the opener with the + * `createSetupTokenPtyTransport` factory from `@paperclipai/adapter-utils` and + * hands the transport to the login runner. The opener runs the login command on + * a real pseudo-terminal, streams the terminal output, delivers the delayed + * browser code plus the Enter byte, and stops the child for a terminal state. + */ +export function createDaytonaSetupTokenPtySessionOpener( + process: DaytonaPtyProcess, + options?: DaytonaSetupTokenPtyOptions, +): SetupTokenPtySessionOpener { + return (command: string) => openDaytonaSetupTokenPtySession(process, command, options); +} diff --git a/server/src/app.ts b/server/src/app.ts index 51f49a1ca3..83ba9f791f 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -29,6 +29,7 @@ import { summarySlotRoutes } from "./routes/summary-slots.js"; import { statusCardRoutes } from "./routes/status-cards.js"; import { teamsCatalogRoutes } from "./routes/teams-catalog.js"; import { agentRoutes } from "./routes/agents.js"; +import type { SetupTokenSessionService } from "./services/setup-token-session.js"; import { projectRoutes } from "./routes/projects.js"; import { issueRoutes } from "./routes/issues.js"; import { issueTreeControlRoutes } from "./routes/issue-tree-control.js"; @@ -384,7 +385,41 @@ export async function createApp( api.use(summarySlotRoutes(db)); api.use(statusCardRoutes(db)); api.use(teamsCatalogRoutes(db)); - api.use(agentRoutes(db, { pluginWorkerManager: workerManager })); + // The setup-token login session service. The router builds it and hands it + // back through the callback below, so the shutdown hook can cancel every live + // session (SR-4). + let setupTokenLoginService: SetupTokenSessionService | null = null; + // The dedicated proxy IP or CIDR allowlist for the confidential setup-token + // login responses (SR-7). The global `TRUST_PROXY` setting does not satisfy + // the guard; an operator sets this allowlist to the real TLS-terminating + // proxy addresses. An empty value keeps the confidential responses on direct + // TLS (or a `local_trusted` loopback peer) only. + const setupTokenLoginProxyAllowlist = (process.env.CLAUDE_LOGIN_TRUSTED_PROXIES ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + // The production server does not bind `setupTokenLogin` yet, so the start route + // fails closed with a fixed no-secret 503 and the login never spawns a process + // or holds a lease. This is a deliberate staged rollout: the live transport + // needs a real sandbox-lease manager, a live pseudo-terminal factory over the + // sandbox provider, and a durable cleanup store (the in-router default store is + // in-memory only). Each of those is a separate follow-up that goes through its + // own security review before the production server binds `setupTokenLogin`. + api.use( + agentRoutes(db, { + pluginWorkerManager: workerManager, + deploymentMode: opts.deploymentMode, + confidentialProxyAllowlist: setupTokenLoginProxyAllowlist, + onSetupTokenLoginService: (service) => { + setupTokenLoginService = service; + // Startup reaper (SR-4): release any lease whose login session is + // terminal or past its deadline after a restart. The DB is ready here. + void service.reap().catch((err) => { + logger.error({ err }, "Setup-token login startup reaper failed"); + }); + }, + }), + ); api.use(assetRoutes(db, opts.storageService)); api.use(projectRoutes(db)); api.use(caseRoutes(db, opts.storageService)); @@ -792,6 +827,9 @@ export async function createApp( viteHtmlRenderer?.dispose(); hostServiceCleanup.disposeAll(); hostServiceCleanup.teardown(); + // Cancel every live setup-token login session, so each direct child stops + // before the server releases each lease (SR-4). + void setupTokenLoginService?.shutdown(); }; app.locals.paperclipShutdown = shutdownAppServices; diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index e5a5e9ade1..3197e459f3 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -39,6 +39,12 @@ const SENSITIVE_KEYS = new Set([ "sessiontoken", "private_key", "privatekey", + // The Claude setup-token login fields. `browserCode` carries the one-time + // sign-in code and `authorization_code` carries the OAuth code; neither may + // reach a log line. + "browsercode", + "authorization_code", + "authorizationcode", ]); const MAX_DEPTH = 6; @@ -53,6 +59,11 @@ const URLISH_KEYS = new Set([ "sourceurl", "uri", "url", + // The Claude setup-token login URL. A structured `loginUrl` that reaches a log + // sink keeps its origin and path only; the OAuth query, fragment, and any + // credentials are stripped (SR-5 backstop). + "loginurl", + "login_url", ]); function isSensitiveKey(key: string): boolean { diff --git a/server/src/redaction.ts b/server/src/redaction.ts index 64cd96966c..eea53edc67 100644 --- a/server/src/redaction.ts +++ b/server/src/redaction.ts @@ -1,7 +1,7 @@ import { redactCommandText } from "@paperclipai/adapter-utils"; const SECRET_FIELD_NAME_PATTERN = - String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)[A-Za-z0-9_-]*`; + String.raw`[A-Za-z0-9_-]*(?:api[-_]?key|access[-_]?token|auth(?:_?token)?|token|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring|browser[-_]?code|login[-_]?url)[A-Za-z0-9_-]*`; const SECRET_PAYLOAD_KEY_RE = new RegExp(SECRET_FIELD_NAME_PATTERN, "i"); // Authorization reasons are policy decision codes, not credentials. They must diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 08a458b5b4..3dbdc4a7ed 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -94,6 +94,23 @@ import { resolveWorktreeRunExecutionActivationState, } from "../services/instance-settings.js"; import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server"; +import { createInviteRateLimiter } from "../services/invite-rate-limit.js"; +import { + SetupTokenSessionService, + SetupTokenSessionError, + assessConfidentialStartup, + evaluateConfidentialTransport, + SETUP_TOKEN_START_FAILED, + SETUP_TOKEN_TRANSPORT_INSECURE, + type ConfidentialTransportConfig, + type SetupTokenCleanupRecord, + type SetupTokenCleanupStore, + type SetupTokenLease, + type SetupTokenLeaseManager, + type SetupTokenLoginProcessFactory, + type SetupTokenSessionScope, +} from "../services/setup-token-session.js"; +import type { DeploymentMode } from "@paperclipai/shared"; import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local"; import { checkStagedCredentialReadiness, @@ -185,7 +202,37 @@ function readRunIssueId(context: Record | null) { export function agentRoutes( db: Db, - options: { pluginWorkerManager?: PluginWorkerManager } = {}, + options: { + pluginWorkerManager?: PluginWorkerManager; + /** The active deployment mode. The confidential transport guard reads it. */ + deploymentMode?: DeploymentMode; + /** + * The dedicated proxy IP or CIDR allowlist for the confidential setup-token + * responses (SR-7). The global `TRUST_PROXY` setting does not satisfy the + * guard; only a peer on this explicit allowlist may forward a TLS protocol. + */ + confidentialProxyAllowlist?: string[]; + /** + * Receives the setup-token login session service once the router builds it. + * The caller registers the startup reaper and the graceful-shutdown cleanup. + */ + onSetupTokenLoginService?: (service: SetupTokenSessionService) => void; + /** + * Binds the live setup-token login transport. When the caller provides it, + * the session route is the live login path: the start route acquires a real + * sandbox lease through `leases` and drives one live login process through + * `factory`. When the caller omits it, the start route fails closed with the + * fixed no-secret error, because the sandbox pseudo-terminal transport is not + * bound yet. A test injects a fake factory and a fake lease manager to drive + * the full route path. + */ + setupTokenLogin?: { + factory: SetupTokenLoginProcessFactory; + leases: SetupTokenLeaseManager; + /** The durable cleanup store. Defaults to the in-memory record store. */ + store?: SetupTokenCleanupStore; + }; + } = {}, ) { // Legacy hardcoded maps — used as fallback when adapter module does not // declare capability flags explicitly. @@ -234,6 +281,102 @@ export function agentRoutes( const environmentRuntime = environmentRuntimeService(db, { pluginWorkerManager: options.pluginWorkerManager, }); + + // --- Setup-token login session (Claude in-product login) ------------------- + // + // The service owns a company-scoped, owner-bound login session, the + // confidential transport guard (SR-6, SR-7), the session caps, and the start + // rate limit. The `options.setupTokenLogin` transport binds the live sandbox + // pseudo-terminal login process and the real sandbox-lease acquisition. When a + // caller provides the transport, the session route is the live login path and + // `SETUP_TOKEN_LOGIN_TRANSPORT_READY` is true. When a caller omits it, the + // start route returns the fixed no-secret error and the login never spawns a + // process or holds a lease. The full session state machine, the cleanup order, + // and the reaper are covered by setup-token-session.test.ts. + const SETUP_TOKEN_LOGIN_TRANSPORT_READY = options.setupTokenLogin != null; + + const setupTokenConfidentialConfig: ConfidentialTransportConfig = { + deploymentMode: options.deploymentMode ?? "local_trusted", + trustedProxies: options.confidentialProxyAllowlist ?? [], + }; + + // Rate-limit the start route: a small window per company and owner (SR-4). + const setupTokenRateLimiter = createInviteRateLimiter({ windowMs: 60_000, maxRequests: 5 }); + + // The deferred lease manager. It fails closed on acquire until a caller binds + // the live transport. It still releases a lease by handle or by id, so a + // reaper or a shutdown can free a lease that an injected transport acquired. + const deferredSetupTokenLeaseManager: SetupTokenLeaseManager = { + async acquire(): Promise { + // The real sandbox-lease acquisition binds through `options.setupTokenLogin`. + // Until then the start route fails closed before it reaches here. + throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); + }, + async release(lease): Promise { + await environmentsSvc.releaseLease(lease.id, "released").catch(() => {}); + }, + async releaseById(leaseId): Promise { + await environmentsSvc.releaseLease(leaseId, "released").catch(() => {}); + }, + }; + + // The in-memory non-secret cleanup record store. It is the default store when a + // caller does not inject a durable database-backed store. + const setupTokenCleanupRows = new Map(); + const inMemorySetupTokenCleanupStore: SetupTokenCleanupStore = { + async record(record): Promise { + setupTokenCleanupRows.set(record.sessionId, { ...record }); + }, + async markState(sessionId, state): Promise { + const row = setupTokenCleanupRows.get(sessionId); + if (row) row.state = state; + }, + async remove(sessionId): Promise { + setupTokenCleanupRows.delete(sessionId); + }, + async listReapable(): Promise { + return []; + }, + }; + + const deferredSetupTokenLoginFactory: SetupTokenLoginProcessFactory = () => { + // The runner-over-pseudo-terminal binding arrives through + // `options.setupTokenLogin`. Until then the start route fails closed. + throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); + }; + + // Resolve the transport: use the injected factory, lease manager, and store + // when a caller binds them; otherwise use the deferred, fail-closed defaults. + const setupTokenLoginFactory = + options.setupTokenLogin?.factory ?? deferredSetupTokenLoginFactory; + const setupTokenLeaseManager = + options.setupTokenLogin?.leases ?? deferredSetupTokenLeaseManager; + const setupTokenCleanupStore = + options.setupTokenLogin?.store ?? inMemorySetupTokenCleanupStore; + + const setupTokenLoginService = new SetupTokenSessionService({ + factory: setupTokenLoginFactory, + leases: setupTokenLeaseManager, + store: setupTokenCleanupStore, + rateLimiter: setupTokenRateLimiter, + }); + + { + // Log the startup transport assessment, so an operator can see whether a + // forwarded proxy protocol is trusted for the confidential routes (SR-7). + const startupAssessment = assessConfidentialStartup(setupTokenConfidentialConfig); + logger.info( + { + proxyForwardingEnabled: startupAssessment.proxyForwardingEnabled, + reason: startupAssessment.reason, + deploymentMode: setupTokenConfidentialConfig.deploymentMode, + }, + "Setup-token login confidential transport startup assessment", + ); + } + + options.onSetupTokenLoginService?.(setupTokenLoginService); + const runRedactions = createRunSecretRedactionRegistry(db); const heartbeat = heartbeatService(db, { pluginWorkerManager: options.pluginWorkerManager, @@ -4044,6 +4187,192 @@ export function agentRoutes( res.json(result); }); + // --- Setup-token login session routes -------------------------------------- + // + // The routes give the harness the operations against one live login session. + // Every operation verifies the company, the owner user, and the target agent + // through the session scope. A missing session and a cross-scope session both + // return the same 404. The confidential responses (read-prompt and + // receive-token) pass through the fail-closed transport guard and set + // `Cache-Control: no-store`. The routes write no prompt, code, token, or raw + // process chunk to a log or an activity detail, and they return fixed error + // text only. + // + // Operator requirement (SR-7): to serve the confidential responses behind a + // TLS-terminating reverse proxy, set `CLAUDE_LOGIN_TRUSTED_PROXIES` to the + // explicit proxy IP or CIDR allowlist. The global `TRUST_PROXY` setting, + // including `TRUST_PROXY=true` and a hop-count value, does not satisfy the + // guard. A direct TLS request is always valid; a non-TLS request is valid only + // on a loopback peer in the `local_trusted` deployment mode. + // + // Each route below writes its full path as a plain string literal. The static + // OpenAPI coverage test reads the route paths from the source text; it does + // not evaluate a template variable. A shared base constant would leave the + // test with an unresolved path, so the routes repeat the base path instead. + + /** + * Builds the immutable session scope from the authenticated owner user and the + * target agent (SR-3, FU-1). Only a user actor owns a login session; the route + * uses this dedicated login scope, not the broad agent-manage permission. + */ + const buildSetupTokenScope = ( + req: Request, + agent: { id: string; companyId: string }, + ): SetupTokenSessionScope => { + const actor = getActorInfo(req); + if (actor.actorType !== "user") { + throw forbidden("A user must own a setup-token login session."); + } + return { companyId: agent.companyId, ownerUserId: actor.actorId, targetAgentId: agent.id }; + }; + + /** + * Applies the fail-closed confidential transport guard (SR-6, SR-7). It reads + * the raw socket TLS bit and the immediate peer address, so the global + * `trust proxy` setting cannot influence the decision. It returns false and + * sends the fixed no-secret error when the transport is not confidential. + */ + const enforceSetupTokenTransport = (req: Request, res: Response): boolean => { + const socket = req.socket as { encrypted?: boolean; remoteAddress?: string }; + const forwardedProto = req.headers["x-forwarded-proto"]; + const decision = evaluateConfidentialTransport(setupTokenConfidentialConfig, { + socketEncrypted: socket?.encrypted === true, + remoteAddress: socket?.remoteAddress, + forwardedProto: Array.isArray(forwardedProto) ? forwardedProto[0] : forwardedProto, + }); + if (!decision.allowed) { + res.setHeader("Cache-Control", "no-store"); + res.status(403).json({ error: SETUP_TOKEN_TRANSPORT_INSECURE }); + return false; + } + return true; + }; + + const sendSetupTokenError = (res: Response, err: unknown): void => { + if (err instanceof SetupTokenSessionError) { + res.status(err.status).json({ error: err.message }); + return; + } + throw err; + }; + + /** + * Resolves the target agent for a login-session route and verifies it is a + * `claude_local` agent. Returns null and sends the response when the agent is + * missing, inaccessible, or the wrong adapter type. + */ + const resolveSetupTokenAgent = async (req: Request, res: Response) => { + const id = req.params.id as string; + const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); + if (!agent) return null; + if (agent.adapterType !== "claude_local") { + res.status(400).json({ error: "Login is only supported for claude_local agents" }); + return null; + } + return agent; + }; + + router.post("/agents/:id/setup-token-login-sessions", async (req, res) => { + const agent = await resolveSetupTokenAgent(req, res); + if (!agent) return; + const scope = buildSetupTokenScope(req, agent); + res.setHeader("Cache-Control", "no-store"); + if (!SETUP_TOKEN_LOGIN_TRANSPORT_READY) { + // The live login transport binds at a later call site. Fail closed with the + // fixed no-secret error until then. + res.status(503).json({ error: SETUP_TOKEN_START_FAILED }); + return; + } + try { + const started = await setupTokenLoginService.start(scope); + res.status(201).json({ sessionId: started.sessionId, state: started.state }); + } catch (err) { + sendSetupTokenError(res, err); + } + }); + + router.get("/agents/:id/setup-token-login-sessions/:sessionId/prompt", async (req, res) => { + const agent = await resolveSetupTokenAgent(req, res); + if (!agent) return; + const scope = buildSetupTokenScope(req, agent); + res.setHeader("Cache-Control", "no-store"); + // SR-6 and SR-7: the full login URL is a confidential response. + if (!enforceSetupTokenTransport(req, res)) return; + try { + const view = setupTokenLoginService.readPrompt(req.params.sessionId as string, scope); + // SR-5: the full login URL rides only in this authorized owner response. + res.json({ state: view.state, loginUrl: view.loginUrl }); + } catch (err) { + sendSetupTokenError(res, err); + } + }); + + router.post("/agents/:id/setup-token-login-sessions/:sessionId/code", async (req, res) => { + const agent = await resolveSetupTokenAgent(req, res); + if (!agent) return; + const scope = buildSetupTokenScope(req, agent); + const browserCode = typeof req.body?.browserCode === "string" ? req.body.browserCode : null; + res.setHeader("Cache-Control", "no-store"); + // SR-6 and SR-7: the browser code is the confidential OAuth authorization + // secret. Enforce the fail-closed confidential transport guard before the + // route reads it, so the code never rides an untrusted transport. + if (!enforceSetupTokenTransport(req, res)) return; + if (!browserCode) { + // The route echoes no input; it returns fixed error text only (SR-1). + res.status(400).json({ error: "A browserCode is required." }); + return; + } + try { + const result = setupTokenLoginService.submitCode(req.params.sessionId as string, scope, browserCode); + res.json({ state: result.state }); + } catch (err) { + sendSetupTokenError(res, err); + } + }); + + router.post("/agents/:id/setup-token-login-sessions/:sessionId/cancel", async (req, res) => { + const agent = await resolveSetupTokenAgent(req, res); + if (!agent) return; + const scope = buildSetupTokenScope(req, agent); + try { + const result = await setupTokenLoginService.cancel(req.params.sessionId as string, scope); + res.json({ state: result.state }); + } catch (err) { + sendSetupTokenError(res, err); + } + }); + + router.post("/agents/:id/setup-token-login-sessions/:sessionId/expire", async (req, res) => { + const agent = await resolveSetupTokenAgent(req, res); + if (!agent) return; + const scope = buildSetupTokenScope(req, agent); + try { + const result = await setupTokenLoginService.expire(req.params.sessionId as string, scope); + res.json({ state: result.state }); + } catch (err) { + sendSetupTokenError(res, err); + } + }); + + router.post("/agents/:id/setup-token-login-sessions/:sessionId/token", async (req, res) => { + const agent = await resolveSetupTokenAgent(req, res); + if (!agent) return; + const scope = buildSetupTokenScope(req, agent); + res.setHeader("Cache-Control", "no-store"); + // SR-6 and SR-7: the token is a confidential response. + if (!enforceSetupTokenTransport(req, res)) return; + try { + // The service returns the token one time from a completed session. It + // returns the fixed unavailable error when the token is not ready or the + // owner already received it. The full token rides only in this authorized + // owner response over the confidential transport (SR-5, SR-6, SR-7). + const result = setupTokenLoginService.receiveToken(req.params.sessionId as string, scope); + res.json({ token: result.token }); + } catch (err) { + sendSetupTokenError(res, err); + } + }); + router.get("/companies/:companyId/heartbeat-runs", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 1808f1aeaf..27619b7b59 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -4488,6 +4488,99 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +// Setup-token login session routes. The owner user starts one session, reads the +// login prompt, submits the browser code, and receives the token. The prompt, +// code, and token responses require a confidential transport; the guard returns +// 403 when the transport is not confidential. +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/setup-token-login-sessions", + tags: ["agents"], + summary: "Start a setup-token login session for an agent", + request: { params: z.object({ id: z.string() }) }, + responses: { + 201: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 503: r.serverError, + }, +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/setup-token-login-sessions/{sessionId}/prompt", + tags: ["agents"], + summary: "Read the login prompt for a setup-token login session", + request: { params: z.object({ id: z.string(), sessionId: z.string() }) }, + responses: { + 200: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/setup-token-login-sessions/{sessionId}/code", + tags: ["agents"], + summary: "Submit the browser code for a setup-token login session", + request: { params: z.object({ id: z.string(), sessionId: z.string() }) }, + responses: { + 200: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/setup-token-login-sessions/{sessionId}/cancel", + tags: ["agents"], + summary: "Cancel a setup-token login session", + request: { params: z.object({ id: z.string(), sessionId: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/setup-token-login-sessions/{sessionId}/expire", + tags: ["agents"], + summary: "Expire a setup-token login session", + request: { params: z.object({ id: z.string(), sessionId: z.string() }) }, + responses: { + 200: r.ok(), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/{id}/setup-token-login-sessions/{sessionId}/token", + tags: ["agents"], + summary: "Receive the token from a completed setup-token login session", + request: { params: z.object({ id: z.string(), sessionId: z.string() }) }, + responses: { + 200: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + // ─── Issue interactions & tree ─────────────────────────────────────────────── registry.registerPath({ diff --git a/server/src/routes/setup-token-route.test.ts b/server/src/routes/setup-token-route.test.ts new file mode 100644 index 0000000000..53f19d2d34 --- /dev/null +++ b/server/src/routes/setup-token-route.test.ts @@ -0,0 +1,528 @@ +// The wired setup-token login route. This test drives the full login path — +// start, read-prompt, submit-code, and receive-token — through the HTTP route +// with a fake transport. It proves the guarded session contract is the live +// login path and that the security controls hold end to end at the route level. +// +// The test covers these criteria (the full set is on the parent plan document): +// * The full path returns the sign-in URL to the owner, accepts one code, and +// returns the minted token one time. +// * SR-1 and SR-5: the browser code, the authorization-URL query values, and +// the token never reach the request log, an activity detail, the exception +// metadata, or a non-owner response. The owner read-prompt response carries +// the full URL with `Cache-Control: no-store`; every other response carries +// the sanitized URL form only. +// * SR-6 and SR-7: the fail-closed confidential transport guard rejects a +// non-TLS request and a spoofed forwarded protocol through the wired route. + +import express from "express"; +import { Writable } from "node:stream"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SETUP_TOKEN_SESSION_NOT_FOUND, + SETUP_TOKEN_SUBMIT_CONFLICT, + SETUP_TOKEN_TRANSPORT_INSECURE, + type SetupTokenCleanupRecord, + type SetupTokenCleanupStore, + type SetupTokenLeaseManager, + type SetupTokenLoginOutcome, + type SetupTokenLoginProcessFactory, +} from "../services/setup-token-session.js"; + +// --- Test fixtures ----------------------------------------------------------- + +const COMPANY_ID = "company-1"; +const OWNER_USER_ID = "owner-user-1"; +const OTHER_USER_ID = "other-user-2"; +const AGENT_ID = "11111111-1111-4111-8111-111111111111"; + +// The distinctive secret values. Each holds a unique marker, so a substring +// check proves the value never reached a sink. +const BROWSER_CODE = "CODESECRETqrs321"; +const URL_CODE_QUERY = "QUERYSECRETabc123"; +const URL_STATE_QUERY = "STATESECRETdef456"; +const MINTED_TOKEN = "sk-ant-oat01-TOKENSECRETxyz789aaaaaaaaaaaa"; + +// The full authorization URL the transport surfaces. Its query holds the two +// distinctive markers. The sanitized form keeps only the origin and the path. +const FULL_LOGIN_URL = + "https://claude.com/cai/oauth/authorize" + + "?client_id=abc" + + `&code=${URL_CODE_QUERY}` + + "&code_challenge=xyz" + + "&code_challenge_method=S256" + + "&redirect_uri=https%3A%2F%2Fexample.test%2Fcb" + + "&response_type=code" + + "&scope=user" + + `&state=${URL_STATE_QUERY}`; +const SANITIZED_LOGIN_URL = "https://claude.com/cai/oauth/authorize"; + +const SECRET_MARKERS = [BROWSER_CODE, URL_CODE_QUERY, URL_STATE_QUERY, MINTED_TOKEN, "TOKENSECRETxyz789"]; + +function expectNoSecret(text: string): void { + for (const marker of SECRET_MARKERS) { + expect(text).not.toContain(marker); + } +} + +// --- Service mocks ----------------------------------------------------------- + +const mockAgentService = vi.hoisted(() => ({ getById: vi.fn() })); +const mockLogActivity = vi.hoisted(() => vi.fn()); +const mockHeartbeatService = vi.hoisted(() => ({ wakeup: vi.fn() })); +const mockIssueService = vi.hoisted(() => ({ getById: vi.fn(), getByIdentifier: vi.fn() })); +const mockInstanceSettingsService = vi.hoisted(() => ({ + get: vi.fn(), + getExperimental: vi.fn(), + getGeneral: vi.fn(), + listCompanyIds: vi.fn(), +})); +const mockRunSecretRedactionRegistry = vi.hoisted(() => ({ + redactForRun: vi.fn(async (_companyId: string, _runId: string, value: unknown) => value), +})); + +function registerModuleMocks(): void { + vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); + vi.doMock("../services/agents.js", () => ({ agentService: () => mockAgentService })); + vi.doMock("../services/heartbeat.js", () => ({ heartbeatService: () => mockHeartbeatService })); + vi.doMock("../services/instance-settings.js", () => ({ + instanceSettingsService: () => mockInstanceSettingsService, + })); + vi.doMock("../services/issues.js", () => ({ issueService: () => mockIssueService })); + vi.doMock("../services/run-secret-redaction.js", () => ({ + createRunSecretRedactionRegistry: () => mockRunSecretRedactionRegistry, + })); + vi.doMock("../services/index.js", () => ({ + agentService: () => mockAgentService, + agentInstructionsService: () => ({}), + accessService: () => ({ + canUser: vi.fn(async () => true), + decide: vi.fn(async (input: { action?: string }) => ({ + allowed: true, + action: input.action, + reason: "allow_explicit_grant", + explanation: "Allowed by test grant.", + })), + hasPermission: vi.fn(async () => true), + }), + approvalService: () => ({}), + builtInAgentService: () => ({ ensureCompanyDefaultAgentGrants: vi.fn() }), + companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }), + budgetService: () => ({}), + heartbeatService: () => mockHeartbeatService, + issueApprovalService: () => ({}), + issueService: () => mockIssueService, + logActivity: mockLogActivity, + secretService: () => ({}), + syncInstructionsBundleConfigFromFilePath: vi.fn((_agent: unknown, config: unknown) => config), + workspaceOperationService: () => ({}), + })); + vi.doMock("../adapters/index.js", () => ({ + findServerAdapter: vi.fn(), + listAdapterModels: vi.fn(), + detectAdapterModel: vi.fn(), + findActiveServerAdapter: vi.fn(), + requireServerAdapter: vi.fn(), + })); +} + +// The actor the request middleware installs. A test switches the owner id to +// prove the owner-binding check (SR-3). +let currentActor: Record; +function useOwner(userId: string = OWNER_USER_ID): void { + currentActor = { + type: "board", + userId, + companyIds: [COMPANY_ID], + source: "local_implicit", + isInstanceAdmin: false, + }; +} + +// --- Fake transport ---------------------------------------------------------- + +interface TransportHandle { + factory: SetupTokenLoginProcessFactory; + leases: SetupTokenLeaseManager; + store: SetupTokenCleanupStore; + submittedCodes: string[]; +} + +/** + * Builds a fake login transport. The factory surfaces the sign-in URL at once. + * On submit it either completes the login and delivers the token, throws an + * internal error, or leaves the process pending. The lease manager and the store + * are in-memory and hold no secret. + */ +function buildTransport(opts: { onSubmit?: "complete" | "throw" | "pending" } = {}): TransportHandle { + const mode = opts.onSubmit ?? "complete"; + const submittedCodes: string[] = []; + const rows = new Map(); + const store: SetupTokenCleanupStore = { + async record(record) { + rows.set(record.sessionId, { ...record }); + }, + async markState(sessionId, state) { + const row = rows.get(sessionId); + if (row) row.state = state; + }, + async remove(sessionId) { + rows.delete(sessionId); + }, + async listReapable() { + return []; + }, + }; + const leases: SetupTokenLeaseManager = { + async acquire() { + return { id: "lease-1" }; + }, + async release() {}, + async releaseById() {}, + }; + const factory: SetupTokenLoginProcessFactory = ({ onPrompt, onCredential }) => { + onPrompt({ url: FULL_LOGIN_URL }); + let resolveDone!: (outcome: SetupTokenLoginOutcome) => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + return { + done, + submitCode(code: string) { + submittedCodes.push(code); + if (mode === "throw") { + throw new Error("the sandbox pseudo-terminal write failed."); + } + if (mode === "complete") { + onCredential(MINTED_TOKEN); + resolveDone("success"); + } + // pending: the process stays open; the test drives no completion. + }, + stop() {}, + }; + }; + return { factory, leases, store, submittedCodes }; +} + +// --- App builder with a capturing request logger ----------------------------- + +interface AppHandle { + app: express.Express; + logLines: string[]; +} + +async function createApp(opts: { + deploymentMode?: "local_trusted" | "authenticated"; + confidentialProxyAllowlist?: string[]; + transport?: TransportHandle; +} = {}): Promise { + const [{ agentRoutes }, { errorHandler }, pinoModule, pinoHttpModule, redactModule] = + await Promise.all([ + vi.importActual("../routes/agents.js"), + vi.importActual("../middleware/index.js"), + vi.importActual("pino"), + vi.importActual("pino-http"), + vi.importActual( + "../middleware/redact-sensitive.js", + ), + ]); + const { pino } = pinoModule; + const { pinoHttp } = pinoHttpModule; + const { redactSensitive } = redactModule; + + // Capture every request-log line into an array. The custom properties mirror + // the production request logger: on a 4xx or 5xx response it logs the request + // body, params, and query through `redactSensitive`, so a secret in a request + // body cannot reach the log line. + const logLines: string[] = []; + const captureStream = new Writable({ + write(chunk, _encoding, callback) { + logLines.push(chunk.toString()); + callback(); + }, + }); + const captureLogger = pino({ level: "debug" }, captureStream); + const httpCapture = pinoHttp({ + logger: captureLogger, + customProps(req, res) { + if (res.statusCode < 400) return {}; + const ctx = (res as unknown as { __errorContext?: Record }).__errorContext; + if (ctx) { + return { + errorContext: ctx.error, + reqBody: redactSensitive(ctx.reqBody), + reqParams: redactSensitive(ctx.reqParams), + reqQuery: redactSensitive(ctx.reqQuery), + }; + } + const props: Record = {}; + const { body, params, query } = req as unknown as { + body?: unknown; + params?: unknown; + query?: unknown; + }; + if (body && typeof body === "object" && Object.keys(body).length > 0) { + props.reqBody = redactSensitive(body); + } + if (params && typeof params === "object" && Object.keys(params).length > 0) { + props.reqParams = redactSensitive(params); + } + if (query && typeof query === "object" && Object.keys(query).length > 0) { + props.reqQuery = redactSensitive(query); + } + return props; + }, + }); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as unknown as { actor: unknown }).actor = currentActor; + next(); + }); + app.use(httpCapture); + app.use( + "/api", + agentRoutes({} as never, { + deploymentMode: opts.deploymentMode, + confidentialProxyAllowlist: opts.confidentialProxyAllowlist, + setupTokenLogin: opts.transport + ? { factory: opts.transport.factory, leases: opts.transport.leases, store: opts.transport.store } + : undefined, + }), + ); + app.use(errorHandler); + return { app, logLines }; +} + +// Lets the pending microtasks settle, so the login-process `done` handler runs +// its terminal-state transition and the cleanup before the next request. +async function settle(): Promise { + for (let i = 0; i < 3; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +} + +const BASE = `/api/agents/${AGENT_ID}/setup-token-login-sessions`; + +beforeEach(() => { + vi.resetModules(); + registerModuleMocks(); + vi.clearAllMocks(); + useOwner(); + mockAgentService.getById.mockResolvedValue({ + id: AGENT_ID, + companyId: COMPANY_ID, + name: "Claude agent", + adapterType: "claude_local", + }); +}); + +describe("setup-token login route — full path", () => { + it("drives start, read-prompt, submit-code, and receive-token with a fake transport", async () => { + const transport = buildTransport({ onSubmit: "complete" }); + const { app } = await createApp({ transport }); + + // Start the session. The route returns the opaque id and no secret. + const startRes = await request(app).post(BASE).send({}); + expect(startRes.status, JSON.stringify(startRes.body)).toBe(201); + const sessionId = startRes.body.sessionId as string; + expect(sessionId).toBeTruthy(); + expect(startRes.headers["cache-control"]).toBe("no-store"); + expectNoSecret(JSON.stringify(startRes.body)); + + // Read the prompt. The owner response carries the full URL, with no-store. + const promptRes = await request(app).get(`${BASE}/${sessionId}/prompt`).send(); + expect(promptRes.status, JSON.stringify(promptRes.body)).toBe(200); + expect(promptRes.body.loginUrl).toBe(FULL_LOGIN_URL); + expect(promptRes.body.state).toBe("awaiting_code"); + expect(promptRes.headers["cache-control"]).toBe("no-store"); + + // Submit the one browser code. The response carries no secret. + const codeRes = await request(app).post(`${BASE}/${sessionId}/code`).send({ browserCode: BROWSER_CODE }); + expect(codeRes.status, JSON.stringify(codeRes.body)).toBe(200); + expect(transport.submittedCodes).toEqual([BROWSER_CODE]); + expectNoSecret(JSON.stringify(codeRes.body)); + + await settle(); + + // Receive the token one time. The owner response carries the token, with + // no-store. + const tokenRes = await request(app).post(`${BASE}/${sessionId}/token`).send({}); + expect(tokenRes.status, JSON.stringify(tokenRes.body)).toBe(200); + expect(tokenRes.body.token).toBe(MINTED_TOKEN); + expect(tokenRes.headers["cache-control"]).toBe("no-store"); + + // The second receive returns the same not-found error as a missing session. + const secondTokenRes = await request(app).post(`${BASE}/${sessionId}/token`).send({}); + expect(secondTokenRes.status).toBe(404); + expect(secondTokenRes.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND); + }); + + it("returns the fixed no-secret error when no transport is bound", async () => { + const { app } = await createApp({}); + const startRes = await request(app).post(BASE).send({}); + expect(startRes.status).toBe(503); + expect(startRes.headers["cache-control"]).toBe("no-store"); + expectNoSecret(JSON.stringify(startRes.body)); + }); + + it("binds the session to the owner: a different owner gets the same not-found error", async () => { + const transport = buildTransport({ onSubmit: "pending" }); + const { app } = await createApp({ transport }); + + const startRes = await request(app).post(BASE).send({}); + const sessionId = startRes.body.sessionId as string; + + // A different owner reads the same id. The route returns the not-found error, + // so a caller cannot tell a cross-owner session from a missing one. + useOwner(OTHER_USER_ID); + const promptRes = await request(app).get(`${BASE}/${sessionId}/prompt`).send(); + expect(promptRes.status).toBe(404); + expect(promptRes.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND); + expect(JSON.stringify(promptRes.body)).not.toContain(URL_CODE_QUERY); + }); +}); + +describe("setup-token login route — SR-1 and SR-5 (no secret in a sink)", () => { + it("keeps the code, the URL query, and the token out of every log, activity, and non-owner sink", async () => { + const transport = buildTransport({ onSubmit: "complete" }); + const { app, logLines } = await createApp({ transport }); + + const startRes = await request(app).post(BASE).send({}); + const sessionId = startRes.body.sessionId as string; + + // The invalid-session path: a bogus id with the code in the body. + const invalidRes = await request(app) + .post(`${BASE}/bogus-session-id/code`) + .send({ browserCode: BROWSER_CODE }); + expect(invalidRes.status).toBe(404); + expect(invalidRes.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND); + expectNoSecret(JSON.stringify(invalidRes.body)); + + // The first submit completes the login. + const firstCode = await request(app).post(`${BASE}/${sessionId}/code`).send({ browserCode: BROWSER_CODE }); + expect(firstCode.status).toBe(200); + await settle(); + + // The duplicate-submit path: the same code again, after completion. + const duplicateRes = await request(app) + .post(`${BASE}/${sessionId}/code`) + .send({ browserCode: BROWSER_CODE }); + expect(duplicateRes.status).toBe(409); + expect(duplicateRes.body.error).toBe(SETUP_TOKEN_SUBMIT_CONFLICT); + expectNoSecret(JSON.stringify(duplicateRes.body)); + + await settle(); + + // The request log holds no secret, and it did log the redacted request body, + // so the check is not vacuous. + const logText = logLines.join("\n"); + expectNoSecret(logText); + expect(logText).toContain("[REDACTED]"); + + // No activity detail holds a secret. + const activityText = JSON.stringify(mockLogActivity.mock.calls); + expectNoSecret(activityText); + }); + + it("keeps the code out of the exception metadata on the internal-error path", async () => { + const transport = buildTransport({ onSubmit: "throw" }); + const { app, logLines } = await createApp({ transport }); + + const startRes = await request(app).post(BASE).send({}); + const sessionId = startRes.body.sessionId as string; + + const errorRes = await request(app).post(`${BASE}/${sessionId}/code`).send({ browserCode: BROWSER_CODE }); + expect(errorRes.status).toBe(500); + expect(errorRes.body.error).toBe("Internal server error"); + expectNoSecret(JSON.stringify(errorRes.body)); + + await settle(); + const logText = logLines.join("\n"); + expectNoSecret(logText); + }); +}); + +describe("setup-token login route — SR-6 and SR-7 (fail-closed transport guard)", () => { + it("rejects a non-TLS confidential request and a spoofed forwarded protocol", async () => { + // Authenticated mode with no proxy allowlist: a loopback HTTP peer does not + // pass the confidential guard, so read-prompt and receive-token fail closed. + const transport = buildTransport({ onSubmit: "complete" }); + const { app } = await createApp({ transport, deploymentMode: "authenticated", confidentialProxyAllowlist: [] }); + + const startRes = await request(app).post(BASE).send({}); + const sessionId = startRes.body.sessionId as string; + + // read-prompt over plain HTTP → fixed no-secret error, no URL. + const promptRes = await request(app).get(`${BASE}/${sessionId}/prompt`).send(); + expect(promptRes.status).toBe(403); + expect(promptRes.body.error).toBe(SETUP_TOKEN_TRANSPORT_INSECURE); + expect(promptRes.headers["cache-control"]).toBe("no-store"); + expect(promptRes.body.loginUrl).toBeUndefined(); + expectNoSecret(JSON.stringify(promptRes.body)); + + // A spoofed forwarded protocol does not unlock the response. The guard reads + // the raw socket, not the header, unless the peer is on the allowlist. + const spoofRes = await request(app) + .get(`${BASE}/${sessionId}/prompt`) + .set("X-Forwarded-Proto", "https") + .send(); + expect(spoofRes.status).toBe(403); + expect(spoofRes.body.error).toBe(SETUP_TOKEN_TRANSPORT_INSECURE); + expectNoSecret(JSON.stringify(spoofRes.body)); + + // submit-code over plain HTTP → the guard fails closed. The browser code is + // the confidential OAuth secret, so it must never ride an untrusted transport. + // A spoofed forwarded protocol does not unlock it either. The code never + // reaches the transport. + const codeRes = await request(app).post(`${BASE}/${sessionId}/code`).send({ browserCode: BROWSER_CODE }); + expect(codeRes.status).toBe(403); + expect(codeRes.body.error).toBe(SETUP_TOKEN_TRANSPORT_INSECURE); + expectNoSecret(JSON.stringify(codeRes.body)); + + const spoofCodeRes = await request(app) + .post(`${BASE}/${sessionId}/code`) + .set("X-Forwarded-Proto", "https") + .send({ browserCode: BROWSER_CODE }); + expect(spoofCodeRes.status).toBe(403); + expect(spoofCodeRes.body.error).toBe(SETUP_TOKEN_TRANSPORT_INSECURE); + expectNoSecret(JSON.stringify(spoofCodeRes.body)); + + expect(transport.submittedCodes).toEqual([]); + + // receive-token over plain HTTP also fails closed and leaks no token. + const tokenRes = await request(app) + .post(`${BASE}/${sessionId}/token`) + .set("X-Forwarded-Proto", "https") + .send({}); + expect(tokenRes.status).toBe(403); + expect(tokenRes.body.error).toBe(SETUP_TOKEN_TRANSPORT_INSECURE); + expect(tokenRes.body.token).toBeUndefined(); + expectNoSecret(JSON.stringify(tokenRes.body)); + }); + + it("ignores a forwarded protocol from a non-allowlisted peer", async () => { + // A non-empty allowlist that does not match the loopback peer still fails + // closed for a forwarded HTTPS protocol. + const transport = buildTransport({ onSubmit: "pending" }); + const { app } = await createApp({ + transport, + deploymentMode: "authenticated", + confidentialProxyAllowlist: ["10.0.0.1"], + }); + + const startRes = await request(app).post(BASE).send({}); + const sessionId = startRes.body.sessionId as string; + + const promptRes = await request(app) + .get(`${BASE}/${sessionId}/prompt`) + .set("X-Forwarded-Proto", "https") + .send(); + expect(promptRes.status).toBe(403); + expect(promptRes.body.error).toBe(SETUP_TOKEN_TRANSPORT_INSECURE); + expectNoSecret(JSON.stringify(promptRes.body)); + }); +}); diff --git a/server/src/services/setup-token-session.test.ts b/server/src/services/setup-token-session.test.ts new file mode 100644 index 0000000000..7614d9a807 --- /dev/null +++ b/server/src/services/setup-token-session.test.ts @@ -0,0 +1,547 @@ +import { describe, expect, it } from "vitest"; +import { + SetupTokenSessionService, + SetupTokenSessionError, + assessConfidentialStartup, + evaluateConfidentialTransport, + isTerminalSessionState, + toSanitizedLoginUrl, + SETUP_TOKEN_SESSION_NOT_FOUND, + SETUP_TOKEN_SUBMIT_CONFLICT, + SETUP_TOKEN_RATE_LIMITED, + SETUP_TOKEN_CAP_EXCEEDED, + SETUP_TOKEN_TOKEN_UNAVAILABLE, + type SetupTokenCleanupRecord, + type SetupTokenCleanupStore, + type SetupTokenCredentialSink, + type SetupTokenLease, + type SetupTokenLeaseManager, + type SetupTokenLoginOutcome, + type SetupTokenLoginProcess, + type SetupTokenLoginProcessFactory, + type SetupTokenPromptSink, + type SetupTokenRateLimiter, + type SetupTokenSessionScope, +} from "./setup-token-session.js"; +import { redactSensitive } from "../middleware/redact-sensitive.js"; +import { sanitizeRecord } from "../redaction.js"; + +const FULL_LOGIN_URL = + "https://claude.com/cai/oauth/authorize?client_id=abc&code=SECRETCODE123&code_challenge=xyz&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost&response_type=code&scope=org&state=STATEVALUE"; + +// A synthetic token. The session passes the token through in memory; it does not +// parse it. No real token is present. +const SYNTH_TOKEN = "sk-ant-oat01-SYNTHETICSYNTHETICSYNTHETIC01"; + +const OWNER_SCOPE: SetupTokenSessionScope = { + companyId: "company-1", + ownerUserId: "user-1", + targetAgentId: "agent-1", +}; + +/** A controllable fake login process. It records the call order into `events`. */ +class FakeProcess implements SetupTokenLoginProcess { + readonly done: Promise; + private resolveDone!: (outcome: SetupTokenLoginOutcome) => void; + submittedCode: string | null = null; + submitCalls = 0; + stopCalls = 0; + constructor( + readonly id: string, + readonly onPrompt: SetupTokenPromptSink, + readonly onCredential: SetupTokenCredentialSink, + private readonly events: string[], + ) { + this.done = new Promise((resolve) => { + this.resolveDone = resolve; + }); + } + surfacePrompt(url: string): void { + this.onPrompt({ url }); + } + surfaceCredential(token: string): void { + this.onCredential(token); + } + finish(outcome: SetupTokenLoginOutcome): void { + this.resolveDone(outcome); + } + submitCode(code: string): void { + this.submitCalls += 1; + this.submittedCode = code; + this.events.push(`submit:${this.id}`); + } + stop(): void { + this.stopCalls += 1; + this.events.push(`stop:${this.id}`); + } +} + +class FakeLeaseManager implements SetupTokenLeaseManager { + acquired: string[] = []; + released: string[] = []; + releaseByIdCalls: string[] = []; + failReleaseOnce = false; + private counter = 0; + constructor(private readonly events: string[] = []) {} + async acquire(input: { scope: SetupTokenSessionScope; deadline: number }): Promise { + this.counter += 1; + const id = `lease-${this.counter}`; + this.acquired.push(id); + return { id }; + } + async release(lease: SetupTokenLease): Promise { + if (this.failReleaseOnce) { + this.failReleaseOnce = false; + throw new Error("release failed"); + } + this.released.push(lease.id); + this.events.push(`release:${lease.id}`); + } + async releaseById(leaseId: string): Promise { + this.releaseByIdCalls.push(leaseId); + this.released.push(leaseId); + } +} + +class FakeStore implements SetupTokenCleanupStore { + rows = new Map(); + async record(record: SetupTokenCleanupRecord): Promise { + this.rows.set(record.sessionId, { ...record }); + } + async markState(sessionId: string, state: SetupTokenCleanupRecord["state"]): Promise { + const row = this.rows.get(sessionId); + if (row) row.state = state; + } + async remove(sessionId: string): Promise { + this.rows.delete(sessionId); + } + async listReapable(now: number): Promise { + return [...this.rows.values()].filter( + (row) => isTerminalSessionState(row.state) || row.deadline <= now, + ); + } +} + +function allowAllRateLimiter(): SetupTokenRateLimiter { + return { consume: () => ({ allowed: true, retryAfterSeconds: 0 }) }; +} + +function buildService(overrides: { + events?: string[]; + leases?: FakeLeaseManager; + store?: FakeStore; + rateLimiter?: SetupTokenRateLimiter; + caps?: { perOwner: number; perAgent: number; perCompany: number }; + ttlMs?: number; + tokenRetentionMs?: number; + now?: () => number; +} = {}) { + const events = overrides.events ?? []; + const processes: FakeProcess[] = []; + let processCounter = 0; + const factory: SetupTokenLoginProcessFactory = ({ onPrompt, onCredential }) => { + processCounter += 1; + const process = new FakeProcess(`p${processCounter}`, onPrompt, onCredential, events); + processes.push(process); + return process; + }; + const leases = overrides.leases ?? new FakeLeaseManager(events); + const store = overrides.store ?? new FakeStore(); + const service = new SetupTokenSessionService({ + factory, + leases, + store, + rateLimiter: overrides.rateLimiter ?? allowAllRateLimiter(), + caps: overrides.caps ?? { perOwner: 5, perAgent: 5, perCompany: 5 }, + ttlMs: overrides.ttlMs ?? 60_000, + tokenRetentionMs: overrides.tokenRetentionMs, + now: overrides.now, + }); + return { service, processes, leases, store, events }; +} + +describe("SetupTokenSessionService.start", () => { + it("returns a session id and no token, and holds one live process and lease", async () => { + const { service, processes, leases } = buildService(); + const result = await service.start(OWNER_SCOPE); + expect(result.sessionId).toBeTruthy(); + expect(result).not.toHaveProperty("token"); + expect(processes).toHaveLength(1); + expect(leases.acquired).toEqual(["lease-1"]); + expect(service.activeSessionCount()).toBe(1); + }); + + it("returns an opaque, high-entropy session id", async () => { + const { service } = buildService(); + const a = await service.start(OWNER_SCOPE); + const b = await service.start({ ...OWNER_SCOPE, ownerUserId: "user-2" }); + expect(a.sessionId).not.toEqual(b.sessionId); + expect(a.sessionId.length).toBeGreaterThanOrEqual(32); + }); + + it("rejects a caller over the rate limit with 429", async () => { + let allowed = true; + const rateLimiter: SetupTokenRateLimiter = { + consume: () => { + const decision = { allowed, retryAfterSeconds: allowed ? 0 : 30 }; + allowed = false; + return decision; + }, + }; + const { service } = buildService({ rateLimiter }); + await service.start(OWNER_SCOPE); + await expect(service.start(OWNER_SCOPE)).rejects.toMatchObject({ + status: 429, + message: SETUP_TOKEN_RATE_LIMITED, + }); + }); + + it("rejects a caller over the per-owner cap with 429", async () => { + const { service } = buildService({ caps: { perOwner: 1, perAgent: 5, perCompany: 5 } }); + await service.start(OWNER_SCOPE); + await expect(service.start({ ...OWNER_SCOPE, targetAgentId: "agent-2" })).rejects.toMatchObject({ + status: 429, + message: SETUP_TOKEN_CAP_EXCEEDED, + }); + }); +}); + +describe("SetupTokenSessionService.readPrompt", () => { + it("returns the full login URL to the authorized owner once the prompt surfaces", async () => { + const { service, processes } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + expect(service.readPrompt(sessionId, OWNER_SCOPE).loginUrl).toBeNull(); + processes[0].surfacePrompt(FULL_LOGIN_URL); + const view = service.readPrompt(sessionId, OWNER_SCOPE); + expect(view.state).toBe("awaiting_code"); + expect(view.loginUrl).toBe(FULL_LOGIN_URL); + }); +}); + +describe("SetupTokenSessionService.submitCode", () => { + it("accepts one browser code, advances the live session once, and rejects every later submit", async () => { + const { service, processes } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + + const first = service.submitCode(sessionId, OWNER_SCOPE, "browsercode-1"); + expect(first.state).toBe("submitting"); + expect(processes[0].submitCalls).toBe(1); + expect(processes[0].submittedCode).toBe("browsercode-1"); + + expect(() => service.submitCode(sessionId, OWNER_SCOPE, "browsercode-2")).toThrow( + SETUP_TOKEN_SUBMIT_CONFLICT, + ); + // A later submit, including one after an invalid-code retry, never reaches + // the live process. + expect(processes[0].submitCalls).toBe(1); + }); + + it("rejects a submit before the prompt surfaces", async () => { + const { service } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + expect(() => service.submitCode(sessionId, OWNER_SCOPE, "code")).toThrow(SetupTokenSessionError); + }); +}); + +describe("SetupTokenSessionService authorization boundary", () => { + const crossCompany: SetupTokenSessionScope = { ...OWNER_SCOPE, companyId: "company-2" }; + const sameCompanyOtherUser: SetupTokenSessionScope = { ...OWNER_SCOPE, ownerUserId: "user-9" }; + const otherAgent: SetupTokenSessionScope = { ...OWNER_SCOPE, targetAgentId: "agent-9" }; + + it("returns the same not-found for a cross-scope caller on every operation", async () => { + const { service, processes } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + + for (const scope of [crossCompany, sameCompanyOtherUser, otherAgent]) { + expect(() => service.readPrompt(sessionId, scope)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + expect(() => service.submitCode(sessionId, scope, "code")).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + expect(() => service.receiveToken(sessionId, scope)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + await expect(service.cancel(sessionId, scope)).rejects.toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + await expect(service.expire(sessionId, scope)).rejects.toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + } + }); + + it("returns the same not-found for a missing session", async () => { + const { service } = buildService(); + expect(() => service.readPrompt("missing", OWNER_SCOPE)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + }); +}); + +describe("SetupTokenSessionService cleanup order", () => { + it("stops the direct child before the lease release on cancel", async () => { + const events: string[] = []; + const { service } = buildService({ events }); + const { sessionId } = await service.start(OWNER_SCOPE); + await service.cancel(sessionId, OWNER_SCOPE); + const stopIndex = events.indexOf("stop:p1"); + const releaseIndex = events.indexOf("release:lease-1"); + expect(stopIndex).toBeGreaterThanOrEqual(0); + expect(releaseIndex).toBeGreaterThanOrEqual(0); + expect(stopIndex).toBeLessThan(releaseIndex); + expect(service.activeSessionCount()).toBe(0); + }); + + it("stops the direct child before the lease release on expire", async () => { + const events: string[] = []; + const { service } = buildService({ events }); + const { sessionId } = await service.start(OWNER_SCOPE); + const result = await service.expire(sessionId, OWNER_SCOPE); + expect(result.state).toBe("timed_out"); + expect(events.indexOf("stop:p1")).toBeLessThan(events.indexOf("release:lease-1")); + }); + + it("releases the lease when the process ends on its own", async () => { + const { service, processes, leases } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + service.submitCode(sessionId, OWNER_SCOPE, "code"); + processes[0].finish("success"); + await new Promise((resolve) => setImmediate(resolve)); + expect(leases.released).toEqual(["lease-1"]); + expect(service.activeSessionCount()).toBe(0); + }); +}); + +describe("SetupTokenSessionService durable reaper", () => { + it("replays the durable record and releases the orphaned lease after a restart", async () => { + const store = new FakeStore(); + const leases = new FakeLeaseManager(); + // Simulate a prior process that crashed with a live record and a lease. + await store.record({ + sessionId: "orphan-1", + companyId: "company-1", + ownerUserId: "user-1", + targetAgentId: "agent-1", + leaseId: "lease-orphan", + deadline: 1_000, + state: "awaiting_code", + }); + const { service } = buildService({ store, leases, now: () => 5_000 }); + const summary = await service.reap(5_000); + expect(summary.released).toBe(1); + expect(leases.releaseByIdCalls).toEqual(["lease-orphan"]); + expect(store.rows.size).toBe(0); + }); + + it("keeps the record when the reaper lease release fails, so it stays retryable", async () => { + const store = new FakeStore(); + await store.record({ + sessionId: "orphan-2", + companyId: "company-1", + ownerUserId: "user-1", + targetAgentId: "agent-1", + leaseId: "lease-orphan-2", + deadline: 1_000, + state: "failed", + }); + const leases = new FakeLeaseManager(); + leases.releaseById = async () => { + throw new Error("provider down"); + }; + const { service } = buildService({ store, leases, now: () => 5_000 }); + const summary = await service.reap(5_000); + expect(summary.failed).toBe(1); + expect(store.rows.has("orphan-2")).toBe(true); + }); +}); + +describe("SetupTokenSessionService.receiveToken", () => { + it("returns the token once to the authorized owner after a successful login", async () => { + const { service, processes, leases } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + service.submitCode(sessionId, OWNER_SCOPE, "code"); + processes[0].surfaceCredential(SYNTH_TOKEN); + processes[0].finish("success"); + await new Promise((resolve) => setImmediate(resolve)); + // The service releases the sandbox lease at once, but it retains the token + // for the owner to receive it one time. + expect(leases.released).toEqual(["lease-1"]); + const received = service.receiveToken(sessionId, OWNER_SCOPE); + expect(received.token).toBe(SYNTH_TOKEN); + // One-shot: a second receive returns the same not-found as a missing session. + expect(() => service.receiveToken(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + }); + + it("returns the fixed unavailable error before the token is delivered", async () => { + const { service, processes } = buildService(); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + service.submitCode(sessionId, OWNER_SCOPE, "code"); + // The login has not delivered the token yet, so receive-token is unavailable. + expect(() => service.receiveToken(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_TOKEN_UNAVAILABLE); + }); + + it("purges the retained token when the retention window ends", async () => { + const { service, processes } = buildService({ tokenRetentionMs: 5 }); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + service.submitCode(sessionId, OWNER_SCOPE, "code"); + processes[0].surfaceCredential(SYNTH_TOKEN); + processes[0].finish("success"); + await new Promise((resolve) => setTimeout(resolve, 20)); + // The retention timer purged the token, so the session is gone. + expect(() => service.receiveToken(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND); + }); +}); + +describe("no secret reaches a sink (SR-1, SR-5)", () => { + it("keeps the full login URL, the code, and the token out of the durable record", async () => { + const store = new FakeStore(); + const { service, processes } = buildService({ store }); + const { sessionId } = await service.start(OWNER_SCOPE); + processes[0].surfacePrompt(FULL_LOGIN_URL); + service.submitCode(sessionId, OWNER_SCOPE, "SECRETCODE123"); + processes[0].surfaceCredential(SYNTH_TOKEN); + const serialized = JSON.stringify([...store.rows.values()]); + expect(serialized).not.toContain("SECRETCODE123"); + expect(serialized).not.toContain("STATEVALUE"); + expect(serialized).not.toContain("cai/oauth/authorize"); + expect(serialized).not.toContain(SYNTH_TOKEN); + }); + + it("sanitizes the login URL to origin and path only", () => { + expect(toSanitizedLoginUrl(FULL_LOGIN_URL)).toBe("https://claude.com/cai/oauth/authorize"); + expect(toSanitizedLoginUrl("not a url")).toBe("[unparsable-login-url]"); + }); + + it("redacts the browserCode and authorization_code in the HTTP-log sanitizer", () => { + const redacted = redactSensitive({ + browserCode: "SECRETCODE123", + authorization_code: "AUTHCODE", + loginUrl: FULL_LOGIN_URL, + nested: { browserCode: "SECRETCODE123" }, + }) as Record; + expect(redacted.browserCode).toBe("[REDACTED]"); + expect(redacted.authorization_code).toBe("[REDACTED]"); + expect(redacted.loginUrl).toBe("https://claude.com/cai/oauth/authorize"); + expect((redacted.nested as Record).browserCode).toBe("[REDACTED]"); + }); + + it("redacts the browserCode and authorization_code in the activity sanitizer", () => { + const redacted = sanitizeRecord({ + browserCode: "SECRETCODE123", + authorization_code: "AUTHCODE", + loginUrl: FULL_LOGIN_URL, + }); + const serialized = JSON.stringify(redacted); + expect(serialized).not.toContain("SECRETCODE123"); + expect(serialized).not.toContain("AUTHCODE"); + expect(serialized).not.toContain("STATEVALUE"); + }); +}); + +describe("confidential transport guard (SR-6, SR-7)", () => { + const authenticatedNoProxy = { + deploymentMode: "authenticated" as const, + trustedProxies: [] as string[], + }; + const authenticatedWithProxy = { + deploymentMode: "authenticated" as const, + trustedProxies: ["10.0.0.5"], + }; + const localTrusted = { deploymentMode: "local_trusted" as const, trustedProxies: [] as string[] }; + + it("allows a direct TLS request", () => { + expect( + evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: true, + remoteAddress: "203.0.113.7", + forwardedProto: undefined, + }).allowed, + ).toBe(true); + }); + + it("denies a direct non-loopback HTTP request (SR-6)", () => { + const decision = evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: undefined, + }); + expect(decision.allowed).toBe(false); + }); + + it("allows a local_trusted loopback request as the only local exception (SR-6)", () => { + expect( + evaluateConfidentialTransport(localTrusted, { + socketEncrypted: false, + remoteAddress: "127.0.0.1", + forwardedProto: undefined, + }).allowed, + ).toBe(true); + // The same loopback request under authenticated mode fails closed. + expect( + evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: false, + remoteAddress: "127.0.0.1", + forwardedProto: undefined, + }).allowed, + ).toBe(false); + }); + + it("denies a spoofed X-Forwarded-Proto when no trusted proxy is configured (SR-6)", () => { + const decision = evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: "https", + }); + expect(decision.allowed).toBe(false); + }); + + it("allows a forwarded HTTPS request from the configured proxy allowlist (SR-6, SR-7)", () => { + const decision = evaluateConfidentialTransport(authenticatedWithProxy, { + socketEncrypted: false, + remoteAddress: "10.0.0.5", + forwardedProto: "https", + }); + expect(decision.allowed).toBe(true); + }); + + it("allows a forwarded HTTPS request from a proxy inside a configured CIDR (SR-7)", () => { + const decision = evaluateConfidentialTransport( + { deploymentMode: "authenticated", trustedProxies: ["10.0.0.0/24"] }, + { socketEncrypted: false, remoteAddress: "10.0.0.200", forwardedProto: "https" }, + ); + expect(decision.allowed).toBe(true); + }); + + it("denies a forwarded HTTPS request from a peer outside the allowlist (SR-7)", () => { + const decision = evaluateConfidentialTransport(authenticatedWithProxy, { + socketEncrypted: false, + remoteAddress: "10.0.0.6", + forwardedProto: "https", + }); + expect(decision.allowed).toBe(false); + }); + + it("fails closed at startup when no proxy allowlist is configured (SR-7)", () => { + expect(assessConfidentialStartup(authenticatedNoProxy).proxyForwardingEnabled).toBe(false); + expect(assessConfidentialStartup(authenticatedWithProxy).proxyForwardingEnabled).toBe(true); + }); + + it("denies a direct non-loopback HTTP receive-token request, so it delivers no token (SR-6)", () => { + // The route calls this guard before receive-token. A denied decision makes + // the route return the fixed no-secret error and never read the token. + const decision = evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: undefined, + }); + expect(decision.allowed).toBe(false); + }); + + it("denies a spoofed forwarded HTTPS receive-token request under a broad proxy setting (SR-7)", () => { + // A broad `TRUST_PROXY=true` setting never populates the dedicated allowlist, + // so the guard reads an empty allowlist and fails closed on the forwarded + // protocol. The route returns the fixed no-secret error and delivers no + // token. + const decision = evaluateConfidentialTransport(authenticatedNoProxy, { + socketEncrypted: false, + remoteAddress: "203.0.113.7", + forwardedProto: "https", + }); + expect(decision.allowed).toBe(false); + }); +}); diff --git a/server/src/services/setup-token-session.ts b/server/src/services/setup-token-session.ts new file mode 100644 index 0000000000..d57ab21213 --- /dev/null +++ b/server/src/services/setup-token-session.ts @@ -0,0 +1,832 @@ +// The Claude setup-token login session service. It owns a company-scoped, +// owner-bound login session that holds one live setup-token process and one +// sandbox lease for the whole login. The login is a two-way round-trip: the +// `claude setup-token` process holds the flow state in memory, so the server +// keeps one live process and the lease alive inside one long-lived server +// process. The service never re-spawns the command per request and never splits +// the round-trip across separate runs. +// +// The service gives the harness these operations against the one live session: +// start the session, read the login prompt, submit one browser code, cancel the +// session, expire the session on a timeout, and receive the token. Every +// operation verifies the company, the owner user, and the target agent. A +// missing session and a cross-scope session both return the same not-found +// error. +// +// Security controls folded here: +// * SR-1 (no secret in a log): the service keeps the full login URL, the +// browser code, and the token out of every log, activity detail, error, and +// telemetry sink. It surfaces the full URL only through the authorized owner +// read response, and it exposes a sanitized URL form to every other reader. +// * SR-3 (owner binding, atomic one-code): an opaque random session id, an +// immutable scope, and a single compare-and-set transition from +// `awaiting_code` to `submitting`. The service rejects every later submit. +// * SR-4 (durable cleanup and caps): a non-secret cleanup record, an external +// lease expiry no later than the session deadline, idempotent cleanup on +// every terminal path, a startup reaper, per-owner/agent/company caps, and a +// start rate limit. +// * SR-5 (two login-URL representations): the full URL is transport-only; every +// sink receives the sanitized URL form. +// * SR-6 and SR-7 (fail-closed TLS transport guard): a centralized guard that +// the route applies to the confidential read-prompt and receive-token +// responses. The guard is a pure function in this module so it stays unit +// testable. + +import { randomBytes } from "node:crypto"; + +/** The session states. The four terminal states end the login. */ +export type SetupTokenSessionState = + | "starting" + | "awaiting_code" + | "submitting" + | "completed" + | "failed" + | "timed_out" + | "cancelled"; + +/** The four terminal states. Cleanup runs once when a session reaches one. */ +export const SETUP_TOKEN_TERMINAL_STATES: readonly SetupTokenSessionState[] = [ + "completed", + "failed", + "timed_out", + "cancelled", +]; + +export function isTerminalSessionState(state: SetupTokenSessionState): boolean { + return SETUP_TOKEN_TERMINAL_STATES.includes(state); +} + +/** + * The immutable owner scope of a session. Every operation verifies all three + * fields against the stored scope. The service builds the scope once at start + * and never changes it. + */ +export interface SetupTokenSessionScope { + companyId: string; + ownerUserId: string; + targetAgentId: string; +} + +/** The terminal outcome of the live login process. */ +export type SetupTokenLoginOutcome = "success" | "failure" | "timeout" | "cancelled"; + +/** + * The live login process the service drives for one session. A production + * factory binds this to the setup-token runner over a sandbox pseudo-terminal. + * A unit test binds it to a fake. The process holds the flow state in memory. + */ +export interface SetupTokenLoginProcess { + /** Resolves with the terminal outcome when the login process ends. */ + readonly done: Promise; + /** + * Forwards the one browser code to the live process. The service calls it one + * time, only after the single `awaiting_code` to `submitting` transition wins. + */ + submitCode(code: string): void; + /** + * Stops the direct child process. The service calls it before it releases the + * lease. The method must be safe to call more than one time. + */ + stop(): void; +} + +/** The prompt sink the factory calls one time when it surfaces the sign-in URL. */ +export type SetupTokenPromptSink = (prompt: { url: string }) => void; + +/** + * The credential sink the factory calls one time when the login binds the minted + * token from the success record. The service holds the token in memory only and + * returns it one time through receive-token. The sink never logs the token. + */ +export type SetupTokenCredentialSink = (token: string) => void; + +/** + * Builds the live login process for one session. The factory receives the + * prompt sink, the credential sink, the host timeout, and an abort signal that + * the service aborts on cancel and on expiry. The factory delivers the token one + * time through the credential sink. + */ +export type SetupTokenLoginProcessFactory = (params: { + scope: SetupTokenSessionScope; + onPrompt: SetupTokenPromptSink; + onCredential: SetupTokenCredentialSink; + timeoutMs: number; + signal: AbortSignal; +}) => SetupTokenLoginProcess; + +/** An opaque sandbox lease handle. The service holds one per session. */ +export interface SetupTokenLease { + id: string; +} + +/** + * Acquires and releases the sandbox lease for a login session. The production + * manager wraps the environment runtime. The service sets an external lease + * expiry no later than the session deadline through the `deadline` field. + */ +export interface SetupTokenLeaseManager { + acquire(input: { scope: SetupTokenSessionScope; deadline: number }): Promise; + release(lease: SetupTokenLease): Promise; + /** Releases a lease by id. The startup reaper uses this after a restart. */ + releaseById(leaseId: string): Promise; +} + +/** + * The non-secret cleanup record. It holds only ids, the deadline, and the + * terminal state. It never holds a URL, a code, a token, or a raw process chunk + * (SR-4). The service persists it at start so a restart can reap the lease. + */ +export interface SetupTokenCleanupRecord { + sessionId: string; + companyId: string; + ownerUserId: string; + targetAgentId: string; + leaseId: string; + deadline: number; + state: SetupTokenSessionState; +} + +/** + * The durable store for the non-secret cleanup record. A restart reads the + * store and reaps a lease whose session is terminal or past its deadline. The + * store persists no secret. + */ +export interface SetupTokenCleanupStore { + record(record: SetupTokenCleanupRecord): Promise; + markState(sessionId: string, state: SetupTokenSessionState): Promise; + remove(sessionId: string): Promise; + /** Returns each record whose session is terminal or whose deadline is past. */ + listReapable(now: number): Promise; +} + +/** A per-key start rate limiter. It matches the invite-rate-limit shape. */ +export interface SetupTokenRateLimiter { + consume(key: string): { allowed: boolean; retryAfterSeconds: number }; +} + +export interface SetupTokenSessionCaps { + perOwner: number; + perAgent: number; + perCompany: number; +} + +export const DEFAULT_SETUP_TOKEN_SESSION_CAPS: SetupTokenSessionCaps = { + perOwner: 1, + perAgent: 1, + perCompany: 3, +}; + +/** The default host timeout for one login session. */ +export const DEFAULT_SETUP_TOKEN_SESSION_TTL_MS = 5 * 60_000; + +/** + * The default retention window for a completed token. The service releases the + * sandbox lease at once on success, but it keeps the token in memory for this + * window, so the authorized owner can receive it one time. A short window bounds + * how long the service holds the secret if the owner never receives it (SR-4). + */ +export const DEFAULT_SETUP_TOKEN_RETENTION_MS = 60_000; + +// The fixed, non-secret error texts. The route returns these verbatim and +// echoes no input. A missing session and a cross-scope session share one text, +// so a caller cannot tell them apart. +export const SETUP_TOKEN_SESSION_NOT_FOUND = "Setup-token login session not found."; +export const SETUP_TOKEN_SUBMIT_CONFLICT = "The setup-token login session cannot accept this code."; +export const SETUP_TOKEN_RATE_LIMITED = "Too many setup-token login attempts. Try again later."; +export const SETUP_TOKEN_CAP_EXCEEDED = "Too many active setup-token login sessions."; +export const SETUP_TOKEN_TRANSPORT_INSECURE = + "This response requires a secure transport and is not available on this request."; +// The fixed error for a token that is not ready, or that the owner already +// received. It never tells the two cases apart, so it leaks no session state. +export const SETUP_TOKEN_TOKEN_UNAVAILABLE = "The setup-token is not available for this session."; +export const SETUP_TOKEN_START_FAILED = "The setup-token login session could not start."; + +/** A typed error the route maps to a fixed status and the fixed text above. */ +export class SetupTokenSessionError extends Error { + readonly status: number; + constructor(status: number, message: string) { + super(message); + this.name = "SetupTokenSessionError"; + this.status = status; + } +} + +/** + * Builds the sanitized login-URL form for every non-owner sink (SR-5). The + * function removes the query, the fragment, and the credentials, so no OAuth + * parameter and no secret reaches a log, an activity detail, an error, a trace, + * or client telemetry. It keeps the origin and the path for a useful diagnostic. + * It returns a fixed placeholder for a value it cannot parse, so it never falls + * back to the raw input. + */ +export function toSanitizedLoginUrl(rawUrl: string): string { + try { + const url = new URL(rawUrl); + url.username = ""; + url.password = ""; + url.search = ""; + url.hash = ""; + return url.toString(); + } catch { + return "[unparsable-login-url]"; + } +} + +// --- SR-6 and SR-7: the confidential-response transport guard ---------------- + +/** + * The startup transport configuration for the confidential responses. The + * server resolves it once at startup. `trustedProxies` is the dedicated, + * explicit proxy IP or CIDR allowlist for the confidential routes. The global + * `TRUST_PROXY` setting does not appear here, so `TRUST_PROXY=true` and a + * hop-count value never satisfy the guard (SR-7). + */ +export interface ConfidentialTransportConfig { + deploymentMode: "local_trusted" | "authenticated"; + trustedProxies: string[]; +} + +/** The per-request transport signals the guard reads from the raw socket. */ +export interface ConfidentialTransportRequest { + /** The raw TLS bit on the immediate socket. It ignores `trust proxy`. */ + socketEncrypted: boolean; + /** The immediate peer address. It ignores `trust proxy`. */ + remoteAddress: string | undefined; + /** The `X-Forwarded-Proto` header value. The guard reads its first hop only. */ + forwardedProto: string | undefined; +} + +export interface ConfidentialTransportDecision { + allowed: boolean; + reason: string; +} + +function isLoopbackAddress(address: string | undefined): boolean { + if (!address) return false; + const normalized = address.trim().toLowerCase(); + if (normalized === "::1" || normalized === "localhost") return true; + // Express reports an IPv4 loopback peer as `::ffff:127.0.0.1` on a dual stack. + const withoutV4Prefix = normalized.startsWith("::ffff:") + ? normalized.slice("::ffff:".length) + : normalized; + return withoutV4Prefix.startsWith("127."); +} + +function ipv4ToInt(address: string): number | null { + const parts = address.split("."); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + value = value * 256 + octet; + } + return value >>> 0; +} + +/** + * Returns true when `address` matches `entry`. `entry` is a single IPv4 or IPv6 + * address, or an IPv4 CIDR range. The function normalizes an IPv4-mapped IPv6 + * peer (`::ffff:a.b.c.d`) to its IPv4 form first. It matches an IPv6 entry only + * by an exact, case-insensitive string, because the confidential allowlist + * expects a small set of known proxy addresses. + */ +function addressMatchesEntry(address: string, entry: string): boolean { + const peer = address.trim().toLowerCase(); + const candidate = entry.trim().toLowerCase(); + if (candidate.length === 0) return false; + + const peerV4 = peer.startsWith("::ffff:") ? peer.slice("::ffff:".length) : peer; + + if (candidate.includes("/")) { + const [network, prefixText] = candidate.split("/"); + const prefix = Number(prefixText); + const networkInt = ipv4ToInt(network); + const peerInt = ipv4ToInt(peerV4); + if (networkInt === null || peerInt === null) return false; + if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false; + if (prefix === 0) return true; + const mask = prefix === 32 ? 0xffffffff : (0xffffffff << (32 - prefix)) >>> 0; + return (networkInt & mask) === (peerInt & mask); + } + + if (candidate === peer || candidate === peerV4) return true; + const candidateInt = ipv4ToInt(candidate); + const peerInt = ipv4ToInt(peerV4); + return candidateInt !== null && peerInt !== null && candidateInt === peerInt; +} + +function peerMatchesAllowlist(address: string | undefined, allowlist: string[]): boolean { + if (!address) return false; + return allowlist.some((entry) => addressMatchesEntry(address, entry)); +} + +function forwardedProtoFirstHop(forwardedProto: string | undefined): string | null { + if (!forwardedProto) return null; + const first = forwardedProto.split(",")[0]?.trim().toLowerCase(); + return first && first.length > 0 ? first : null; +} + +/** + * Decides whether the request may receive a confidential response (the full + * login URL or the token). The guard fails closed. It never trusts a + * client-supplied header for the TLS decision unless the immediate peer is on + * the dedicated proxy allowlist. It never reads the global `trust proxy` + * setting. + * + * The guard allows a confidential response only in these cases: + * 1. The immediate socket is TLS. A direct TLS request is always valid (SR-6). + * 2. The deployment is `local_trusted` and the peer is loopback. This is the + * only local exception (SR-6). + * 3. The peer is on the dedicated proxy allowlist and the forwarded protocol's + * first hop is `https`. A `TRUST_PROXY=true` or hop-count value does not + * reach this branch, because the guard never reads it (SR-7). + * + * Every other request fails closed and the route returns the fixed no-secret + * error. + */ +export function evaluateConfidentialTransport( + config: ConfidentialTransportConfig, + request: ConfidentialTransportRequest, +): ConfidentialTransportDecision { + if (request.socketEncrypted) { + return { allowed: true, reason: "direct_tls" }; + } + if (config.deploymentMode === "local_trusted" && isLoopbackAddress(request.remoteAddress)) { + return { allowed: true, reason: "local_trusted_loopback" }; + } + if ( + config.trustedProxies.length > 0 && + peerMatchesAllowlist(request.remoteAddress, config.trustedProxies) && + forwardedProtoFirstHop(request.forwardedProto) === "https" + ) { + return { allowed: true, reason: "allowlisted_proxy_tls" }; + } + return { allowed: false, reason: "insecure_transport" }; +} + +/** + * Assesses the confidential transport at startup (SR-7). The server disables + * proxy-forwarded confidential responses when the dedicated allowlist is empty. + * A direct TLS request and a `local_trusted` loopback request still pass at + * runtime, because the runtime guard checks them first. The server logs the + * returned reason so an operator can see why forwarded requests fail closed. + */ +export function assessConfidentialStartup(config: ConfidentialTransportConfig): { + proxyForwardingEnabled: boolean; + reason: string; +} { + if (config.trustedProxies.length > 0) { + return { proxyForwardingEnabled: true, reason: "proxy_allowlist_configured" }; + } + return { + proxyForwardingEnabled: false, + reason: + config.deploymentMode === "local_trusted" + ? "no_proxy_allowlist_local_trusted_loopback_only" + : "no_proxy_allowlist_direct_tls_only", + }; +} + +// --- The session service ----------------------------------------------------- + +interface StoredSession { + id: string; + scope: SetupTokenSessionScope; + state: SetupTokenSessionState; + deadline: number; + lease: SetupTokenLease; + process: SetupTokenLoginProcess; + abort: AbortController; + // The full login URL. The service holds it in memory only and returns it only + // through the authorized owner read response (SR-5). + loginUrl: string | null; + // The minted token. The service holds it in memory only, from the credential + // sink until the authorized owner receives it one time or the retention window + // ends (SR-1, SR-4). + token: string | null; + timer: ReturnType | null; + // The retention timer for a completed token. The service arms it after a + // successful login and clears it when the owner receives the token. + retentionTimer: ReturnType | null; + cleanupDone: boolean; +} + +/** The public read view of a session prompt. */ +export interface SetupTokenPromptView { + state: SetupTokenSessionState; + /** The full login URL, present only when the prompt has surfaced (SR-5). */ + loginUrl: string | null; +} + +export interface SetupTokenSessionServiceOptions { + factory: SetupTokenLoginProcessFactory; + leases: SetupTokenLeaseManager; + store: SetupTokenCleanupStore; + rateLimiter: SetupTokenRateLimiter; + caps?: SetupTokenSessionCaps; + ttlMs?: number; + /** The retention window for a completed token. Defaults to the constant. */ + tokenRetentionMs?: number; + now?: () => number; + /** Returns an opaque, cryptographically random session id. */ + generateSessionId?: () => string; + /** A non-leaking diagnostic sink. It receives only fixed status lines. */ + log?: (line: string) => void; +} + +function defaultSessionId(): string { + return randomBytes(32).toString("base64url"); +} + +/** + * The setup-token login session service. It holds every live session in memory + * and persists a non-secret cleanup record for each. It bounds active sessions + * per owner, per agent, and per company, and it rate-limits the start path. + */ +export class SetupTokenSessionService { + private readonly sessions = new Map(); + private readonly factory: SetupTokenLoginProcessFactory; + private readonly leases: SetupTokenLeaseManager; + private readonly store: SetupTokenCleanupStore; + private readonly rateLimiter: SetupTokenRateLimiter; + private readonly caps: SetupTokenSessionCaps; + private readonly ttlMs: number; + private readonly tokenRetentionMs: number; + private readonly now: () => number; + private readonly generateSessionId: () => string; + private readonly log: (line: string) => void; + + constructor(options: SetupTokenSessionServiceOptions) { + this.factory = options.factory; + this.leases = options.leases; + this.store = options.store; + this.rateLimiter = options.rateLimiter; + this.caps = options.caps ?? DEFAULT_SETUP_TOKEN_SESSION_CAPS; + this.ttlMs = options.ttlMs ?? DEFAULT_SETUP_TOKEN_SESSION_TTL_MS; + this.tokenRetentionMs = options.tokenRetentionMs ?? DEFAULT_SETUP_TOKEN_RETENTION_MS; + this.now = options.now ?? Date.now; + this.generateSessionId = options.generateSessionId ?? defaultSessionId; + this.log = options.log ?? (() => {}); + } + + private countActive(predicate: (session: StoredSession) => boolean): number { + let count = 0; + for (const session of this.sessions.values()) { + if (!isTerminalSessionState(session.state) && predicate(session)) count += 1; + } + return count; + } + + /** + * Starts a login session. It rate-limits the start, enforces the caps, + * acquires the lease with an external expiry no later than the deadline, + * persists the non-secret cleanup record, and starts the one live process. + * It returns the opaque session id and no token. + */ + async start(scope: SetupTokenSessionScope): Promise<{ sessionId: string; state: SetupTokenSessionState }> { + const rate = this.rateLimiter.consume(`${scope.companyId}:${scope.ownerUserId}`); + if (!rate.allowed) { + throw new SetupTokenSessionError(429, SETUP_TOKEN_RATE_LIMITED); + } + if (this.countActive((s) => s.scope.ownerUserId === scope.ownerUserId) >= this.caps.perOwner) { + throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED); + } + if (this.countActive((s) => s.scope.targetAgentId === scope.targetAgentId) >= this.caps.perAgent) { + throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED); + } + if (this.countActive((s) => s.scope.companyId === scope.companyId) >= this.caps.perCompany) { + throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED); + } + + const sessionId = this.generateSessionId(); + const deadline = this.now() + this.ttlMs; + const lease = await this.leases.acquire({ scope, deadline }); + + await this.store.record({ + sessionId, + companyId: scope.companyId, + ownerUserId: scope.ownerUserId, + targetAgentId: scope.targetAgentId, + leaseId: lease.id, + deadline, + state: "starting", + }); + + const abort = new AbortController(); + const session: StoredSession = { + id: sessionId, + scope, + state: "starting", + deadline, + lease, + // The factory replaces this placeholder synchronously below. + process: undefined as unknown as SetupTokenLoginProcess, + abort, + loginUrl: null, + token: null, + timer: null, + retentionTimer: null, + cleanupDone: false, + }; + + let process: SetupTokenLoginProcess; + try { + process = this.factory({ + scope, + onPrompt: (prompt) => this.onPrompt(session, prompt), + onCredential: (token) => this.onCredential(session, token), + timeoutMs: this.ttlMs, + signal: abort.signal, + }); + } catch { + // The factory could not start the process. Release the lease and drop the + // durable record, then return a fixed, non-secret error. + await this.releaseLeaseSafely(lease); + await this.store.remove(sessionId).catch(() => {}); + throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); + } + + session.process = process; + session.timer = setTimeout(() => { + void this.expireInternal(sessionId); + }, this.ttlMs); + // A timer must never keep the process alive on its own. + if (typeof session.timer.unref === "function") session.timer.unref(); + + this.sessions.set(sessionId, session); + // The process outcome drives the terminal state and the cleanup. + void process.done.then( + (outcome) => this.onProcessDone(session, outcome), + () => this.onProcessDone(session, "failure"), + ); + + return { sessionId, state: session.state }; + } + + private onPrompt(session: StoredSession, prompt: { url: string }): void { + if (isTerminalSessionState(session.state)) return; + // Hold the full URL in memory only. Never log it (SR-1, SR-5). + session.loginUrl = prompt.url; + if (session.state === "starting") { + session.state = "awaiting_code"; + void this.store.markState(session.id, "awaiting_code").catch(() => {}); + } + } + + /** + * Receives the minted token from the login process. The service holds the + * token in memory only, until the authorized owner receives it one time or the + * retention window ends. It never logs the token (SR-1). It ignores a token + * that arrives after a non-success terminal state. + */ + private onCredential(session: StoredSession, token: string): void { + if (isTerminalSessionState(session.state) && session.state !== "completed") return; + session.token = token; + } + + /** + * Resolves a session for an operation. It returns the session only when the + * id exists and the stored scope equals the caller scope in all three fields. + * A missing session and a cross-scope session both throw the same not-found + * error, so a caller cannot tell them apart (SR-3). + */ + private resolveOwned(sessionId: string, scope: SetupTokenSessionScope): StoredSession { + const session = this.sessions.get(sessionId); + if ( + !session || + session.scope.companyId !== scope.companyId || + session.scope.ownerUserId !== scope.ownerUserId || + session.scope.targetAgentId !== scope.targetAgentId + ) { + throw new SetupTokenSessionError(404, SETUP_TOKEN_SESSION_NOT_FOUND); + } + return session; + } + + /** + * Returns the prompt view for the authorized owner. The full login URL rides + * only in this response (SR-5). The route sets `Cache-Control: no-store` and + * applies the confidential transport guard before it calls this method. + */ + readPrompt(sessionId: string, scope: SetupTokenSessionScope): SetupTokenPromptView { + const session = this.resolveOwned(sessionId, scope); + return { state: session.state, loginUrl: session.loginUrl }; + } + + /** + * Submits the one browser code. It performs the single compare-and-set from + * `awaiting_code` to `submitting`, then forwards the code to the live process + * one time. It rejects every later submit, including one after an + * invalid-code retry (SR-3). + */ + submitCode(sessionId: string, scope: SetupTokenSessionScope, code: string): { state: SetupTokenSessionState } { + const session = this.resolveOwned(sessionId, scope); + if (session.state !== "awaiting_code") { + throw new SetupTokenSessionError(409, SETUP_TOKEN_SUBMIT_CONFLICT); + } + session.state = "submitting"; + void this.store.markState(session.id, "submitting").catch(() => {}); + session.process.submitCode(code); + return { state: session.state }; + } + + /** + * Cancels a session. It stops the direct child before it releases the lease. + * It is idempotent: a cancel on a terminal session returns the terminal state. + */ + async cancel(sessionId: string, scope: SetupTokenSessionScope): Promise<{ state: SetupTokenSessionState }> { + const session = this.resolveOwned(sessionId, scope); + if (isTerminalSessionState(session.state)) { + return { state: session.state }; + } + await this.terminate(session, "cancelled"); + return { state: session.state }; + } + + /** + * Expires a session on a timeout. It stops the direct child before it releases + * the lease. The harness can call it, and the deadline timer calls the same + * path internally. + */ + async expire(sessionId: string, scope: SetupTokenSessionScope): Promise<{ state: SetupTokenSessionState }> { + const session = this.resolveOwned(sessionId, scope); + if (isTerminalSessionState(session.state)) { + return { state: session.state }; + } + await this.terminate(session, "timed_out"); + return { state: session.state }; + } + + private async expireInternal(sessionId: string): Promise { + const session = this.sessions.get(sessionId); + if (!session || isTerminalSessionState(session.state)) return; + await this.terminate(session, "timed_out"); + } + + /** + * Returns the token for the authorized owner one time. The service returns the + * token only from a completed session that still holds it. It clears the token + * and drops the retained session at once, so a second receive returns the same + * not-found error as a missing session. It returns the fixed unavailable error + * when the token is not ready or the owner already received it. The route + * applies the confidential transport guard and sets `Cache-Control: no-store` + * before it calls this method. + */ + receiveToken(sessionId: string, scope: SetupTokenSessionScope): { token: string } { + // The scope check still runs, so a cross-scope caller gets the same + // not-found error, not the unavailable error. + const session = this.resolveOwned(sessionId, scope); + if (session.state !== "completed" || session.token === null) { + throw new SetupTokenSessionError(409, SETUP_TOKEN_TOKEN_UNAVAILABLE); + } + const token = session.token; + // One-shot delivery: clear the token and drop the retained session, so the + // service never returns the token a second time (SR-1). + this.purgeRetained(session.id); + return { token }; + } + + /** + * Stops the direct child, then runs the idempotent cleanup. The order stops + * the child before the lease release on every terminal path (SR-4). + */ + private async terminate(session: StoredSession, state: SetupTokenSessionState): Promise { + if (isTerminalSessionState(session.state)) return; + session.state = state; + session.abort.abort(); + try { + session.process.stop(); + } catch { + this.log("[paperclip] Setup-token session: the process stop step errored."); + } + await this.runCleanup(session, state); + } + + /** + * Maps the live process outcome to the terminal state and runs the cleanup. + * A cancel or an expire already set the state, so this call is a no-op then. + */ + private async onProcessDone(session: StoredSession, outcome: SetupTokenLoginOutcome): Promise { + if (isTerminalSessionState(session.state) && session.cleanupDone) return; + const state: SetupTokenSessionState = + outcome === "success" + ? "completed" + : outcome === "timeout" + ? "timed_out" + : outcome === "cancelled" + ? "cancelled" + : "failed"; + if (!isTerminalSessionState(session.state)) { + session.state = state; + } + await this.runCleanup(session, session.state); + } + + /** + * Runs the idempotent cleanup for a terminal session: clear the timer, mark + * the durable record terminal, release the lease, and drop the in-memory + * session. It clears the in-memory login URL reference promptly (SR-3, SR-5). + * A cleanup failure stays retryable and records no process output (SR-4). + * + * A completed session that still holds a token stays in memory until the + * authorized owner receives it one time or the retention window ends. The + * cleanup still releases the sandbox lease at once; it only holds the token in + * memory, not the sandbox (SR-4). + */ + private async runCleanup(session: StoredSession, state: SetupTokenSessionState): Promise { + if (session.cleanupDone) return; + session.cleanupDone = true; + if (session.timer) { + clearTimeout(session.timer); + session.timer = null; + } + // Clear the secret-bearing reference promptly. Best-effort only (SR-5). + session.loginUrl = null; + try { + await this.store.markState(session.id, state); + } catch { + this.log("[paperclip] Setup-token session: the cleanup record update failed; it stays retryable."); + } + await this.releaseLeaseSafely(session.lease); + try { + await this.store.remove(session.id); + } catch { + this.log("[paperclip] Setup-token session: the cleanup record removal failed; it stays retryable."); + } + if (state === "completed" && session.token !== null) { + // Retain the token in memory for the owner to receive it one time. The + // retention timer purges it if the owner never receives it (SR-4). + session.retentionTimer = setTimeout(() => this.purgeRetained(session.id), this.tokenRetentionMs); + if (typeof session.retentionTimer.unref === "function") session.retentionTimer.unref(); + return; + } + this.sessions.delete(session.id); + } + + /** + * Purges a retained completed session. It clears the token reference, clears + * the retention timer, and drops the in-memory session. The owner receive path + * and the retention timer both call it (SR-1, SR-4). + */ + private purgeRetained(sessionId: string): void { + const session = this.sessions.get(sessionId); + if (!session) return; + session.token = null; + if (session.retentionTimer) { + clearTimeout(session.retentionTimer); + session.retentionTimer = null; + } + this.sessions.delete(sessionId); + } + + private async releaseLeaseSafely(lease: SetupTokenLease): Promise { + try { + await this.leases.release(lease); + } catch { + // The lease release stays retryable and alertable. The startup reaper + // releases any lease that a crash or a failure left behind. + this.log("[paperclip] Setup-token session: the lease release failed; the reaper retries it."); + } + } + + /** + * The startup reaper. It reads the durable store and releases any lease whose + * session is terminal or past its deadline. It runs after a restart, so it + * frees a lease that a crash left behind (SR-4). A release failure stays + * retryable: the reaper leaves the record for a later run. + */ + async reap(now: number = this.now()): Promise<{ released: number; failed: number }> { + const records = await this.store.listReapable(now); + let released = 0; + let failed = 0; + for (const record of records) { + try { + await this.leases.releaseById(record.leaseId); + await this.store.remove(record.sessionId); + released += 1; + } catch { + failed += 1; + this.log("[paperclip] Setup-token reaper: a lease release failed; it stays retryable."); + } + } + return { released, failed }; + } + + /** + * The graceful-shutdown cleanup. It cancels every live session, so the server + * stops each direct child before it releases each lease (SR-4). + */ + async shutdown(): Promise { + const live = [...this.sessions.values()].filter((s) => !isTerminalSessionState(s.state)); + for (const session of live) { + await this.terminate(session, "cancelled"); + } + } + + /** Returns the count of live sessions. The route and the tests use it. */ + activeSessionCount(): number { + return this.countActive(() => true); + } +}