feat: Claude agent setup-token login in a sandbox (#11286)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Claude agents that run in a remote sandbox need a safe in-product login path > - The existing host login route cannot open a pseudo-terminal inside that sandbox > - The login flow must protect the browser code, the login URL, and the OAuth token at every step > - This pull request adds the parser, the runner, a Daytona pseudo-terminal transport, and a guarded, owner-bound session route behind an injectable transport > - The route stays inert in the default build and fails closed until a sandbox provider binds the live transport > - The benefit is a company-scoped setup-token flow with one-time secret delivery, redaction, and fail-closed transport checks, ready for a later staged production rollout ## Linked Issues or Issue Description **Agent or provider** Claude Code setup-token login for sandbox agents. **Why this adapter is useful** Sandbox agents need a supported way to sign in without host credentials. An authorized owner completes the browser step and receives the token one time. **How the agent is invoked** When a sandbox provider binds the injectable transport, the server starts `claude setup-token` through a sandbox pseudo-terminal, sends the browser code to the matched prompt, and returns the token through the guarded session route. The default build does not bind the transport. In that state the start route fails closed with a fixed no-secret `503`. It does not start a process and it does not hold a sandbox lease. **Additional context** The transport is injectable, so each sandbox provider binds its own pseudo-terminal. This pull request adds the Daytona transport but does not bind it in the production server. A production wiring needs a lease manager, a live pseudo-terminal factory, a durable token store, and its own security review. The route keeps secrets out of logs, activity details, errors, telemetry, and non-owner responses. ## What Changed - Add strict parsers for the setup-token URL, the prompt, and the success token. - Add a login runner that drives the `claude setup-token` command through a pseudo-terminal. - Add the Daytona pseudo-terminal transport and the sandbox plugin wiring. - Add a company-scoped, owner-bound login session service with rate limits, a reaper, cleanup, and one-time token delivery. - Add the guarded session routes at `/agents/:id/setup-token-login-sessions/*` behind an injectable transport. The routes become the live login path only when a provider binds the transport. - Keep the start route fail-closed in the default build. It returns a fixed no-secret `503` and it does not bind `setupTokenLogin`. - Keep the existing host route `POST /agents/:id/claude-login` in place. This pull request does not replace it. - Keep confidential responses behind a fail-closed TLS transport guard with `Cache-Control: no-store`, and extend redaction for the new fields. - Export the parser and the runner from the Claude local server entry, and document the new session routes in the OpenAPI spec. ## Verification - `pnpm --filter @paperclipai/server exec vitest run setup-token-route setup-token-session` - `pnpm --filter @paperclipai/adapter-claude-local exec vitest run` - `pnpm --filter @paperclipai/server run typecheck` - Confirm that the pull request checks pass on GitHub. ## Risks - Low user-facing risk on merge. The default build does not bind the transport, so the production start route stays fail-closed with a `503`. The merge does not change the production login behavior. - When a provider later binds the transport, the flow starts a live sandbox process and holds a short-lived in-memory secret. Cleanup must stop the child before it releases the sandbox lease. - The transport guard fails closed when the deployment does not provide a trusted TLS path. A wrong proxy allowlist can block a valid request. - The production wiring is out of scope. It needs a lease manager, a live pseudo-terminal factory, a durable token store, and its own security review before the server binds `setupTokenLogin`. ## Model Used Anthropic Claude Opus 4.8 assisted the implementation. It used extended reasoning, code execution, repository tool use, and a 200,000-token context window. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes (the OpenAPI spec covers the new session routes; no user-facing documentation needs changes) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0ee0543e5b
commit
e31951a17d
|
|
@ -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<void>;
|
||||
} {
|
||||
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<void>((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<void> {
|
||||
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<void>((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<void>((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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SetupTokenPtySession>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
// 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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 `<REDACTED_*>` 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…
|
||||
<spinner frames>
|
||||
Browser didn't open? Use the url below to sign in (c to copy)
|
||||
|
||||
<REDACTED_AUTHORIZATION_URL>
|
||||
|
||||
Paste code here if prompted > <MASKED_CODE>
|
||||
|
||||
✓ Long-lived authentication token created successfully!
|
||||
|
||||
Your OAuth token (valid for 1 year):
|
||||
|
||||
<REDACTED_SETUP_TOKEN>
|
||||
|
||||
Store this token securely. You won't be able to see it again.
|
||||
|
||||
Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<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 `<authorization_code>#<state>` — 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-<opaque>` 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.
|
||||
|
|
@ -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)
|
||||
<REDACTED_AUTHORIZATION_URL>
|
||||
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-<opaque>` 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.
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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=<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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
/** 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<string>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
|
||||
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<void> {
|
||||
if (ms <= 0 || signal.aborted) return Promise.resolve();
|
||||
return new Promise<void>((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<RaceResult> {
|
||||
return new Promise<RaceResult>((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<void> {
|
||||
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<SetupTokenLoginResult> {
|
||||
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<void> = 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<void> = 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<void> => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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<void>): 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<void> {},
|
||||
async sendInput(data: string | Uint8Array): Promise<void> {
|
||||
inputs.push(typeof data === "string" ? data : new TextDecoder().decode(data));
|
||||
},
|
||||
wait(): Promise<{ exitCode?: number; error?: string }> {
|
||||
return waitPromise;
|
||||
},
|
||||
async kill(): Promise<void> {
|
||||
killed += 1;
|
||||
},
|
||||
async disconnect(): Promise<void> {
|
||||
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<typeof createFakePtyHandle> | null;
|
||||
createOptions: DaytonaPtyCreateOptions | null;
|
||||
} {
|
||||
const state: {
|
||||
handle: ReturnType<typeof createFakePtyHandle> | null;
|
||||
createOptions: DaytonaPtyCreateOptions | null;
|
||||
} = { handle: null, createOptions: null };
|
||||
return {
|
||||
get handle() {
|
||||
return state.handle;
|
||||
},
|
||||
get createOptions() {
|
||||
return state.createOptions;
|
||||
},
|
||||
async createPty(options: DaytonaPtyCreateOptions): Promise<DaytonaPtyHandle> {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void>;
|
||||
}
|
||||
|
||||
/** Opens a {@link SetupTokenPtySession} for `command`. The transport calls it one time. */
|
||||
export type SetupTokenPtySessionOpener = (command: string) => Promise<SetupTokenPtySession>;
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
/** Sends raw input bytes to the pseudo-terminal. */
|
||||
sendInput(data: string | Uint8Array): Promise<void>;
|
||||
/** Resolves with the exit result when the pseudo-terminal process ends. */
|
||||
wait(): Promise<{ exitCode?: number; error?: string }>;
|
||||
/** Kills the pseudo-terminal process. */
|
||||
kill(): Promise<void>;
|
||||
/** Closes the PTY socket and releases the resources. */
|
||||
disconnect(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<DaytonaPtyHandle>;
|
||||
}
|
||||
|
||||
/** 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<SetupTokenPtySession> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ const SENSITIVE_KEYS = new Set<string>([
|
|||
"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<string>([
|
|||
"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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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<SetupTokenLease> {
|
||||
// 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<void> {
|
||||
await environmentsSvc.releaseLease(lease.id, "released").catch(() => {});
|
||||
},
|
||||
async releaseById(leaseId): Promise<void> {
|
||||
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<string, SetupTokenCleanupRecord>();
|
||||
const inMemorySetupTokenCleanupStore: SetupTokenCleanupStore = {
|
||||
async record(record): Promise<void> {
|
||||
setupTokenCleanupRows.set(record.sessionId, { ...record });
|
||||
},
|
||||
async markState(sessionId, state): Promise<void> {
|
||||
const row = setupTokenCleanupRows.get(sessionId);
|
||||
if (row) row.state = state;
|
||||
},
|
||||
async remove(sessionId): Promise<void> {
|
||||
setupTokenCleanupRows.delete(sessionId);
|
||||
},
|
||||
async listReapable(): Promise<SetupTokenCleanupRecord[]> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
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<string, SetupTokenCleanupRecord>();
|
||||
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<SetupTokenLoginOutcome>((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<AppHandle> {
|
||||
const [{ agentRoutes }, { errorHandler }, pinoModule, pinoHttpModule, redactModule] =
|
||||
await Promise.all([
|
||||
vi.importActual<typeof import("../routes/agents.js")>("../routes/agents.js"),
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("pino")>("pino"),
|
||||
vi.importActual<typeof import("pino-http")>("pino-http"),
|
||||
vi.importActual<typeof import("../middleware/redact-sensitive.js")>(
|
||||
"../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<string, unknown> }).__errorContext;
|
||||
if (ctx) {
|
||||
return {
|
||||
errorContext: ctx.error,
|
||||
reqBody: redactSensitive(ctx.reqBody),
|
||||
reqParams: redactSensitive(ctx.reqParams),
|
||||
reqQuery: redactSensitive(ctx.reqQuery),
|
||||
};
|
||||
}
|
||||
const props: Record<string, unknown> = {};
|
||||
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<void> {
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SetupTokenLoginOutcome>;
|
||||
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<SetupTokenLease> {
|
||||
this.counter += 1;
|
||||
const id = `lease-${this.counter}`;
|
||||
this.acquired.push(id);
|
||||
return { id };
|
||||
}
|
||||
async release(lease: SetupTokenLease): Promise<void> {
|
||||
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<void> {
|
||||
this.releaseByIdCalls.push(leaseId);
|
||||
this.released.push(leaseId);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeStore implements SetupTokenCleanupStore {
|
||||
rows = new Map<string, SetupTokenCleanupRecord>();
|
||||
async record(record: SetupTokenCleanupRecord): Promise<void> {
|
||||
this.rows.set(record.sessionId, { ...record });
|
||||
}
|
||||
async markState(sessionId: string, state: SetupTokenCleanupRecord["state"]): Promise<void> {
|
||||
const row = this.rows.get(sessionId);
|
||||
if (row) row.state = state;
|
||||
}
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
this.rows.delete(sessionId);
|
||||
}
|
||||
async listReapable(now: number): Promise<SetupTokenCleanupRecord[]> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<SetupTokenLoginOutcome>;
|
||||
/**
|
||||
* 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<SetupTokenLease>;
|
||||
release(lease: SetupTokenLease): Promise<void>;
|
||||
/** Releases a lease by id. The startup reaper uses this after a restart. */
|
||||
releaseById(leaseId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>;
|
||||
markState(sessionId: string, state: SetupTokenSessionState): Promise<void>;
|
||||
remove(sessionId: string): Promise<void>;
|
||||
/** Returns each record whose session is terminal or whose deadline is past. */
|
||||
listReapable(now: number): Promise<SetupTokenCleanupRecord[]>;
|
||||
}
|
||||
|
||||
/** 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<typeof setTimeout> | 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<typeof setTimeout> | 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<string, StoredSession>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue