feat: Claude login on the new-agent page before agent creation (#11347)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Claude local adapter supports subscription login through a sandbox > - The new-agent page must show login before the user creates an agent > - Test results must not expose raw sandbox diagnostics or secret values > - This pull request adds the login UI to both Test lanes and closes the diagnostic boundary > - The branch also adds durable cleanup recovery for failed sandbox teardown > - Reusable sandboxes must retain both their recorded teardown configuration and a valid lifecycle path until destruction succeeds > - The benefit is a usable login flow with fixed public checks, redacted server logs, and recoverable sandbox cleanup ## Linked Issues or Issue Description Related public work: [#9488](https://github.com/paperclipai/paperclip/pull/9488) adds first-class recognition for `CLAUDE_CODE_OAUTH_TOKEN` in headless and remote runs. Related public issue: [#2681](https://github.com/paperclipai/paperclip/issues/2681) requests Claude Code subscription support. This pull request adds the login transport and new-agent UI flow that those changes do not provide. **Subsystem affected:** Claude local adapter, server login probes, sandbox provider setup, cleanup recovery, and the new-agent UI. **Problem or motivation:** The Test lanes did not show the sandbox login panel in all supported cases. Test results also exposed raw probe diagnostics, and JSON escapes could end secret redaction early. **Proposed solution:** Surface the login capability through the bundled provider manifest. Prepare the same probe runtime in the ACP lane. Send diagnostics only to redacted server logs. Keep Test checks on fixed public messages. Normalize login URL hints to allowlisted HTTPS Claude and Anthropic hosts. Consume JSON escapes during redaction. Preserve failed sandbox cleanup state across retries and restarts, and prevent deletion from severing the lifecycle context of a live reusable sandbox. **Alternatives considered:** Keep raw diagnostics in Test checks or trust login URL text from the sandbox. Both choices increase information exposure. Keep separate probe behavior in the ACP lane. That choice would leave the two Test lanes inconsistent. ## What Changed - Surface the sandbox login panel on both Test lanes. - Reconcile the bundled Daytona plugin manifest so `supportsSetupTokenLogin` reaches the UI capability gate. - Prepare the ACP Test lane with the same probe runtime as the CLI Test lane. - Add the `claude_acp_login_probe_unavailable` warning when the ACP probe cannot run. - Send raw sandbox diagnostics only to redacted server logs. - Keep Test checks on fixed public messages in the ACP, managed-config, and CLI paths. - Normalize login URL hints to allowlisted HTTPS Claude and Anthropic hosts. - Redact JSON and escaped-JSON secret values, including escaped quotes and backslashes. - Preserve orphan cleanup records across provider failures, restarts, and unavailable plugins. - Atomically block environment deletion while a live reusable sandbox lease still depends on it. - Verify pending cleanup destroys plugin sandboxes with the provider configuration recorded on the lease, even after the current environment configuration changes. ## Verification - Head under review: `506b7fa2d83c36bfa5fd722ee9d95b0c7431c241`. - Focused environment route/service/runtime coverage passes: 196 tests across 3 files. - `pnpm -r typecheck` passes. - `pnpm build` passes. - The full Vitest run completed with 4,754 passing and 28 failing tests. All 23 source-test failures reproduce unchanged on parent head `58cfe61a33191ce03d965d65085d26064b4888ba`; the other 5 are duplicate executions from stale `server/dist` output. The failures are unrelated macOS path/listener and scheduler-fixture failures, so there is no new bad commit for bisect to localize. - All required CI checks pass for the current head, including build, typecheck/release registry, all server and workspace shards, serialized server suites, canary, and e2e. - A fresh Greptile review for `506b7fa2d83c36bfa5fd722ee9d95b0c7431c241` reports 5/5, “safe to merge,” with no blocking failure remaining. ## Risks - A probe or redaction change could hide useful server diagnostics. - An allowlist change could reject a valid Claude login URL. - Cleanup recovery changes could affect provider teardown ordering. - An environment with a live reusable sandbox can no longer be deleted until the owning issue or execution workspace completes teardown. - The implementation keeps public Test messages fixed and sends detail to redacted server logs. ## Model Used OpenAI GPT-5 via Codex — exact model ID: GPT-5; tool use and code execution enabled; extended reasoning enabled. The implementation author used AI-assisted development. ## 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 documented the result - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation or confirmed no separate documentation change is needed - [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>
This commit is contained in:
parent
3061ce6901
commit
c1c46f1e4e
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { REDACTED_COMMAND_TEXT_VALUE, redactDiagnosticText } from "./command-redaction.js";
|
||||
|
||||
describe("redactDiagnosticText", () => {
|
||||
it("redacts a JSON secret field value", () => {
|
||||
const input = '{"token":"opaque-value","status":"error"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("opaque-value");
|
||||
expect(output).toContain(`"token":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
||||
// The non-secret field keeps its value.
|
||||
expect(output).toContain('"status":"error"');
|
||||
});
|
||||
|
||||
it("redacts an api_key JSON field with whitespace around the colon", () => {
|
||||
const input = '{ "api_key" : "sk-secret-123" }';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("sk-secret-123");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret field value", () => {
|
||||
// A diagnostic can carry a JSON string, so the double quotes appear as `\"`.
|
||||
const input = '{\\"token\\":\\"opaque-value\\"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("opaque-value");
|
||||
expect(output).toContain(`\\"token\\":\\"${REDACTED_COMMAND_TEXT_VALUE}\\"`);
|
||||
});
|
||||
|
||||
it("still redacts a shell KEY=value secret", () => {
|
||||
const input = "ANTHROPIC_API_KEY=super-secret-value claude --print";
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("super-secret-value");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
|
||||
it("keeps non-secret text and non-secret JSON fields intact", () => {
|
||||
const input = '{"status":"ok","message":"probe finished"}';
|
||||
expect(redactDiagnosticText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("redacts the secret but keeps a non-secret marker in the same string", () => {
|
||||
const input = 'DIAGMARKER1234 said {"authorization":"Bearer opaque"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).toContain("DIAGMARKER1234");
|
||||
expect(output).not.toContain("opaque");
|
||||
});
|
||||
|
||||
it("redacts a JSON secret value that contains an escaped quote", () => {
|
||||
// The value holds an escaped quote, so a naive matcher stops at the `\"` and
|
||||
// leaves the rest of the credential. The marker sits after the escaped quote.
|
||||
const input = '{"token":"pre\\"MARKERQUOTE_A"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERQUOTE_A");
|
||||
expect(output).toContain(`"token":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
||||
});
|
||||
|
||||
it("redacts a JSON secret value that contains an escaped backslash", () => {
|
||||
const input = '{"secret":"pre\\\\MARKERBACKSLASH_A"}';
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERBACKSLASH_A");
|
||||
expect(output).toContain(`"secret":"${REDACTED_COMMAND_TEXT_VALUE}"`);
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret value that contains an escaped quote", () => {
|
||||
// A diagnostic can carry a serialized JSON string, so the whole JSON is
|
||||
// escaped a second time. The inner value still holds an escaped quote.
|
||||
const innerJson = '{"token":"pre\\"MARKERQUOTE_B"}';
|
||||
const input = JSON.stringify(innerJson);
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERQUOTE_B");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret value that contains an escaped backslash", () => {
|
||||
const innerJson = '{"password":"pre\\\\MARKERBACKSLASH_B"}';
|
||||
const input = JSON.stringify(innerJson);
|
||||
const output = redactDiagnosticText(input);
|
||||
expect(output).not.toContain("MARKERBACKSLASH_B");
|
||||
expect(output).toContain(REDACTED_COMMAND_TEXT_VALUE);
|
||||
});
|
||||
});
|
||||
|
|
@ -56,3 +56,37 @@ export function redactCommandText(command: string, redactedValue = REDACTED_COMM
|
|||
.replace(COMMAND_GITHUB_TOKEN_RE, redactedValue)
|
||||
.replace(COMMAND_JWT_RE, redactedValue);
|
||||
}
|
||||
|
||||
// A JSON secret field is a key/value pair such as `"token":"opaque-value"`. The
|
||||
// command redaction handles shell `KEY=value` syntax only. A sandbox diagnostic
|
||||
// can also carry a serialized JSON error, so the sanitizer must redact the JSON
|
||||
// form too. The value body consumes JSON escape sequences. An escaped quote
|
||||
// (`\"`) inside the value does not end the match early.
|
||||
const JSON_SECRET_FIELD_RE = new RegExp(
|
||||
String.raw`("(?:${SECRET_NAME_PATTERN})"\s*:\s*")(?:\\[\s\S]|[^"\\])*(")`,
|
||||
"gi",
|
||||
);
|
||||
// An escaped JSON secret field is the same pair inside a JSON string. The double
|
||||
// quote appears as `\"` and a backslash appears as `\\`. The value body
|
||||
// consumes the doubled escape sequences. An escaped quote inside the value does
|
||||
// not end the match early. The value ends at the next unescaped `\"`.
|
||||
const JSON_ESCAPED_SECRET_FIELD_RE = new RegExp(
|
||||
String.raw`(\\"(?:${SECRET_NAME_PATTERN})\\"\s*:\s*\\")(?:\\\\\\\\|\\\\\\"|\\\\[\s\S]|[^\\"])*(\\")`,
|
||||
"gi",
|
||||
);
|
||||
|
||||
/**
|
||||
* Redact secrets from an untrusted diagnostic string.
|
||||
*
|
||||
* The function first runs the command redaction. The command redaction handles
|
||||
* shell `KEY=value` assignments, CLI secret options, bearer headers, and common
|
||||
* token shapes. The function then redacts JSON and escaped-JSON secret fields,
|
||||
* because a sandbox diagnostic can carry a serialized JSON error such as
|
||||
* `{"token":"opaque-value"}`. The caller must still bound the length after this
|
||||
* step.
|
||||
*/
|
||||
export function redactDiagnosticText(text: string, redactedValue = REDACTED_COMMAND_TEXT_VALUE): string {
|
||||
return redactCommandText(text, redactedValue)
|
||||
.replace(JSON_ESCAPED_SECRET_FIELD_RE, `$1${redactedValue}$2`)
|
||||
.replace(JSON_SECRET_FIELD_RE, `$1${redactedValue}$2`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ export {
|
|||
export {
|
||||
REDACTED_COMMAND_TEXT_VALUE,
|
||||
redactCommandText,
|
||||
redactDiagnosticText,
|
||||
} from "./command-redaction.js";
|
||||
export { buildSandboxNpmInstallCommand } from "./sandbox-install-command.js";
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -579,6 +579,21 @@ export interface CreateConfigValues {
|
|||
envBindings: Record<string, unknown>;
|
||||
url: string;
|
||||
bootstrapPrompt: string;
|
||||
/**
|
||||
* The non-secret stored-session claim from a completed Claude subscription
|
||||
* login. The create form holds it after the login reaches the server `stored`
|
||||
* state and sends it in the agent create request. The server consumes the
|
||||
* claim to bind the fixed `CLAUDE_CODE_OAUTH_TOKEN`. It never carries a token.
|
||||
*/
|
||||
claudeStoredSessionId?: string | null;
|
||||
/**
|
||||
* True when the create form binds the fixed `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
* reference to an existing stored owner login with no new login round trip.
|
||||
* The form sets it after the owner clicks apply-existing. The server binds the
|
||||
* fixed reference only for a user actor and only when a stored value exists. It
|
||||
* never carries a token.
|
||||
*/
|
||||
claudeApplyStoredLogin?: boolean;
|
||||
payloadTemplateJson?: string;
|
||||
workspaceStrategyType?: string;
|
||||
workspaceBaseRef?: string;
|
||||
|
|
|
|||
|
|
@ -61,4 +61,95 @@ No real login or token was captured in this phase. Implementation should provisi
|
|||
|
||||
## 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.
|
||||
`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.
|
||||
## Re-characterization on Claude Code 2.1.226 (2026-08-13)
|
||||
|
||||
- Re-characterized: 2026-08-13
|
||||
- Claude Code: `2.1.226`
|
||||
- Environment: real pseudo-terminal, headless, no browser. Prompt phase only; no
|
||||
login was completed and no token was minted.
|
||||
- Safety: no real authorization URL, browser code, or token is recorded here.
|
||||
|
||||
A live run of `claude setup-token` on a real pseudo-terminal confirmed the prompt
|
||||
contract is unchanged in substance from `2.1.205`:
|
||||
|
||||
- URL preamble line: `Browser didn't open? Use the url below to sign in (c to copy)`.
|
||||
- Authorization URL origin `https://claude.com`, path `/cai/oauth/authorize`, the
|
||||
same eight query keys (`client_id`, `code`, `code_challenge`,
|
||||
`code_challenge_method`, `redirect_uri`, `response_type`, `scope`, `state`), no
|
||||
fragment. The `redirect_uri` value host is `https://platform.claude.com`.
|
||||
- Browser-code prompt line: `Paste code here if prompted >`.
|
||||
|
||||
The live run recorded two terminal-rendering behaviors that the earlier
|
||||
normalized capture hid. The parser now handles both:
|
||||
|
||||
1. Word spacing. The login UI lays out each word at an absolute column with a
|
||||
Cursor Horizontal Absolute (`ESC[<n>G`) sequence, so it emits no literal space
|
||||
between two words. A parser that removes the control sequence glues the words
|
||||
together. The parser now renders each horizontal-move sequence as one space.
|
||||
2. Repeated URL. The UI emits one OSC 8 hyperlink per wrapped display row, so it
|
||||
repeats the full authorization URL on a few consecutive lines before the
|
||||
prompt. The parser now skips a repeated URL line and binds the prompt on the
|
||||
first non-blank line after the URL block; any other unrelated line still fails
|
||||
the bind.
|
||||
|
||||
The success anchors and the token shape were not re-run live, because a real
|
||||
login needs a subscription and an out-of-band browser. They stay as recorded in
|
||||
[`setup-token-success.md`](./setup-token-success.md): the before-anchor line
|
||||
`Your OAuth token (valid for 1 year):`, the after-anchor line `Store this token
|
||||
securely. You won't be able to see it again.`, and the token prefix
|
||||
`sk-ant-oat01-`. The success screen renders its anchor words with the same
|
||||
Cursor Horizontal Absolute spacing, so the same rendering fix applies to the
|
||||
token parser.
|
||||
|
||||
An executable characterization test drives this live run. See
|
||||
[`../setup-token-characterization.test.ts`](../setup-token-characterization.test.ts);
|
||||
it is opt-in through `RUN_CLAUDE_SETUP_TOKEN_CHARACTERIZATION=1`.
|
||||
|
||||
## Re-characterization on Claude Code 2.1.19 (2026-08-15)
|
||||
|
||||
- Re-characterized: 2026-08-15
|
||||
- Claude Code: `2.1.19`
|
||||
- Environment: real Daytona sandbox, image `daytonaio/sandbox:0.8.0`, headless, no
|
||||
browser. Prompt phase only; no login was completed and no token was minted.
|
||||
- Safety: no real authorization URL, browser code, or token is recorded here.
|
||||
Every query value below is a same-shape synthetic placeholder that passes the
|
||||
parser value validators.
|
||||
|
||||
The production sandbox image ships `claude` 2.1.19. A real Daytona smoke found
|
||||
that this version emits a different login contract than `2.1.205` and `2.1.226`.
|
||||
The parser now accepts both contracts:
|
||||
|
||||
- Authorization URL for 2.1.19: origin `https://claude.ai`, path
|
||||
`/oauth/authorize`. The full pair is `https://claude.ai/oauth/authorize`.
|
||||
- Authorization URL for 2.1.205 and 2.1.226: origin `https://claude.com`, path
|
||||
`/cai/oauth/authorize`. The full pair is `https://claude.com/cai/oauth/authorize`.
|
||||
- Both versions carry the same eight query keys: `client_id`, `code`,
|
||||
`code_challenge`, `code_challenge_method`, `redirect_uri`, `response_type`,
|
||||
`scope`, `state`. No fragment.
|
||||
- The `redirect_uri` value is the static callback
|
||||
`https://platform.claude.com/oauth/code/callback`. A real 2.1.19 capture
|
||||
confirmed this exact value, so the parser keeps the single pin.
|
||||
- The `code` value is a short opaque token. A real 2.1.19 capture measured a
|
||||
four-character value, so the parser accepts a `code` value of one or more
|
||||
characters.
|
||||
- Browser-code prompt line: `Paste code here if prompted >`.
|
||||
|
||||
Two rendering differences from `2.1.226`:
|
||||
|
||||
1. No OSC 8 hyperlink. `2.1.19` prints the URL as plain text, not as an OSC 8
|
||||
hyperlink.
|
||||
2. Hard line wrap. `2.1.19` wraps the plain-text URL across several physical
|
||||
lines at the terminal width, with no space at the wrap. The parser joins the
|
||||
URL-character-only physical lines and validates only the final reassembled
|
||||
string.
|
||||
|
||||
Sanitized rendered form of the wrapped URL (placeholder values):
|
||||
|
||||
```text
|
||||
Browser didn't open? Use the url below to sign in (c to copy)
|
||||
https://claude.ai/oauth/authorize?client_id=9d1c8f00-1a2b-3c4d-5e6f-708192a3b4c5&code=wZ9x&code_challenge=E9Melhoa2OwvFr
|
||||
EMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2
|
||||
Fcallback&response_type=code&scope=user%3Ainference&state=Xy7Kd2Pq9Rn4Vb8Lf1Mw6Zc3Hj0Tg5Us
|
||||
Paste code here if prompted >
|
||||
```
|
||||
|
|
|
|||
|
|
@ -0,0 +1,365 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
// A shared handle so each test can set the stub Claude hello-probe output the
|
||||
// sandbox runner returns.
|
||||
const { runAdapterExecutionTargetProcess, probeResult } = vi.hoisted(() => {
|
||||
const probeResult: {
|
||||
value: { exitCode: number; stdout: string; stderr: string; timedOut: boolean };
|
||||
throwError: Error | null;
|
||||
} = {
|
||||
value: { exitCode: 1, stdout: "", stderr: "", timedOut: false },
|
||||
throwError: null,
|
||||
};
|
||||
return {
|
||||
probeResult,
|
||||
runAdapterExecutionTargetProcess: vi.fn(async () => {
|
||||
if (probeResult.throwError) throw probeResult.throwError;
|
||||
return {
|
||||
exitCode: probeResult.value.exitCode,
|
||||
signal: null,
|
||||
timedOut: probeResult.value.timedOut,
|
||||
stdout: probeResult.value.stdout,
|
||||
stderr: probeResult.value.stderr,
|
||||
pid: 321,
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
runAdapterExecutionTargetProcess,
|
||||
};
|
||||
});
|
||||
|
||||
import { mapClaudeAcpAuthErrorCode, probeClaudeAcpSandboxLogin } from "./acp.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
|
||||
const sandboxTarget: AdapterExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "daytona",
|
||||
remoteCwd: "/home/daytona/paperclip-workspace",
|
||||
runner: {
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const initLine =
|
||||
'{"type":"system","subtype":"init","cwd":"/home/daytona/paperclip-workspace","session_id":"abc","tools":["Bash","Read"]}';
|
||||
|
||||
const loginRequiredStdout = [
|
||||
initLine,
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Please run `claude login` to authenticate.","session_id":"abc"}',
|
||||
].join("\n");
|
||||
|
||||
const helloStdout = [
|
||||
initLine,
|
||||
'{"type":"result","subtype":"success","is_error":false,"result":"hello","session_id":"abc"}',
|
||||
].join("\n");
|
||||
|
||||
// The real Claude CLI output for CLAUDE_CODE_OAUTH_TOKEN=invalid. The probe
|
||||
// exits non-zero and the result event reports a 401 authentication failure with
|
||||
// an "Invalid bearer token" message. A synthetic bearer marker rides along on a
|
||||
// retry line, so the test can prove the raw probe text never reaches a check.
|
||||
const invalidTokenMarker = "SUPERSECRETbearerMARKER";
|
||||
const invalidTokenStdout = [
|
||||
initLine,
|
||||
`{"type":"system","subtype":"api_retry","attempt":1,"error_status":401,"error":"authentication_failed: bearer ${invalidTokenMarker} is invalid","session_id":"abc"}`,
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid bearer token"}]},"session_id":"abc","error":"authentication_failed"}',
|
||||
'{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"error":"authentication_failed","result":"Failed to authenticate. API Error: 401 Invalid bearer token","session_id":"abc"}',
|
||||
].join("\n");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
probeResult.value = { exitCode: 1, stdout: "", stderr: "", timedOut: false };
|
||||
probeResult.throwError = null;
|
||||
});
|
||||
|
||||
describe("mapClaudeAcpAuthErrorCode", () => {
|
||||
it("translates the generic acpx_auth_required code into claude_auth_required", () => {
|
||||
const engineResult: AdapterExecutionResult = {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorMessage: "Claude requires login.",
|
||||
errorCode: "acpx_auth_required",
|
||||
errorMeta: { category: "auth", errorName: "Error" },
|
||||
};
|
||||
|
||||
const mapped = mapClaudeAcpAuthErrorCode(engineResult);
|
||||
|
||||
// The user interface run gate reads claude_auth_required to show the login
|
||||
// affordance on the default ACP path.
|
||||
expect(mapped.errorCode).toBe("claude_auth_required");
|
||||
// The mapping keeps every other field so diagnostics stay intact.
|
||||
expect(mapped.errorMessage).toBe("Claude requires login.");
|
||||
expect(mapped.errorMeta).toEqual({ category: "auth", errorName: "Error" });
|
||||
});
|
||||
|
||||
it("leaves a different error code unchanged", () => {
|
||||
const engineResult: AdapterExecutionResult = {
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorCode: "acpx_runtime_error",
|
||||
};
|
||||
|
||||
expect(mapClaudeAcpAuthErrorCode(engineResult).errorCode).toBe("acpx_runtime_error");
|
||||
});
|
||||
|
||||
it("leaves a null error code unchanged", () => {
|
||||
const engineResult: AdapterExecutionResult = {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
errorCode: null,
|
||||
};
|
||||
|
||||
expect(mapClaudeAcpAuthErrorCode(engineResult).errorCode).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeClaudeAcpSandboxLogin", () => {
|
||||
it("emits the canonical adapter_auth_missing check when the sandbox probe reports missing auth", async () => {
|
||||
probeResult.value = { exitCode: 1, stdout: loginRequiredStdout, stderr: "", timedOut: false };
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
expect(checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
|
||||
// The descriptive check stays for diagnostics.
|
||||
expect(checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
// A missing-auth probe is a warning, not a failure, so the environment stays
|
||||
// testable and the user interface can offer login.
|
||||
expect(checks.every((check) => check.level === "warn")).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies an invalid or expired token as adapter_auth_missing without leaking the token", async () => {
|
||||
probeResult.value = { exitCode: 1, stdout: invalidTokenStdout, stderr: "", timedOut: false };
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
// An auth failure returns the canonical login gate code, not the
|
||||
// probe-could-not-run code.
|
||||
expect(checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
|
||||
expect(checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
expect(checks.some((check) => check.code === "claude_acp_login_probe_unavailable")).toBe(false);
|
||||
// The raw probe text, including the bearer marker, never reaches a check.
|
||||
expect(JSON.stringify(checks)).not.toContain(invalidTokenMarker);
|
||||
});
|
||||
|
||||
it("does not flag a healthy probe whose assistant text repeats a token phrase", async () => {
|
||||
// A healthy run prints an auth phrase in its answer text. The parsed result
|
||||
// is a success, so the probe stays healthy and offers no login gate.
|
||||
probeResult.value = {
|
||||
exitCode: 0,
|
||||
stdout: [
|
||||
initLine,
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello — note authentication_failed means an invalid bearer token"}]},"session_id":"abc"}',
|
||||
'{"type":"result","subtype":"success","is_error":false,"result":"hello","session_id":"abc"}',
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
};
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
// The parsed result is a success, so no login gate and no probe-unavailable
|
||||
// check appears.
|
||||
expect(checks).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a non-auth failed probe with a token phrase on the probe-unavailable path", async () => {
|
||||
// The probe fails on a transient upstream error and prints an auth phrase in
|
||||
// its assistant text. The parsed result is not an auth failure, so the probe
|
||||
// stays on the neutral probe-unavailable code, not the login gate.
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [
|
||||
initLine,
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"authentication_failed: the bearer token is invalid"}]},"session_id":"abc"}',
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 503 service unavailable","session_id":"abc"}',
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
};
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
expect(checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
|
||||
expect(checks.some((check) => check.code === "claude_acp_login_probe_unavailable")).toBe(true);
|
||||
});
|
||||
|
||||
it("never renders an untrusted login URL with sensitive query or fragment text", async () => {
|
||||
// The sandbox prints a login-required line that carries a malicious URL. The
|
||||
// URL has a non-allowlisted host, a query, and a fragment, each with a
|
||||
// marker. The normalizer must reject the URL, so no marker reaches a check.
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [
|
||||
initLine,
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Please run `claude login`. Visit https://evil.example.com/claude-login?leak=SECRETQUERYmarker#SECRETFRAGmarker","session_id":"abc"}',
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
};
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
// The login-required checks still appear.
|
||||
expect(checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
expect(checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
|
||||
const checkText = JSON.stringify(checks);
|
||||
// No marker and no untrusted host reaches any check.
|
||||
expect(checkText).not.toContain("SECRETQUERYmarker");
|
||||
expect(checkText).not.toContain("SECRETFRAGmarker");
|
||||
expect(checkText).not.toContain("evil.example.com");
|
||||
// The hint falls back to the fixed `claude login` text.
|
||||
const authRequired = checks.find((check) => check.code === "claude_hello_probe_auth_required");
|
||||
expect(authRequired?.hint).toBe("Run `claude login` in this environment, then retry the probe.");
|
||||
});
|
||||
|
||||
it("emits no checks when the sandbox probe reports a healthy login", async () => {
|
||||
probeResult.value = { exitCode: 0, stdout: helloStdout, stderr: "", timedOut: false };
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
expect(checks).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits a distinct warn check, not a silent pass, when the probe cannot run", async () => {
|
||||
probeResult.throwError = new Error("claude command not found");
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
// A probe that cannot run must not report a success. It emits a distinct
|
||||
// warn check that is NOT the login affordance code.
|
||||
expect(checks).toHaveLength(1);
|
||||
expect(checks[0]?.code).toBe("claude_acp_login_probe_unavailable");
|
||||
expect(checks[0]?.level).toBe("warn");
|
||||
expect(checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
|
||||
});
|
||||
|
||||
it("emits a distinct warn check when the probe times out", async () => {
|
||||
probeResult.value = { exitCode: null as unknown as number, stdout: "", stderr: "", timedOut: true };
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
expect(checks).toHaveLength(1);
|
||||
expect(checks[0]?.code).toBe("claude_acp_login_probe_unavailable");
|
||||
expect(checks[0]?.level).toBe("warn");
|
||||
expect(checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
|
||||
});
|
||||
|
||||
it("emits a distinct warn check when the probe runs but does not complete", async () => {
|
||||
// The probe ran, login is not detected, and the exit code is non-zero. This
|
||||
// is not a healthy login, so it must not be a silent pass.
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: initLine,
|
||||
stderr: "unexpected sandbox error",
|
||||
timedOut: false,
|
||||
};
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
expect(checks).toHaveLength(1);
|
||||
expect(checks[0]?.code).toBe("claude_acp_login_probe_unavailable");
|
||||
expect(checks[0]?.level).toBe("warn");
|
||||
});
|
||||
|
||||
it("never copies a thrown probe error into a Test-result check", async () => {
|
||||
// A sandbox transport failure can carry a credential. Inject a secret
|
||||
// marker through the thrown error and assert no check text repeats it.
|
||||
const secret = "sk-ant-LEAKMARKER0123456789abcdef";
|
||||
probeResult.throwError = new Error(`transport failed with ${secret}`);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
const checkText = JSON.stringify(checks);
|
||||
expect(checkText).not.toContain(secret);
|
||||
expect(checkText).not.toContain("LEAKMARKER");
|
||||
expect(checks[0]?.code).toBe("claude_acp_login_probe_unavailable");
|
||||
// The diagnostic still reaches the server log, but the secret is redacted.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain(secret);
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("never copies raw probe stderr or stdout into a Test-result check", async () => {
|
||||
// A non-zero probe can print a credential to stderr. Inject a secret marker
|
||||
// and assert no check text repeats it.
|
||||
const secret = "sk-ant-STDERRMARKER0123456789abcdef";
|
||||
probeResult.value = {
|
||||
exitCode: 2,
|
||||
stdout: initLine,
|
||||
stderr: `fatal: leaked ${secret}`,
|
||||
timedOut: false,
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const checks = await probeClaudeAcpSandboxLogin({
|
||||
config: { engine: "acp" },
|
||||
target: sandboxTarget,
|
||||
});
|
||||
|
||||
const checkText = JSON.stringify(checks);
|
||||
expect(checkText).not.toContain(secret);
|
||||
expect(checkText).not.toContain("STDERRMARKER");
|
||||
expect(checks[0]?.code).toBe("claude_acp_login_probe_unavailable");
|
||||
// The diagnostic still reaches the server log, but the secret is redacted.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain(secret);
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -17,7 +17,9 @@ import {
|
|||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
readAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
runAdapterExecutionTargetProcess,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
DEFAULT_ACP_ENGINE_MODE,
|
||||
DEFAULT_ACP_ENGINE_NON_INTERACTIVE_PERMISSIONS,
|
||||
|
|
@ -30,6 +32,7 @@ import type {
|
|||
AcpxRemoteManagedHomeResult,
|
||||
} from "@paperclipai/adapter-utils/acpx-engine/execute";
|
||||
import {
|
||||
asBoolean,
|
||||
asNumber,
|
||||
asString,
|
||||
parseObject,
|
||||
|
|
@ -37,7 +40,16 @@ import {
|
|||
import {
|
||||
materializeRemoteClaudeConfig,
|
||||
prepareClaudeConfigSeed,
|
||||
prepareSandboxClaudeProbeRuntime,
|
||||
} from "./claude-config.js";
|
||||
import {
|
||||
buildClaudeLoginRequiredHint,
|
||||
logRedactedSandboxProbeDiagnostic,
|
||||
} from "./probe-diagnostics.js";
|
||||
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
|
||||
import { buildClaudeProbePermissionArgs } from "./permissions.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageRootDir = path.resolve(moduleDir, "../..");
|
||||
|
|
@ -300,6 +312,36 @@ function withClaudeAcpDefaults(options: ClaudeAcpExecutorOptions): AcpxEngineExe
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The generic error code the shared acpx engine emits when a run fails because
|
||||
* the agent has no ready authentication. The shared engine stays vendor-neutral,
|
||||
* so it keeps this generic code. See `adapter-utils/acpx-engine/execute.ts`.
|
||||
*/
|
||||
const ACPX_AUTH_REQUIRED_ERROR_CODE = "acpx_auth_required";
|
||||
|
||||
/**
|
||||
* The Claude-specific error code the user interface reads to show the Claude
|
||||
* login affordance on a run. The Claude CLI lane already emits this code. See
|
||||
* `execute.ts` and the user interface gate in `ui/src/pages/AgentDetail.tsx`.
|
||||
*/
|
||||
const CLAUDE_AUTH_REQUIRED_ERROR_CODE = "claude_auth_required";
|
||||
|
||||
/**
|
||||
* Translate the generic acpx auth-required code into the Claude-specific code at
|
||||
* the claude-local boundary. The shared acpx engine reports the generic
|
||||
* `acpx_auth_required` code for every adapter. The user interface run gate reads
|
||||
* the Claude-specific `claude_auth_required` code, the same code the Claude CLI
|
||||
* lane emits. Without this translation the default ACP run never shows the login
|
||||
* prompt. The function changes only the error code and keeps every other field,
|
||||
* so the error message and the error metadata stay intact.
|
||||
*/
|
||||
export function mapClaudeAcpAuthErrorCode(
|
||||
result: AdapterExecutionResult,
|
||||
): AdapterExecutionResult {
|
||||
if (result.errorCode !== ACPX_AUTH_REQUIRED_ERROR_CODE) return result;
|
||||
return { ...result, errorCode: CLAUDE_AUTH_REQUIRED_ERROR_CODE };
|
||||
}
|
||||
|
||||
export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ClaudeAcpExecutor {
|
||||
let executor: ClaudeAcpExecutor | null = null;
|
||||
return async (ctx) => {
|
||||
|
|
@ -309,10 +351,11 @@ export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}):
|
|||
currentExecutor = createAcpxEngineExecutor(withClaudeAcpDefaults(options));
|
||||
executor = currentExecutor;
|
||||
}
|
||||
return currentExecutor({
|
||||
const result = await currentExecutor({
|
||||
...ctx,
|
||||
config: buildClaudeAcpConfig(ctx.config),
|
||||
});
|
||||
return mapClaudeAcpAuthErrorCode(result);
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -450,6 +493,132 @@ function isNonEmpty(value: unknown): value is string {
|
|||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the two checks that tell the user interface a sandbox has no ready
|
||||
* Claude authentication. The first check is descriptive for diagnostics. The
|
||||
* second check is the neutral canonical code the user interface reads to offer
|
||||
* login. The user interface does not read the message text or the top-level
|
||||
* status.
|
||||
*/
|
||||
function buildAcpSandboxAuthMissingChecks(loginUrl: string | null): AdapterEnvironmentCheck[] {
|
||||
return [
|
||||
{
|
||||
code: "claude_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Claude ACP is available, but login is required.",
|
||||
hint: buildClaudeLoginRequiredHint(loginUrl),
|
||||
},
|
||||
{
|
||||
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
level: "warn",
|
||||
message: "The sandbox has no ready authentication for this adapter.",
|
||||
hint: "Provide credentials for this adapter, or start login in the sandbox.",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the check that tells the user a Claude login probe could not run on the
|
||||
* ACP path. The check is a warn, not an info, so `summarizeStatus` never
|
||||
* reports a pass. The check code is distinct from `adapter_auth_missing`, so the
|
||||
* user interface never shows the login affordance for a probe that could not
|
||||
* confirm the login state. Nicky's direction: a sandbox Test without available
|
||||
* auth must not report a success.
|
||||
*/
|
||||
function buildAcpLoginProbeUnavailableCheck(message: string): AdapterEnvironmentCheck {
|
||||
return {
|
||||
code: "claude_acp_login_probe_unavailable",
|
||||
level: "warn",
|
||||
message,
|
||||
hint: "Verify that the sandbox can run `claude` and retry the Test. Set engine=cli to use the Claude CLI lane.",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the stored Claude login inside a sandbox on the ACP path. The ACP engine
|
||||
* and the Claude CLI share the same stored Claude login, so the probe runs the
|
||||
* `claude` command with a short hello turn. The caller passes the prepared
|
||||
* `env`, so the probe reads the managed `CLAUDE_CONFIG_DIR` the same way the CLI
|
||||
* lane does. When the probe reports that login is required, the function returns
|
||||
* the canonical auth-missing checks. The user interface reads the canonical
|
||||
* check to offer login on the default ACP path, the same way it does for the
|
||||
* Claude CLI path.
|
||||
*
|
||||
* The function keeps two signals distinct. It returns `adapter_auth_missing`
|
||||
* only when the probe ran and login is required. It returns a separate warn
|
||||
* check when the probe could not run, timed out, or did not complete. It never
|
||||
* maps "probe could not run" to a silent pass.
|
||||
*/
|
||||
export async function probeClaudeAcpSandboxLogin(input: {
|
||||
config: Record<string, unknown>;
|
||||
target: AdapterExecutionTarget;
|
||||
env?: Record<string, string>;
|
||||
}): Promise<AdapterEnvironmentCheck[]> {
|
||||
const { config, target } = input;
|
||||
let env: Record<string, string>;
|
||||
if (input.env) {
|
||||
env = input.env;
|
||||
} else {
|
||||
const envConfig = parseObject(config.env);
|
||||
env = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
}
|
||||
const command = "claude";
|
||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||
args.push(
|
||||
...buildClaudeProbePermissionArgs({
|
||||
dangerouslySkipPermissions: asBoolean(config.dangerouslySkipPermissions, true),
|
||||
targetIsRemote: true,
|
||||
localProcessUid: process.getuid?.() ?? null,
|
||||
}),
|
||||
);
|
||||
const timeoutSec = Math.max(1, asNumber(config.helloProbeTimeoutSec, 90));
|
||||
const runId = `claude-acp-authprobe-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
let probe: Awaited<ReturnType<typeof runAdapterExecutionTargetProcess>>;
|
||||
try {
|
||||
probe = await runAdapterExecutionTargetProcess(runId, target, command, args, {
|
||||
cwd: target.kind === "remote" ? target.remoteCwd : process.cwd(),
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec: 5,
|
||||
stdin: "Respond with hello.",
|
||||
onLog: async () => {},
|
||||
});
|
||||
} catch (err) {
|
||||
// Keep the raw error out of the Test-result check. Send the redacted
|
||||
// diagnostic to the server log instead.
|
||||
logRedactedSandboxProbeDiagnostic(
|
||||
"Claude ACP login probe could not run in the sandbox",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
return [buildAcpLoginProbeUnavailableCheck("The Claude login probe could not run in the sandbox.")];
|
||||
}
|
||||
if (probe.timedOut) {
|
||||
return [buildAcpLoginProbeUnavailableCheck("The Claude login probe timed out.")];
|
||||
}
|
||||
const parsedStream = parseClaudeStreamJson(probe.stdout);
|
||||
const loginMeta = detectClaudeLoginRequired({
|
||||
parsed: parsedStream.resultJson,
|
||||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
if (loginMeta.requiresLogin) {
|
||||
return buildAcpSandboxAuthMissingChecks(loginMeta.loginUrl);
|
||||
}
|
||||
if ((probe.exitCode ?? 1) !== 0) {
|
||||
// Keep the raw sandbox stderr and stdout out of the Test-result check. Send
|
||||
// the redacted diagnostic to the server log instead.
|
||||
logRedactedSandboxProbeDiagnostic(
|
||||
"Claude ACP login probe did not complete",
|
||||
firstNonEmptyString(probe.stderr, probe.stdout),
|
||||
);
|
||||
return [buildAcpLoginProbeUnavailableCheck("The Claude login probe did not complete.")];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function testClaudeAcpEnvironment(
|
||||
ctx: AdapterEnvironmentTestContext,
|
||||
): Promise<AdapterEnvironmentTestResult> {
|
||||
|
|
@ -553,6 +722,45 @@ export async function testClaudeAcpEnvironment(
|
|||
});
|
||||
}
|
||||
|
||||
// A sandbox target can start a login flow, and subscription auth is the only
|
||||
// credential source left after the branches above rule out Bedrock and an
|
||||
// API key. Prepare the sandbox the same way the CLI lane does — install the
|
||||
// Claude CLI when it is absent and materialize the managed CLAUDE_CONFIG_DIR
|
||||
// — then probe the stored Claude login. The Test result carries the canonical
|
||||
// adapter_auth_missing signal when login is required, and a distinct warn
|
||||
// check when the probe cannot run. The user interface reads the canonical
|
||||
// signal to offer login on the default ACP path.
|
||||
if (
|
||||
target?.kind === "remote" &&
|
||||
target.transport === "sandbox" &&
|
||||
!hasBedrock &&
|
||||
!isNonEmpty(configApiKey)
|
||||
) {
|
||||
const probeEnv: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") probeEnv[key] = value;
|
||||
}
|
||||
const runId = `claude-acp-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
checks.push(
|
||||
...(await prepareSandboxClaudeProbeRuntime({
|
||||
runId,
|
||||
target,
|
||||
cwd,
|
||||
companyId: ctx.companyId,
|
||||
env: probeEnv,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: "claude",
|
||||
targetIsRemote: true,
|
||||
targetIsSandbox: true,
|
||||
helloProbeTimeoutSec: asNumber(config.helloProbeTimeoutSec, 90),
|
||||
})),
|
||||
);
|
||||
const canProbe = !checks.some((check) => check.code === "claude_managed_config_dir_failed");
|
||||
if (canProbe) {
|
||||
checks.push(...(await probeClaudeAcpSandboxLogin({ config, target, env: probeEnv })));
|
||||
}
|
||||
}
|
||||
|
||||
const mode = firstNonEmptyString(config.mode, config.acpMode) ?? DEFAULT_ACP_ENGINE_MODE;
|
||||
const warmHandleIdleMs = asNumber(
|
||||
config.warmHandleIdleMs ?? config.acpWarmHandleIdleMs,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
/**
|
||||
* The canonical check code the Claude Test engine emits when a sandbox target
|
||||
* has no ready authentication. The user interface reads this stable code to
|
||||
* decide login eligibility. The user interface does not read the message text
|
||||
* or the top-level status.
|
||||
*
|
||||
* The name is neutral. It carries no vendor name and no login-flow word, because
|
||||
* a Test result can sync to public packages. The value matches the code the
|
||||
* Codex Test engine emits, so the user interface can gate login from one code
|
||||
* across adapters.
|
||||
*/
|
||||
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";
|
||||
|
|
@ -2,14 +2,22 @@ import { createHash } from "node:crypto";
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AdapterExecutionContext, AdapterRuntimeMcpServer } from "@paperclipai/adapter-utils";
|
||||
import type {
|
||||
AdapterExecutionContext,
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterRuntimeMcpServer,
|
||||
} from "@paperclipai/adapter-utils";
|
||||
import {
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
maybeRunSandboxInstallCommand,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
runAdapterExecutionTargetShellCommand,
|
||||
type AdapterExecutionTarget,
|
||||
type AdapterExecutionTargetShellOptions,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
||||
import { logRedactedSandboxProbeDiagnostic } from "./probe-diagnostics.js";
|
||||
|
||||
const SEEDED_SHARED_FILES = ["settings.json", "CLAUDE.md"] as const;
|
||||
|
||||
|
|
@ -242,3 +250,124 @@ export async function materializeRemoteClaudeConfig(input: {
|
|||
input.options,
|
||||
);
|
||||
}
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare the sandbox runtime that a Claude hello probe needs. The step
|
||||
* installs the Claude CLI in the sandbox when the CLI is absent, and it
|
||||
* materializes the Paperclip-managed Claude config directory. Both the CLI
|
||||
* Test lane and the ACP Test lane call this helper, so the two lanes probe
|
||||
* the same login state. The Claude CLI and the Claude ACP engine share the
|
||||
* same stored Claude login.
|
||||
*
|
||||
* The function mutates `env`: it sets `CLAUDE_CONFIG_DIR` to the managed
|
||||
* remote config directory when it materializes one. It returns the checks to
|
||||
* add to the Test result. An operator-provided `CLAUDE_CONFIG_DIR` wins, so
|
||||
* the function keeps it and skips the managed materialization.
|
||||
*/
|
||||
export async function prepareSandboxClaudeProbeRuntime(input: {
|
||||
runId: string;
|
||||
target: AdapterExecutionTarget | null;
|
||||
cwd: string;
|
||||
companyId?: string;
|
||||
env: Record<string, string>;
|
||||
installCommand: string;
|
||||
detectCommand: string;
|
||||
targetIsRemote: boolean;
|
||||
targetIsSandbox: boolean;
|
||||
helloProbeTimeoutSec: number;
|
||||
}): Promise<AdapterEnvironmentCheck[]> {
|
||||
const checks: AdapterEnvironmentCheck[] = [];
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId: input.runId,
|
||||
target: input.target,
|
||||
adapterKey: "claude",
|
||||
installCommand: input.installCommand,
|
||||
detectCommand: input.detectCommand,
|
||||
env: input.env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
|
||||
const hasExplicitClaudeConfigDir = isNonEmptyString(input.env.CLAUDE_CONFIG_DIR);
|
||||
if (
|
||||
input.targetIsRemote &&
|
||||
adapterExecutionTargetUsesManagedHome(input.target) &&
|
||||
!hasExplicitClaudeConfigDir
|
||||
) {
|
||||
let tempWorkspaceDir: string | null = null;
|
||||
let preparedRuntime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | null = null;
|
||||
try {
|
||||
const seedDir = await prepareClaudeConfigSeed(process.env, async () => {}, input.companyId);
|
||||
const managedRemoteCwd =
|
||||
input.target?.kind === "remote" ? input.target.remoteCwd : input.cwd;
|
||||
tempWorkspaceDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "paperclip-claude-envtest-workspace-"),
|
||||
);
|
||||
preparedRuntime = await prepareAdapterExecutionTargetRuntime({
|
||||
runId: input.runId,
|
||||
target: input.target,
|
||||
adapterKey: "claude",
|
||||
workspaceLocalDir: tempWorkspaceDir,
|
||||
workspaceRemoteDir: managedRemoteCwd,
|
||||
timeoutSec: Math.max(1, input.helloProbeTimeoutSec),
|
||||
assets: [
|
||||
{
|
||||
key: "config-seed",
|
||||
localDir: seedDir,
|
||||
followSymlinks: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtimeRootDir =
|
||||
preparedRuntime.runtimeRootDir ??
|
||||
path.posix.join(managedRemoteCwd, ".paperclip-runtime", "claude");
|
||||
const remoteClaudeConfigSeedDir =
|
||||
preparedRuntime.assetDirs["config-seed"] ??
|
||||
path.posix.join(runtimeRootDir, "config-seed");
|
||||
const remoteClaudeConfigDir = path.posix.join(runtimeRootDir, "config");
|
||||
input.env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
|
||||
await materializeRemoteClaudeConfig({
|
||||
runId: input.runId,
|
||||
target: input.target,
|
||||
remoteClaudeConfigDir,
|
||||
remoteClaudeConfigSeedDir,
|
||||
options: {
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
timeoutSec: Math.max(15, input.helloProbeTimeoutSec),
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
});
|
||||
checks.push({
|
||||
code: "claude_managed_config_dir",
|
||||
level: "info",
|
||||
message: "Sandbox probe is using Paperclip-managed Claude config materialization.",
|
||||
detail: remoteClaudeConfigDir,
|
||||
});
|
||||
} catch (err) {
|
||||
// Keep the raw error out of the Test-result check. Send the redacted
|
||||
// diagnostic to the server log instead.
|
||||
logRedactedSandboxProbeDiagnostic(
|
||||
"Could not materialize Paperclip-managed Claude config for the sandbox probe",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
checks.push({
|
||||
code: "claude_managed_config_dir_failed",
|
||||
level: "error",
|
||||
message: "Could not materialize Paperclip-managed Claude config for the sandbox probe.",
|
||||
hint: "Retry the Test. If the failure repeats, check the server log for the redacted diagnostic.",
|
||||
});
|
||||
} finally {
|
||||
await preparedRuntime?.restoreWorkspace().catch(() => undefined);
|
||||
if (tempWorkspaceDir) {
|
||||
await fs.rm(tempWorkspaceDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,8 +36,8 @@ export {
|
|||
parseSetupTokenPrompt,
|
||||
parseSetupTokenCredential,
|
||||
SETUP_TOKEN_PROMPT,
|
||||
SETUP_TOKEN_URL_ORIGIN,
|
||||
SETUP_TOKEN_URL_PATH,
|
||||
SETUP_TOKEN_AUTH_URLS,
|
||||
SETUP_TOKEN_REDIRECT_URI,
|
||||
SETUP_TOKEN_URL_QUERY_KEYS,
|
||||
SETUP_TOKEN_PREFIX,
|
||||
SETUP_TOKEN_BEFORE_ANCHOR,
|
||||
|
|
@ -52,7 +52,6 @@ export {
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,118 @@ describe("detectClaudeLoginRequired", () => {
|
|||
}).requiresLogin,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies an invalid or expired OAuth bearer token as login required", () => {
|
||||
// Grounded on the real Claude CLI output for CLAUDE_CODE_OAUTH_TOKEN=invalid:
|
||||
// the result event carries a 401 authentication failure and an "Invalid
|
||||
// bearer token" message. This is an auth failure, not a probe that could not
|
||||
// run, so the detector must classify it as login required.
|
||||
const parsed = {
|
||||
is_error: true,
|
||||
subtype: "success",
|
||||
api_error_status: 401,
|
||||
error: "authentication_failed",
|
||||
result: "Failed to authenticate. API Error: 401 Invalid bearer token",
|
||||
};
|
||||
expect(
|
||||
detectClaudeLoginRequired({
|
||||
parsed,
|
||||
stdout:
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"Failed to authenticate. API Error: 401 Invalid bearer token"}]},"error":"authentication_failed"}',
|
||||
stderr: "",
|
||||
}).requiresLogin,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not route an invalid token to the transient or quota classifiers", () => {
|
||||
// An auth failure must win over the transient and quota lanes, so a run
|
||||
// surfaces login required instead of a retry.
|
||||
const input = {
|
||||
parsed: {
|
||||
is_error: true,
|
||||
result: "Failed to authenticate. API Error: 401 Invalid bearer token",
|
||||
},
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
};
|
||||
expect(isClaudeTransientUpstreamError(input)).toBe(false);
|
||||
expect(isClaudeProviderQuotaError(input)).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies an expired or revoked token in the parsed result as login required", () => {
|
||||
// The result event marks the run as a failure and reports an expired token.
|
||||
// This is a token failure, so the detector classifies it as login required.
|
||||
const parsed = {
|
||||
is_error: true,
|
||||
subtype: "success",
|
||||
result: "Failed to authenticate. Your OAuth access token is expired.",
|
||||
};
|
||||
expect(
|
||||
detectClaudeLoginRequired({ parsed, stdout: "", stderr: "" }).requiresLogin,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify a successful probe whose assistant text repeats a token phrase", () => {
|
||||
// A healthy run can print an auth phrase in its answer text. The parsed
|
||||
// result is a success, so the token-failure markers must not fire on the raw
|
||||
// stdout assistant event.
|
||||
const parsed = {
|
||||
is_error: false,
|
||||
subtype: "success",
|
||||
result: "hello",
|
||||
};
|
||||
expect(
|
||||
detectClaudeLoginRequired({
|
||||
parsed,
|
||||
stdout:
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"The phrase authentication_failed means an invalid bearer token."}]}}',
|
||||
stderr: "",
|
||||
}).requiresLogin,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not classify a success whose result text repeats a token phrase", () => {
|
||||
// The model's own answer repeats a token phrase, so the phrase lands in the
|
||||
// parsed result. The run is a success, so the failure gate keeps it healthy.
|
||||
const parsed = {
|
||||
is_error: false,
|
||||
subtype: "success",
|
||||
result: "Sure. An invalid bearer token means authentication_failed.",
|
||||
};
|
||||
expect(
|
||||
detectClaudeLoginRequired({ parsed, stdout: "", stderr: "" }).requiresLogin,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a transient failure with an assistant token phrase on the transient lane", () => {
|
||||
// The run fails on a 503 transient error. The token phrase appears only in
|
||||
// the raw stdout assistant event, not the parsed result, so the run stays
|
||||
// transient and never classifies as login required.
|
||||
const input = {
|
||||
parsed: {
|
||||
is_error: true,
|
||||
subtype: "error_during_execution",
|
||||
result: "API Error: 503 service unavailable",
|
||||
},
|
||||
stdout:
|
||||
'{"type":"assistant","message":{"content":[{"type":"text","text":"authentication_failed: the bearer token is invalid"}]}}',
|
||||
stderr: "",
|
||||
};
|
||||
expect(detectClaudeLoginRequired(input).requiresLogin).toBe(false);
|
||||
expect(isClaudeTransientUpstreamError(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat a bare token phrase in raw stdout with no parsed result as login required", () => {
|
||||
// Untrusted stdout alone must not satisfy a token-failure marker. Only the
|
||||
// parsed terminal result fields of a failed run can trip the token markers.
|
||||
expect(
|
||||
detectClaudeLoginRequired({
|
||||
parsed: null,
|
||||
stdout: "authentication_failed while the assistant called a tool",
|
||||
stderr: "",
|
||||
}).requiresLogin,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClaudeModelNotFoundError", () => {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,22 @@ import type { UsageSummary } from "@paperclipai/adapter-utils";
|
|||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
parseObject,
|
||||
parseJson,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+(?:`?claude\s+login`?|\/login)|login\s+required|requires\s+login|unauthorized|authentication\s+required|invalid\s+api\s+key[\s\S]{0,120}(?:\/login|claude\s+login|log\s+in))/i;
|
||||
// The legacy login-prompt markers. The Claude CLI prints these words when it
|
||||
// asks the user to log in. The detector matches them against any probe output
|
||||
// line, which includes the raw stdout and stderr. This scope is pre-existing.
|
||||
const CLAUDE_LOGIN_PROMPT_RE =
|
||||
/(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+(?:`?claude\s+login`?|\/login)|login\s+required|requires\s+login|unauthorized|authentication\s+required|invalid\s+api\s+key[\s\S]{0,120}(?:\/login|claude\s+login|log\s+in))/i;
|
||||
|
||||
// The token-failure markers. An assistant or model event can print these same
|
||||
// words as ordinary prose, so the detector matches them only against the parsed
|
||||
// terminal result fields of a failed run. See detectClaudeLoginRequired.
|
||||
const CLAUDE_AUTH_TOKEN_FAILURE_RE =
|
||||
/(?:authentication[_\s-](?:failed|error)|failed\s+to\s+authenticate|invalid\s+bearer\s+token|(?:invalid|expired|revoked)[\s\S]{0,40}(?:bearer|oauth|access)\s+token|(?:bearer|oauth|access)\s+token[\s\S]{0,40}(?:is\s+)?(?:invalid|expired|revoked))/i;
|
||||
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
|
||||
|
||||
const CLAUDE_TRANSIENT_UPSTREAM_RE =
|
||||
|
|
@ -163,21 +174,63 @@ export function extractClaudeLoginUrl(text: string): string | null {
|
|||
return match[0]?.replace(/[\])}.!,?;:'\"]+$/g, "") ?? null;
|
||||
}
|
||||
|
||||
// Collect the parsed terminal result fields that carry an auth failure. The
|
||||
// CLI writes the token-failure text to the result event, so the detector reads
|
||||
// the result string, the top-level error field, and the errors array. It never
|
||||
// reads the raw stdout, so an assistant event cannot inject a token marker.
|
||||
function collectClaudeTerminalText(parsed: Record<string, unknown>): string {
|
||||
return [
|
||||
asString(parsed.result, ""),
|
||||
asString(parsed.error, ""),
|
||||
...extractClaudeErrorMessages(parsed),
|
||||
]
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// Report whether the parsed terminal result marks the run as an auth failure.
|
||||
// The token-failure markers apply only to a failed run. A successful probe
|
||||
// whose answer text repeats an auth phrase does not classify as login required.
|
||||
function claudeResultIndicatesAuthFailure(parsed: Record<string, unknown>): boolean {
|
||||
if (asBoolean(parsed.is_error, false)) return true;
|
||||
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
|
||||
if (subtype.startsWith("error")) return true;
|
||||
const status =
|
||||
asNumber(parsed.api_error_status, 0) || asNumber(parsed.error_status, 0);
|
||||
if (status === 401 || status === 403) return true;
|
||||
if (asString(parsed.error, "").trim()) return true;
|
||||
return extractClaudeErrorMessages(parsed).length > 0;
|
||||
}
|
||||
|
||||
export function detectClaudeLoginRequired(input: {
|
||||
parsed: Record<string, unknown> | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}): { requiresLogin: boolean; loginUrl: string | null } {
|
||||
const resultText = asString(input.parsed?.result, "").trim();
|
||||
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
|
||||
const parsed = input.parsed ?? null;
|
||||
const resultText = asString(parsed?.result, "").trim();
|
||||
|
||||
// The legacy login-prompt markers keep their broad scope. They match against
|
||||
// every output line, which includes the parsed result, the parsed errors, and
|
||||
// the raw stdout and stderr.
|
||||
const promptLines = [resultText, ...extractClaudeErrorMessages(parsed ?? {}), input.stdout, input.stderr]
|
||||
.join("\n")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const loginPrompt = promptLines.some((line) => CLAUDE_LOGIN_PROMPT_RE.test(line));
|
||||
|
||||
// The token-failure markers match only against the parsed terminal fields of
|
||||
// a failed run. The raw stdout is untrusted, so a model that prints a token
|
||||
// phrase, or a successful run that repeats one, does not flip the classifier.
|
||||
const tokenFailure =
|
||||
parsed !== null &&
|
||||
claudeResultIndicatesAuthFailure(parsed) &&
|
||||
CLAUDE_AUTH_TOKEN_FAILURE_RE.test(collectClaudeTerminalText(parsed));
|
||||
|
||||
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
|
||||
return {
|
||||
requiresLogin,
|
||||
requiresLogin: loginPrompt || tokenFailure,
|
||||
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildClaudeLoginRequiredHint,
|
||||
logRedactedSandboxProbeDiagnostic,
|
||||
normalizeClaudeLoginUrl,
|
||||
} from "./probe-diagnostics.js";
|
||||
|
||||
describe("normalizeClaudeLoginUrl", () => {
|
||||
it("accepts an allowlisted https Claude host with no query or fragment", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://claude.ai/login")).toBe("https://claude.ai/login");
|
||||
});
|
||||
|
||||
it("accepts an allowlisted Anthropic subdomain", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://console.anthropic.com/login")).toBe(
|
||||
"https://console.anthropic.com/login",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a URL with a query", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://claude.ai/login?token=secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a URL with a fragment", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://claude.ai/login#access_token=secret")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a non-https URL", () => {
|
||||
expect(normalizeClaudeLoginUrl("http://claude.ai/login")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a non-allowlisted host", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://evil.example.com/claude-login")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a look-alike host that only ends with the brand word", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://claude.ai.evil.com/login")).toBeNull();
|
||||
expect(normalizeClaudeLoginUrl("https://notclaude.ai/login")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a URL that embeds credentials or a port", () => {
|
||||
expect(normalizeClaudeLoginUrl("https://user:pass@claude.ai/login")).toBeNull();
|
||||
expect(normalizeClaudeLoginUrl("https://claude.ai:8443/login")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty or malformed input", () => {
|
||||
expect(normalizeClaudeLoginUrl(null)).toBeNull();
|
||||
expect(normalizeClaudeLoginUrl(undefined)).toBeNull();
|
||||
expect(normalizeClaudeLoginUrl("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClaudeLoginRequiredHint", () => {
|
||||
it("names a safe login URL in the hint", () => {
|
||||
expect(buildClaudeLoginRequiredHint("https://claude.ai/login")).toBe(
|
||||
"Run `claude login` and complete sign-in at https://claude.ai/login, then retry.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the fixed hint for an unsafe URL", () => {
|
||||
expect(buildClaudeLoginRequiredHint("https://evil.example.com/claude-login?leak=secret")).toBe(
|
||||
"Run `claude login` in this environment, then retry the probe.",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the fixed hint for a null URL", () => {
|
||||
expect(buildClaudeLoginRequiredHint(null)).toBe(
|
||||
"Run `claude login` in this environment, then retry the probe.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logRedactedSandboxProbeDiagnostic", () => {
|
||||
it("redacts a JSON secret field before it reaches the log", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
logRedactedSandboxProbeDiagnostic("probe failed", '{"token":"opaque-secret-value"}');
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain("opaque-secret-value");
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not log when the diagnostic is empty", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
logRedactedSandboxProbeDiagnostic("probe failed", "");
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("redacts a JSON secret value with an escaped quote before it reaches the log", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
// The value holds an escaped quote, then the rest of the credential. A naive
|
||||
// matcher stops at the escaped quote and leaks the marker to the log.
|
||||
logRedactedSandboxProbeDiagnostic("probe failed", '{"token":"pre\\"MARKERLOGQUOTE"}');
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain("MARKERLOGQUOTE");
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("redacts an escaped-JSON secret value before it reaches the log", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const innerJson = '{"password":"pre\\\\MARKERLOGBACKSLASH"}';
|
||||
logRedactedSandboxProbeDiagnostic("probe failed", JSON.stringify(innerJson));
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain("MARKERLOGBACKSLASH");
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { redactDiagnosticText } from "@paperclipai/adapter-utils";
|
||||
|
||||
// The server log keeps a bounded diagnostic. The bound stops a very large probe
|
||||
// output from filling the log.
|
||||
const MAX_LOGGED_PROBE_DIAGNOSTIC_CHARS = 2000;
|
||||
|
||||
// The login hint may show a login URL. The URL must be a normalized https URL
|
||||
// with an allowlisted Claude or Anthropic host and no query or fragment. A host
|
||||
// matches when it equals a suffix or ends with a dot and the suffix.
|
||||
const ALLOWED_LOGIN_URL_HOST_SUFFIXES = ["anthropic.com", "claude.ai"] as const;
|
||||
|
||||
/**
|
||||
* Send a sandbox probe or config materialization diagnostic to the server log.
|
||||
*
|
||||
* Three Test-lane call sites use this helper:
|
||||
* - the Claude CLI Test lane (`test.ts`),
|
||||
* - the Claude ACP Test lane (`acp.ts`),
|
||||
* - the managed-config materialization step (`claude-config.ts`).
|
||||
*
|
||||
* The helper is the single boundary where a raw probe error, stdout, or stderr
|
||||
* string reaches an output. It redacts secrets with `redactDiagnosticText`
|
||||
* first, so no credential reaches the log. The sanitizer redacts shell
|
||||
* `KEY=value` secrets and JSON secret fields such as `{"token":"..."}`. The
|
||||
* helper also bounds the length. A caller must never copy the raw string into a
|
||||
* Test-result check, because the user interface renders check text.
|
||||
*
|
||||
* @param context A short fixed description of the failed step. It carries no
|
||||
* untrusted text.
|
||||
* @param raw The untrusted diagnostic from the sandbox. The helper redacts it.
|
||||
*/
|
||||
export function logRedactedSandboxProbeDiagnostic(
|
||||
context: string,
|
||||
raw: string | null | undefined,
|
||||
): void {
|
||||
if (!raw) return;
|
||||
const redacted = redactDiagnosticText(raw)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, MAX_LOGGED_PROBE_DIAGNOSTIC_CHARS);
|
||||
if (!redacted) return;
|
||||
console.warn(`[paperclip] ${context}`, { detail: redacted });
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an untrusted login URL for a Test-result hint.
|
||||
*
|
||||
* The extractor reads the URL from raw sandbox stdout or stderr, so the value is
|
||||
* untrusted. The function accepts the URL only when every rule holds:
|
||||
* - the protocol is `https`,
|
||||
* - the host equals or ends with an allowlisted Claude or Anthropic host,
|
||||
* - the URL has no user, password, or port,
|
||||
* - the URL has no query and no fragment.
|
||||
*
|
||||
* The function returns the normalized URL string, or `null` when any rule
|
||||
* fails. The caller shows a fixed `claude login` hint when the function returns
|
||||
* `null`.
|
||||
*/
|
||||
export function normalizeClaudeLoginUrl(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (url.protocol !== "https:") return null;
|
||||
if (url.username || url.password || url.port) return null;
|
||||
if (url.search || url.hash) return null;
|
||||
const host = url.hostname.toLowerCase();
|
||||
const allowed = ALLOWED_LOGIN_URL_HOST_SUFFIXES.some(
|
||||
(suffix) => host === suffix || host.endsWith(`.${suffix}`),
|
||||
);
|
||||
if (!allowed) return null;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the fixed auth-required hint for a Test-result check. When the login URL
|
||||
* normalizes to a safe value, the hint names it. Otherwise the hint tells the
|
||||
* operator to run `claude login`.
|
||||
*/
|
||||
export function buildClaudeLoginRequiredHint(loginUrl: string | null | undefined): string {
|
||||
const safeUrl = normalizeClaudeLoginUrl(loginUrl);
|
||||
return safeUrl
|
||||
? `Run \`claude login\` and complete sign-in at ${safeUrl}, then retry.`
|
||||
: "Run `claude login` in this environment, then retry the probe.";
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
// The managed-config step runs inside `prepareSandboxClaudeProbeRuntime`. The
|
||||
// step resolves the Paperclip instance root first. This mock makes that resolve
|
||||
// throw, so the managed-config materialization fails with a controllable error
|
||||
// that carries a secret marker.
|
||||
const { resolveInstanceRoot } = vi.hoisted(() => {
|
||||
const resolveInstanceRoot: { throwError: Error | null } = { throwError: null };
|
||||
return { resolveInstanceRoot };
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
maybeRunSandboxInstallCommand: vi.fn(async () => null),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
||||
"@paperclipai/adapter-utils/server-utils",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
resolvePaperclipInstanceRootForAdapter: (...args: unknown[]) => {
|
||||
if (resolveInstanceRoot.throwError) throw resolveInstanceRoot.throwError;
|
||||
return (
|
||||
actual.resolvePaperclipInstanceRootForAdapter as (...a: unknown[]) => string
|
||||
)(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
|
||||
|
||||
const sandboxTarget: AdapterExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "daytona",
|
||||
remoteCwd: "/home/daytona/paperclip-workspace",
|
||||
runner: {
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveInstanceRoot.throwError = null;
|
||||
});
|
||||
|
||||
describe("prepareSandboxClaudeProbeRuntime managed-config redaction", () => {
|
||||
it("never copies a materialization error into a Test-result check", async () => {
|
||||
// A materialization failure can carry a credential. Inject a secret marker
|
||||
// through the thrown error and assert no check text repeats it.
|
||||
const secret = "sk-ant-MANAGEDMARKER0123456789abcdef";
|
||||
resolveInstanceRoot.throwError = new Error(`materialize failed with ${secret}`);
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const checks = await prepareSandboxClaudeProbeRuntime({
|
||||
runId: "test-run",
|
||||
target: sandboxTarget,
|
||||
cwd: "/home/daytona/paperclip-workspace",
|
||||
env: {},
|
||||
installCommand: "npm i -g @anthropic-ai/claude-code",
|
||||
detectCommand: "claude --version",
|
||||
targetIsRemote: true,
|
||||
targetIsSandbox: true,
|
||||
helloProbeTimeoutSec: 5,
|
||||
});
|
||||
|
||||
const failedCheck = checks.find((check) => check.code === "claude_managed_config_dir_failed");
|
||||
expect(failedCheck).toBeDefined();
|
||||
expect(failedCheck?.level).toBe("error");
|
||||
|
||||
const checkText = JSON.stringify(checks);
|
||||
expect(checkText).not.toContain(secret);
|
||||
expect(checkText).not.toContain("MANAGEDMARKER");
|
||||
// The diagnostic still reaches the server log, but the secret is redacted.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain(secret);
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseSetupTokenPrompt,
|
||||
SETUP_TOKEN_AUTH_URLS,
|
||||
SETUP_TOKEN_PROMPT,
|
||||
SETUP_TOKEN_URL_QUERY_KEYS,
|
||||
} from "./setup-token-parse.js";
|
||||
|
||||
// The live characterization of `claude setup-token`. This suite runs the real
|
||||
// Claude Code login command in a real pseudo-terminal, captures the sign-in
|
||||
// prompt output, and confirms the parser reads the real terminal contract. It
|
||||
// records the tested Claude Code version. It fails loudly on a parse miss, so a
|
||||
// terminal-contract drift breaks the suite instead of passing silently.
|
||||
//
|
||||
// The suite is opt-in. It needs the real `claude` binary, `python3` for the
|
||||
// pseudo-terminal, and network access to claude.com to mint the authorization
|
||||
// URL, so it never runs in a normal or a continuous-integration test run. Set
|
||||
// `RUN_CLAUDE_SETUP_TOKEN_CHARACTERIZATION=1` to run it in a real sandbox.
|
||||
//
|
||||
// Security: the suite drives only the prompt phase. It never submits a browser
|
||||
// code, so the command never mints a token. It parses the captured bytes in
|
||||
// memory and asserts only the non-secret shape (the URL origin, the URL path,
|
||||
// the URL query keys, and the constant prompt). It never writes the captured
|
||||
// bytes, the real authorization URL, or any token to a durable file, a log, or
|
||||
// an assertion message.
|
||||
|
||||
const OPT_IN = process.env.RUN_CLAUDE_SETUP_TOKEN_CHARACTERIZATION === "1";
|
||||
|
||||
// A pseudo-terminal harness. It runs `claude setup-token` on a real
|
||||
// pseudo-terminal with a wide window, streams the raw terminal bytes to its own
|
||||
// standard output, and stops when it sees the prompt phrase or after a timeout.
|
||||
// It never submits a code, so the command never mints a token.
|
||||
const PTY_HARNESS = `
|
||||
import os, pty, sys, select, time, fcntl, termios, struct, signal
|
||||
duration = float(os.environ.get("STCHAR_DURATION", "40"))
|
||||
cols, rows = 200, 50
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.execvp("claude", ["claude", "setup-token"])
|
||||
os._exit(127)
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
||||
acc = b""
|
||||
start = time.time()
|
||||
try:
|
||||
while time.time() - start < duration:
|
||||
r, _, _ = select.select([fd], [], [], 0.5)
|
||||
if fd in r:
|
||||
try:
|
||||
data = os.read(fd, 65536)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
acc += data
|
||||
os.write(1, data)
|
||||
if b"Paste code here if prompted" in acc:
|
||||
break
|
||||
finally:
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
os.waitpid(pid, 0)
|
||||
except ChildProcessError:
|
||||
pass
|
||||
`;
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
const probe = spawnSync("sh", ["-c", `command -v ${command}`], { encoding: "utf8" });
|
||||
return probe.status === 0;
|
||||
}
|
||||
|
||||
function readClaudeVersion(): string {
|
||||
const raw = execFileSync("claude", ["--version"], { encoding: "utf8" });
|
||||
// The version line reads like `2.1.226 (Claude Code)`. Read the leading
|
||||
// semantic version.
|
||||
const match = raw.match(/(\d+\.\d+\.\d+)/);
|
||||
if (!match) throw new Error("could not read the Claude Code version");
|
||||
return match[1];
|
||||
}
|
||||
|
||||
// Removes the terminal control sequences the same way the parser does, so the
|
||||
// test can assert on the rendered banner text. It keeps this local, so the test
|
||||
// never imports a parser internal.
|
||||
function renderForBanner(raw: string): string {
|
||||
return raw
|
||||
// OSC sequences (hyperlinks and other OSC), BEL or ST terminated.
|
||||
.replace(/\x1b\][^\x1b\x07]*(?:\x1b\\|\x07)/g, "")
|
||||
// Horizontal-move CSI sequences render as one space.
|
||||
.replace(/\x1b\[[0-9;]*[GC]/g, " ")
|
||||
// Any remaining CSI sequence.
|
||||
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
||||
}
|
||||
|
||||
const run = OPT_IN ? describe : describe.skip;
|
||||
|
||||
run("claude setup-token live characterization", () => {
|
||||
it(
|
||||
"reads the real sign-in prompt contract and records the Claude Code version",
|
||||
() => {
|
||||
expect(hasCommand("claude"), "the claude binary must be installed").toBe(true);
|
||||
expect(hasCommand("python3"), "python3 must be installed for the pseudo-terminal").toBe(true);
|
||||
|
||||
const version = readClaudeVersion();
|
||||
|
||||
const result = spawnSync("python3", ["-c", PTY_HARNESS], {
|
||||
encoding: "buffer",
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
env: { ...process.env, STCHAR_DURATION: "40" },
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
const raw = (result.stdout ?? Buffer.alloc(0)).toString("utf8");
|
||||
|
||||
// Fail loudly on a parse miss. A terminal-contract drift breaks the suite
|
||||
// here instead of passing silently.
|
||||
const parsed = parseSetupTokenPrompt(raw);
|
||||
expect(parsed, "the parser did not read the live setup-token prompt").not.toBeNull();
|
||||
|
||||
const url = new URL(parsed!.url);
|
||||
// The live CLI emits one of the two accepted origin-and-path pairs. The
|
||||
// installed version decides which pair the run records.
|
||||
expect(SETUP_TOKEN_AUTH_URLS).toContain(`${url.origin}${url.pathname}`);
|
||||
expect([...url.searchParams.keys()].sort()).toEqual([...SETUP_TOKEN_URL_QUERY_KEYS].sort());
|
||||
expect(url.hash).toBe("");
|
||||
expect(parsed!.prompt).toBe(SETUP_TOKEN_PROMPT);
|
||||
|
||||
// The banner names the tested Claude Code version. This ties the recorded
|
||||
// contract to the version that produced it.
|
||||
const banner = renderForBanner(raw);
|
||||
expect(banner).toContain(`Claude Code v${version}`);
|
||||
|
||||
// Record the tested version and the non-secret anchors. The reporter shows
|
||||
// this line; it names no secret.
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[characterization] Claude Code v${version}: prompt "${SETUP_TOKEN_PROMPT}", ` +
|
||||
`URL ${url.origin}${url.pathname} with query keys ` +
|
||||
`[${[...SETUP_TOKEN_URL_QUERY_KEYS].join(", ")}].`,
|
||||
);
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
|
@ -4,10 +4,11 @@ import { fileURLToPath } from "node:url";
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
SETUP_TOKEN_AFTER_ANCHOR,
|
||||
SETUP_TOKEN_AUTH_URLS,
|
||||
SETUP_TOKEN_BEFORE_ANCHOR,
|
||||
SETUP_TOKEN_PREFIX,
|
||||
SETUP_TOKEN_PROMPT,
|
||||
SETUP_TOKEN_URL_PATH,
|
||||
SETUP_TOKEN_REDIRECT_URI,
|
||||
SETUP_TOKEN_URL_QUERY_KEYS,
|
||||
parseSetupTokenCredential,
|
||||
parseSetupTokenPrompt,
|
||||
|
|
@ -19,18 +20,35 @@ 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 synthetic query values. Each value passes the value validators of the
|
||||
// contract. No value is a real OAuth value.
|
||||
const CLIENT_ID = "9d1c8f00-1a2b-3c4d-5e6f-708192a3b4c5";
|
||||
const CODE = "ac_0aB1cD2eF3gH4iJ5kL6mN7oP8qR9sT";
|
||||
const CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
|
||||
const STATE = "Xy7Kd2Pq9Rn4Vb8Lf1Mw6Zc3Hj0Tg5Us";
|
||||
const SCOPE = "org:create_api_key user:profile";
|
||||
|
||||
// The query of a well-formed authorization URL. The `redirect_uri` and the
|
||||
// `scope` are percent-encoded the way the CLI emits them; the parser decodes and
|
||||
// validates each value.
|
||||
const QUERY = [
|
||||
`client_id=${CLIENT_ID}`,
|
||||
`code=${CODE}`,
|
||||
`code_challenge=${CODE_CHALLENGE}`,
|
||||
"code_challenge_method=S256",
|
||||
`redirect_uri=${encodeURIComponent(SETUP_TOKEN_REDIRECT_URI)}`,
|
||||
"response_type=code",
|
||||
`scope=${encodeURIComponent(SCOPE)}`,
|
||||
`state=${STATE}`,
|
||||
].join("&");
|
||||
|
||||
// The two observed authorization URLs. `claude` 2.1.19 emits the `claude.ai`
|
||||
// URL; `claude` 2.1.205 and 2.1.226 emit the `claude.com` URL.
|
||||
const CLAUDE_AI_URL = `https://claude.ai/oauth/authorize?${QUERY}`;
|
||||
const CLAUDE_COM_URL = `https://claude.com/cai/oauth/authorize?${QUERY}`;
|
||||
|
||||
// The default URL for the credential-parser regression tests below.
|
||||
const VALID_URL = CLAUDE_COM_URL;
|
||||
|
||||
// The URL preamble line and the browser-code prompt line, exactly as the
|
||||
// fixture records them.
|
||||
|
|
@ -56,28 +74,66 @@ function osc8Hyperlink(url: string, display: string): string {
|
|||
return `${ESC}]8;;${url}${ESC}\\${display}${ESC}]8;;${ESC}\\`;
|
||||
}
|
||||
|
||||
// Joins `words` with a Cursor Horizontal Absolute (CHA) sequence. The live
|
||||
// Claude login UI lays out each word at an absolute column with a CHA sequence
|
||||
// instead of a literal space, so this reproduces one Ink-rendered line. The
|
||||
// column number does not matter to the parser, so this uses a fixed number. The
|
||||
// parser must render each CHA sequence as a space to read the line.
|
||||
function chaSpaced(words: string[]): string {
|
||||
return words.join("\x1b[9G");
|
||||
}
|
||||
|
||||
// Wraps a string into physical lines of at most `width` characters, joined with
|
||||
// a line feed and no space at the wrap. This reproduces the `claude` 2.1.19 hard
|
||||
// line wrap of the plain-text authorization URL.
|
||||
function hardWrap(text: string, width: number): string {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < text.length; i += width) {
|
||||
lines.push(text.slice(i, i + width));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
describe("parseSetupTokenPrompt", () => {
|
||||
it("returns the exact URL and prompt from a complete output", () => {
|
||||
const result = parseSetupTokenPrompt(completeOutput(VALID_URL));
|
||||
it("returns the exact claude.com URL and prompt from a complete output", () => {
|
||||
const result = parseSetupTokenPrompt(completeOutput(CLAUDE_COM_URL));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(VALID_URL);
|
||||
expect(result?.url).toBe(CLAUDE_COM_URL);
|
||||
expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT);
|
||||
});
|
||||
|
||||
it("returns the exact claude.ai URL and prompt from a complete output", () => {
|
||||
const result = parseSetupTokenPrompt(completeOutput(CLAUDE_AI_URL));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(CLAUDE_AI_URL);
|
||||
expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT);
|
||||
});
|
||||
|
||||
it("reassembles the claude.ai URL from the 2.1.19 hard-wrapped plain-text form", () => {
|
||||
// `claude` 2.1.19 emits the URL as plain text with a hard line wrap and no
|
||||
// OSC 8 hyperlink. The parser joins the URL-character-only physical lines and
|
||||
// validates only the final reassembled string.
|
||||
const wrapped = hardWrap(CLAUDE_AI_URL, 64);
|
||||
expect(wrapped).toContain("\n");
|
||||
const text = [PREAMBLE_LINE, wrapped, PROMPT_LINE].join("\n");
|
||||
const result = parseSetupTokenPrompt(text);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(CLAUDE_AI_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(
|
||||
// The streaming caller accumulates chunks and re-parses the whole buffer. The
|
||||
// chunk boundary falls inside the URL token with no line break, so the joined
|
||||
// buffer holds the whole URL on one line.
|
||||
const chunkA = ["Opening browser to sign in…", PREAMBLE_LINE, CLAUDE_COM_URL.slice(0, 40)].join(
|
||||
"\n",
|
||||
);
|
||||
const chunkB = [`${CLAUDE_COM_URL.slice(40)}`, 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?.url).toBe(CLAUDE_COM_URL);
|
||||
expect(joined?.prompt).toBe(SETUP_TOKEN_PROMPT);
|
||||
});
|
||||
|
||||
|
|
@ -86,48 +142,205 @@ describe("parseSetupTokenPrompt", () => {
|
|||
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 wrappedDisplay = "https://claude.com/cai/oauth/aut\nhorize?client_id=…";
|
||||
const text = [
|
||||
`${cyan}Opening browser to sign in…${reset}`,
|
||||
PREAMBLE_LINE,
|
||||
`${cyan}${osc8Hyperlink(VALID_URL, wrappedDisplay)}${reset}`,
|
||||
`${cyan}${osc8Hyperlink(CLAUDE_COM_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?.url).toBe(CLAUDE_COM_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 host", () => {
|
||||
const badHost = CLAUDE_COM_URL.replace("https://claude.com", "https://claude.example.com");
|
||||
expect(parseSetupTokenPrompt(completeOutput(badHost))).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 mixed-case host spelling", () => {
|
||||
// The raw candidate must start with the exact lowercase host prefix, so a
|
||||
// mixed-case host cannot bind even though the URL class lowercases the host.
|
||||
const mixedCase = CLAUDE_AI_URL.replace("https://claude.ai", "https://Claude.AI");
|
||||
expect(parseSetupTokenPrompt(completeOutput(mixedCase))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an explicit default :443 port on the host", () => {
|
||||
const withPort = CLAUDE_AI_URL.replace("https://claude.ai/", "https://claude.ai:443/");
|
||||
expect(parseSetupTokenPrompt(completeOutput(withPort))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a userinfo prefix on the host", () => {
|
||||
const withUserinfo = CLAUDE_AI_URL.replace("https://claude.ai/", "https://user@claude.ai/");
|
||||
expect(parseSetupTokenPrompt(completeOutput(withUserinfo))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a punycode look-alike host", () => {
|
||||
// A punycode host does not start with the exact lowercase prefix, so the
|
||||
// parser rejects it before the URL parse.
|
||||
const punycode = "https://xn--clade-9wa.ai/oauth/authorize?" + QUERY;
|
||||
expect(parseSetupTokenPrompt(completeOutput(punycode))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the claude.ai host with the claude.com path", () => {
|
||||
const crossPair = `https://claude.ai/cai/oauth/authorize?${QUERY}`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(crossPair))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the claude.com host with the claude.ai path", () => {
|
||||
const crossPair = `https://claude.com/oauth/authorize?${QUERY}`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(crossPair))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing query key", () => {
|
||||
const missingKey = VALID_URL.replace("&state=state-000", "");
|
||||
const missingKey = CLAUDE_COM_URL.replace(`&state=${STATE}`, "");
|
||||
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 duplicate query key", () => {
|
||||
const duplicate = `${CLAUDE_COM_URL}&state=${STATE}`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(duplicate))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an unexpected added key with a valid name", () => {
|
||||
// The parser accepts at most four added keys, so a first added key still
|
||||
// needs a valid name; a valid name alone does not fail. This test proves a
|
||||
// fifth added key fails the count bound.
|
||||
const fiveAdded = `${CLAUDE_COM_URL}&a1=1&b2=2&c3=3&d4=4&e5=5`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(fiveAdded))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the URL when it carries up to four valid added keys", () => {
|
||||
const fourAdded = `${CLAUDE_COM_URL}&a1=1&b2=2&c3=3&d4=4`;
|
||||
const result = parseSetupTokenPrompt(completeOutput(fourAdded));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(fourAdded);
|
||||
});
|
||||
|
||||
it("returns null for an added key with an invalid name", () => {
|
||||
const badName = `${CLAUDE_COM_URL}&Extra=1`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(badName))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an added key value over the length cap", () => {
|
||||
const longValue = `${CLAUDE_COM_URL}&extra=${"a".repeat(257)}`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(longValue))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a duplicate added key", () => {
|
||||
const dupAdded = `${CLAUDE_COM_URL}&extra=1&extra=2`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(dupAdded))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an invalid code_challenge_method", () => {
|
||||
const badMethod = CLAUDE_COM_URL.replace("code_challenge_method=S256", "code_challenge_method=plain");
|
||||
expect(parseSetupTokenPrompt(completeOutput(badMethod))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for an invalid response_type", () => {
|
||||
const badType = CLAUDE_COM_URL.replace("response_type=code", "response_type=token");
|
||||
expect(parseSetupTokenPrompt(completeOutput(badType))).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a short four-character code value", () => {
|
||||
// A real `claude` 2.1.19 capture measured a four-character `code` value, so
|
||||
// the parser accepts it.
|
||||
const shortCode = CLAUDE_AI_URL.replace(`code=${CODE}`, "code=wZ9x");
|
||||
const result = parseSetupTokenPrompt(completeOutput(shortCode));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(shortCode);
|
||||
});
|
||||
|
||||
it("returns null for an empty code value", () => {
|
||||
const emptyCode = CLAUDE_COM_URL.replace(`code=${CODE}`, "code=");
|
||||
expect(parseSetupTokenPrompt(completeOutput(emptyCode))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a code_challenge that is too short", () => {
|
||||
const shortChallenge = CLAUDE_COM_URL.replace(`code_challenge=${CODE_CHALLENGE}`, "code_challenge=tooshort");
|
||||
expect(parseSetupTokenPrompt(completeOutput(shortChallenge))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a state that is too short", () => {
|
||||
const shortState = CLAUDE_COM_URL.replace(`state=${STATE}`, "state=short");
|
||||
expect(parseSetupTokenPrompt(completeOutput(shortState))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a client_id with an invalid character", () => {
|
||||
const badClientId = CLAUDE_COM_URL.replace(`client_id=${CLIENT_ID}`, "client_id=bad%2Fid");
|
||||
expect(parseSetupTokenPrompt(completeOutput(badClientId))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a scope with too many tokens", () => {
|
||||
const scope = encodeURIComponent("a b c d e f g h i");
|
||||
const manyScope = CLAUDE_COM_URL.replace(`scope=${encodeURIComponent(SCOPE)}`, `scope=${scope}`);
|
||||
expect(parseSetupTokenPrompt(completeOutput(manyScope))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a scope with an invalid token character", () => {
|
||||
const scope = encodeURIComponent("user:profile bad/token");
|
||||
const badScope = CLAUDE_COM_URL.replace(`scope=${encodeURIComponent(SCOPE)}`, `scope=${scope}`);
|
||||
expect(parseSetupTokenPrompt(completeOutput(badScope))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a redirect_uri with a wrong host", () => {
|
||||
const badRedirect = encodeURIComponent("https://evil.example.com/oauth/code/callback");
|
||||
const url = CLAUDE_COM_URL.replace(
|
||||
`redirect_uri=${encodeURIComponent(SETUP_TOKEN_REDIRECT_URI)}`,
|
||||
`redirect_uri=${badRedirect}`,
|
||||
);
|
||||
expect(parseSetupTokenPrompt(completeOutput(url))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a redirect_uri with a stray query", () => {
|
||||
const badRedirect = encodeURIComponent(`${SETUP_TOKEN_REDIRECT_URI}?next=/`);
|
||||
const url = CLAUDE_COM_URL.replace(
|
||||
`redirect_uri=${encodeURIComponent(SETUP_TOKEN_REDIRECT_URI)}`,
|
||||
`redirect_uri=${badRedirect}`,
|
||||
);
|
||||
expect(parseSetupTokenPrompt(completeOutput(url))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a URL with a fragment", () => {
|
||||
const withFragment = `${VALID_URL}#section`;
|
||||
const withFragment = `${CLAUDE_COM_URL}#section`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(withFragment))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a raw sk-ant- value in a query value", () => {
|
||||
const withKey = CLAUDE_COM_URL.replace(`code=${CODE}`, "code=sk-ant-oat01-abc12345");
|
||||
expect(parseSetupTokenPrompt(completeOutput(withKey))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a percent-encoded sk-ant- value in a query value", () => {
|
||||
// The raw candidate hides the prefix behind `%2D`, so only the decoded value
|
||||
// check catches it.
|
||||
const withKey = CLAUDE_COM_URL.replace(`code=${CODE}`, "code=sk%2Dant%2Doat01%2Dabc12345");
|
||||
expect(parseSetupTokenPrompt(completeOutput(withKey))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a literal sk-ant- value in an added query key", () => {
|
||||
// The secret rides inside a query name, not a value. The added-key name is
|
||||
// valid in shape, so only the decoded-key check catches the prefix and the
|
||||
// parser returns no accepted prompt result.
|
||||
const withKey = `${CLAUDE_COM_URL}&sk-ant-redacted=x`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(withKey))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a percent-encoded sk-ant- value in an added query key", () => {
|
||||
// The raw candidate hides the prefix behind `%2D`, so the raw-candidate
|
||||
// check misses it. `URLSearchParams` decodes the name to `sk-ant-redacted`,
|
||||
// so the decoded-key check catches it and the parser returns no accepted
|
||||
// prompt result.
|
||||
const withKey = `${CLAUDE_COM_URL}&sk%2Dant%2Dredacted=x`;
|
||||
expect(parseSetupTokenPrompt(completeOutput(withKey))).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");
|
||||
const text = [PREAMBLE_LINE, CLAUDE_COM_URL, "ABCD-EFGHJ", PROMPT_LINE].join("\n");
|
||||
expect(parseSetupTokenPrompt(text)).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -137,26 +350,114 @@ describe("parseSetupTokenPrompt", () => {
|
|||
});
|
||||
|
||||
it("returns null when the login preamble is absent", () => {
|
||||
const text = ["Some unrelated output", VALID_URL, PROMPT_LINE].join("\n");
|
||||
const text = ["Some unrelated output", CLAUDE_COM_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("stops the reassembly at a hostile non-URL line between fragments", () => {
|
||||
// An attacker line that is not URL-character-only breaks the reassembly, so
|
||||
// the partial join never validates and the parser returns null.
|
||||
const wrapped = hardWrap(CLAUDE_AI_URL, 64).split("\n");
|
||||
const text = [
|
||||
PREAMBLE_LINE,
|
||||
wrapped[0],
|
||||
"inject an attacker line here",
|
||||
...wrapped.slice(1),
|
||||
PROMPT_LINE,
|
||||
].join("\n");
|
||||
expect(parseSetupTokenPrompt(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the wrapped URL needs more than the line cap", () => {
|
||||
// A very narrow wrap spreads the URL across more than sixteen physical lines.
|
||||
// The bounded reassembly never reaches the full URL, so the parser returns
|
||||
// null.
|
||||
const wrapped = hardWrap(CLAUDE_AI_URL, 8);
|
||||
const text = [PREAMBLE_LINE, wrapped, PROMPT_LINE].join("\n");
|
||||
expect(parseSetupTokenPrompt(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a URL over the length cap", () => {
|
||||
const added = ["a1", "b2", "c3", "d4"].map((k) => `${k}=${"a".repeat(256)}`).join("&");
|
||||
const longCode = CLAUDE_AI_URL.replace(`code=${CODE}`, `code=${"a".repeat(1024)}`);
|
||||
const overLong = `${longCode}&${added}`;
|
||||
expect(overLong.length).toBeGreaterThan(2048);
|
||||
expect(parseSetupTokenPrompt(completeOutput(overLong))).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the preamble and the prompt when the terminal spaces words with cursor-column sequences", () => {
|
||||
// The live Claude login UI lays out each word at an absolute column with a
|
||||
// CHA sequence, so it emits no literal space between two words. The parser
|
||||
// renders each CHA sequence as a space; without that step the words glue
|
||||
// together and the preamble and the prompt never match.
|
||||
const preamble = chaSpaced([
|
||||
"Browser",
|
||||
"didn't",
|
||||
"open?",
|
||||
"Use",
|
||||
"the",
|
||||
"url",
|
||||
"below",
|
||||
"to",
|
||||
"sign",
|
||||
"in",
|
||||
"(c",
|
||||
"to",
|
||||
"copy)",
|
||||
]);
|
||||
const prompt = chaSpaced(["Paste", "code", "here", "if", "prompted", ">"]);
|
||||
const text = [preamble, CLAUDE_COM_URL, prompt].join("\n");
|
||||
const result = parseSetupTokenPrompt(text);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(CLAUDE_COM_URL);
|
||||
expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT);
|
||||
});
|
||||
|
||||
it("binds the prompt after the terminal repeats the URL once per wrapped display row", () => {
|
||||
// The terminal can emit one full URL line per wrapped display row, so it
|
||||
// repeats the same full authorization URL on a few consecutive lines. The
|
||||
// parser accepts the first full URL line and skips the repeated lines, then
|
||||
// binds the prompt on the line after the URL block.
|
||||
const text = [
|
||||
"Browser didn't open? Use the url below to sign in (c to copy)",
|
||||
CLAUDE_COM_URL,
|
||||
CLAUDE_COM_URL,
|
||||
CLAUDE_COM_URL,
|
||||
"",
|
||||
"Paste code here if prompted >",
|
||||
].join("\n");
|
||||
const result = parseSetupTokenPrompt(text);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.url).toBe(CLAUDE_COM_URL);
|
||||
expect(result?.prompt).toBe(SETUP_TOKEN_PROMPT);
|
||||
});
|
||||
|
||||
it("returns null when an unrelated line sits after the URL block", () => {
|
||||
// A non-blank line that is neither the prompt nor a repeat of the URL breaks
|
||||
// the bind, so a stray value between the URL and the prompt cannot pass.
|
||||
const text = [
|
||||
"Browser didn't open? Use the url below to sign in (c to copy)",
|
||||
CLAUDE_COM_URL,
|
||||
CLAUDE_COM_URL,
|
||||
"an unrelated line",
|
||||
"Paste code here if prompted >",
|
||||
].join("\n");
|
||||
expect(parseSetupTokenPrompt(text)).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.
|
||||
// The fixture documents the prompt text, the two authorization URLs, 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 url of SETUP_TOKEN_AUTH_URLS) {
|
||||
expect(fixture).toContain(url);
|
||||
}
|
||||
for (const key of SETUP_TOKEN_URL_QUERY_KEYS) {
|
||||
expect(fixture).toContain(`\`${key}\``);
|
||||
}
|
||||
|
|
@ -171,9 +472,12 @@ 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 {
|
||||
// Builds the success screen around a token body, joined with `delimiter`. The
|
||||
// body holds the token fragment lines, already split the way the terminal wraps
|
||||
// them. The real terminal redraw joins the anchor lines and the token with a
|
||||
// bare carriage return, or a CRLF pair, instead of a line feed, so the tests
|
||||
// vary the delimiter to prove the parser reads each real record shape.
|
||||
function successScreenJoined(body: string[], delimiter: string): string {
|
||||
return [
|
||||
"✓ Long-lived authentication token created successfully!",
|
||||
"",
|
||||
|
|
@ -184,7 +488,12 @@ function successScreen(body: string[]): string {
|
|||
SETUP_TOKEN_AFTER_ANCHOR,
|
||||
"",
|
||||
"Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token>",
|
||||
].join("\n");
|
||||
].join(delimiter);
|
||||
}
|
||||
|
||||
// Builds the success screen around a token body, joined with line feeds.
|
||||
function successScreen(body: string[]): string {
|
||||
return successScreenJoined(body, "\n");
|
||||
}
|
||||
|
||||
describe("parseSetupTokenCredential", () => {
|
||||
|
|
@ -198,6 +507,58 @@ describe("parseSetupTokenCredential", () => {
|
|||
expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN);
|
||||
});
|
||||
|
||||
it("returns the token from a bare carriage-return success record", () => {
|
||||
// The real terminal redraw joins the before-anchor line, the token, and the
|
||||
// after-anchor line with a bare carriage return. The parser canonicalizes
|
||||
// each bare carriage return to a line feed before the split, so each anchor
|
||||
// line and the token land on their own lines and the token binds.
|
||||
const text = successScreenJoined([FULL_TOKEN], "\r");
|
||||
expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN);
|
||||
});
|
||||
|
||||
it("returns the token from a CRLF-delimited success record", () => {
|
||||
// A terminal that ends each line with a carriage return and a line feed
|
||||
// must parse the same as a plain line-feed record. The parser maps each
|
||||
// CRLF pair to one line feed, so no empty line splits between the anchors.
|
||||
const text = successScreenJoined([FULL_TOKEN], "\r\n");
|
||||
expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN);
|
||||
});
|
||||
|
||||
it("returns the de-wrapped token from a bare carriage-return wrapped record", () => {
|
||||
// The terminal wraps the token across two physical lines and joins every
|
||||
// line with a bare carriage return. The parser canonicalizes the delimiter,
|
||||
// then joins the two fragment lines into the full token.
|
||||
const text = successScreenJoined([TOKEN_FRAGMENT_A, TOKEN_FRAGMENT_B], "\r");
|
||||
expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN);
|
||||
});
|
||||
|
||||
it("returns null for duplicate anchors in a bare carriage-return record", () => {
|
||||
// Canonicalization keeps the duplicate-anchor guard closed. Two success
|
||||
// blocks joined by a bare carriage return still fail closed.
|
||||
const one = successScreenJoined([FULL_TOKEN], "\r");
|
||||
const text = `${one}\r${one}`;
|
||||
expect(parseSetupTokenCredential(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for junk between the anchors in a bare carriage-return record", () => {
|
||||
// A stray prose line between the anchors fails the fragment test, so the
|
||||
// parser fails closed after canonicalization.
|
||||
const text = successScreenJoined([TOKEN_FRAGMENT_A, "a stray note", TOKEN_FRAGMENT_B], "\r");
|
||||
expect(parseSetupTokenCredential(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a wrong token prefix in a bare carriage-return record", () => {
|
||||
const text = successScreenJoined(["sk-ant-api03-AAAABBBBCCCCDDDDEEEE1111"], "\r");
|
||||
expect(parseSetupTokenCredential(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for extra token text in a bare carriage-return record", () => {
|
||||
// The token line holds the token and extra prose. The parser reads only a
|
||||
// token fragment on each line, so canonicalization does not open a gap.
|
||||
const text = successScreenJoined([`${FULL_TOKEN} keep this secret`], "\r");
|
||||
expect(parseSetupTokenCredential(text)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the token through ANSI color sequences", () => {
|
||||
const cyan = "\x1b[36m";
|
||||
const reset = "\x1b[0m";
|
||||
|
|
@ -205,6 +566,38 @@ describe("parseSetupTokenCredential", () => {
|
|||
expect(parseSetupTokenCredential(text)).toBe(FULL_TOKEN);
|
||||
});
|
||||
|
||||
it("reads the token when the terminal spaces the anchor words with cursor-column sequences", () => {
|
||||
// The success screen renders the anchor lines the same way as the prompt: it
|
||||
// lays out each word at an absolute column with a CHA sequence. The parser
|
||||
// renders each CHA sequence as a space, so the anchor lines match the exact
|
||||
// anchor text and the token binds between them.
|
||||
const beforeAnchor = chaSpaced([
|
||||
"Your",
|
||||
"OAuth",
|
||||
"token",
|
||||
"(valid",
|
||||
"for",
|
||||
"1",
|
||||
"year):",
|
||||
]);
|
||||
const afterAnchor = chaSpaced([
|
||||
"Store",
|
||||
"this",
|
||||
"token",
|
||||
"securely.",
|
||||
"You",
|
||||
"won't",
|
||||
"be",
|
||||
"able",
|
||||
"to",
|
||||
"see",
|
||||
"it",
|
||||
"again.",
|
||||
]);
|
||||
const text = [beforeAnchor, "", TOKEN_FRAGMENT_A, TOKEN_FRAGMENT_B, "", afterAnchor].join("\n");
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -3,12 +3,19 @@
|
|||
// 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.
|
||||
// Security (shape validation): the prompt parser accepts an authorization URL
|
||||
// only when it matches one of the two observed origin-and-path pairs, carries
|
||||
// each required query key one time with a valid decoded value, and pins the
|
||||
// `redirect_uri` value to the one observed callback. It rejects a look-alike
|
||||
// host, userinfo, an explicit port, a fragment, a duplicate key, an unexpected
|
||||
// added key, an invalid value, and an API-key-shaped value in the raw URL or in
|
||||
// any decoded query value. Different Claude CLI versions render the URL two ways:
|
||||
// a single OSC 8 hyperlink, or plain text with a hard line wrap and no
|
||||
// hyperlink. The parser reassembles the wrapped plain-text URL from the physical
|
||||
// lines and validates only the final reassembled string, so the login works on
|
||||
// every CLI version that keeps this flow shape. It binds the browser-code prompt
|
||||
// to the dedicated line after the URL block; a repeated URL line, a code-like
|
||||
// value on the wrong line, or an unrelated line cannot form a prompt.
|
||||
//
|
||||
// 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
|
||||
|
|
@ -30,16 +37,36 @@ export interface SetupTokenPrompt {
|
|||
// 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 two accepted authorization URLs, each the exact origin plus path. The
|
||||
// parser accepts an origin-and-path pair only when it equals one of these two
|
||||
// strings. It does not accept the cross-product of the two hosts and the two
|
||||
// paths. `claude` 2.1.19 emits the first URL; `claude` 2.1.205 and 2.1.226 emit
|
||||
// the second URL.
|
||||
export const SETUP_TOKEN_AUTH_URLS = [
|
||||
"https://claude.ai/oauth/authorize",
|
||||
"https://claude.com/cai/oauth/authorize",
|
||||
] as const;
|
||||
|
||||
// The one and only accepted authorization URL path.
|
||||
export const SETUP_TOKEN_URL_PATH = "/cai/oauth/authorize";
|
||||
// The lowercase host prefix of each accepted authorization URL. The parser
|
||||
// requires the raw URL candidate to start with one of these exact lowercase
|
||||
// prefixes before it parses the URL. This rejects a mixed-case host spelling, a
|
||||
// punycode host, and an explicit default `:443` port, because none of them start
|
||||
// with the exact lowercase `https://<host>/` prefix.
|
||||
const SETUP_TOKEN_AUTH_URL_PREFIXES = [
|
||||
"https://claude.ai/",
|
||||
"https://claude.com/",
|
||||
] as const;
|
||||
|
||||
// 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.
|
||||
// The one accepted `redirect_uri` value. The parser parses the decoded
|
||||
// `redirect_uri` value on its own and pins it to this exact callback, with no
|
||||
// userinfo, no port, no query, and no fragment. The 2.1.226 live capture grounds
|
||||
// this host; the contract keeps a single pin until sanitized 2.1.19 evidence
|
||||
// proves another real callback.
|
||||
export const SETUP_TOKEN_REDIRECT_URI = "https://platform.claude.com/oauth/code/callback";
|
||||
|
||||
// The required query keys of the authorization URL. The parser requires each key
|
||||
// one time with a valid decoded value. It rejects a missing key, a duplicate
|
||||
// key, and an invalid value.
|
||||
export const SETUP_TOKEN_URL_QUERY_KEYS = [
|
||||
"client_id",
|
||||
"code",
|
||||
|
|
@ -51,6 +78,55 @@ export const SETUP_TOKEN_URL_QUERY_KEYS = [
|
|||
"state",
|
||||
] as const;
|
||||
|
||||
// The set form of the required query keys. The parser uses it to sort a key into
|
||||
// a required key or an added key.
|
||||
const SETUP_TOKEN_CORE_KEY_SET = new Set<string>(SETUP_TOKEN_URL_QUERY_KEYS);
|
||||
|
||||
// The value validators for the required query keys. Each pattern binds the whole
|
||||
// decoded value. The parser rejects a value that does not match.
|
||||
const CLIENT_ID_RE = /^[A-Za-z0-9._~-]{1,128}$/;
|
||||
// The `code` value of the `claude` 2.1.19 authorization request is a short
|
||||
// opaque token. A real 2.1.19 capture measured a 4-character value, so the
|
||||
// minimum length is one character. The character class and the 1024-character
|
||||
// maximum bound the value.
|
||||
const CODE_RE = /^[A-Za-z0-9._~-]{1,1024}$/;
|
||||
const CODE_CHALLENGE_RE = /^[A-Za-z0-9_-]{43,128}$/;
|
||||
const STATE_RE = /^[A-Za-z0-9_-]{16,256}$/;
|
||||
// One `scope` token. The `scope` value is one to eight space-separated tokens.
|
||||
const SCOPE_TOKEN_RE = /^[A-Za-z0-9:._-]{1,64}$/;
|
||||
|
||||
// The name of an added query key. An added key is any key that is not a required
|
||||
// key. The parser accepts at most four added keys, each with a name that matches
|
||||
// this pattern.
|
||||
const ADDED_KEY_NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
|
||||
|
||||
// The decoded value of an added query key. The parser accepts at most 256
|
||||
// printable ASCII characters.
|
||||
const ADDED_VALUE_RE = /^[\x20-\x7E]{0,256}$/;
|
||||
|
||||
// An API-key prefix. The Claude setup token and API key start with `sk-ant-`.
|
||||
// The parser rejects the raw URL candidate and any decoded query value that
|
||||
// holds this prefix, so a secret cannot ride inside the authorization URL.
|
||||
const API_KEY_RE = /sk-ant-/i;
|
||||
|
||||
// The maximum number of added query keys. The parser accepts at most four added
|
||||
// keys, so the URL holds at most twelve keys in total.
|
||||
const MAX_ADDED_KEYS = 4;
|
||||
|
||||
// The maximum number of `scope` tokens.
|
||||
const MAX_SCOPE_TOKENS = 8;
|
||||
|
||||
// The maximum length of the whole `scope` value.
|
||||
const MAX_SCOPE_LENGTH = 512;
|
||||
|
||||
// The maximum length of the reassembled authorization URL. The parser rejects a
|
||||
// candidate longer than this cap.
|
||||
const MAX_URL_LENGTH = 2048;
|
||||
|
||||
// The characters that a URL can hold. A physical line that reassembles into the
|
||||
// wrapped URL holds only these characters after a trim.
|
||||
const URL_CHARS_ONLY_RE = /^[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+$/;
|
||||
|
||||
// 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
|
||||
|
|
@ -59,6 +135,18 @@ export const SETUP_TOKEN_URL_QUERY_KEYS = [
|
|||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
||||
|
||||
// A horizontal-move CSI sequence: Cursor Horizontal Absolute (final byte `G`)
|
||||
// and Cursor Forward (final byte `C`). The Claude login UI lays out each word at
|
||||
// an absolute column with one of these sequences, so it emits no literal space
|
||||
// between two words. The parser replaces each horizontal-move sequence with one
|
||||
// space, so a word gap reads as a space. Without this step the later CSI removal
|
||||
// deletes the sequence and glues the two words, and the URL preamble line, the
|
||||
// browser-code prompt line, and the success anchor lines never match. The
|
||||
// vertical and color sequences carry no horizontal text gap; the CSI removal
|
||||
// below drops them.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const CSI_HORIZONTAL_MOVE_RE = /\x1b\[[0-9;]*[GC]/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
|
||||
|
|
@ -76,14 +164,10 @@ const OSC8_HYPERLINK_RE =
|
|||
// 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.
|
||||
// strips only these characters when it compares a line to a repeated URL. It
|
||||
// never strips these from the candidate before validation, because the validator
|
||||
// rejects any trailing punctuation on its own.
|
||||
const TRAILING_PUNCTUATION_RE = /[)\].,;:!]+$/;
|
||||
|
||||
// The line that introduces the URL. The Claude login UI prints this phrase right
|
||||
|
|
@ -97,134 +181,218 @@ const URL_PREAMBLE_RE = /the url below to sign in/i;
|
|||
// 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
|
||||
// The parser accepts a URL start 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 maximum number of consecutive physical lines that the parser joins to
|
||||
// reassemble a wrapped plain-text URL. The `claude` 2.1.19 CLI wraps the URL at
|
||||
// the terminal width with no space at the wrap, so the parser joins a bounded
|
||||
// run of URL-character-only lines.
|
||||
const MAX_URL_LINES = 16;
|
||||
|
||||
// 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.
|
||||
// The result of a URL search: the validated URL and the index of the physical
|
||||
// line that holds the last fragment of the URL.
|
||||
interface SetupTokenUrlMatch {
|
||||
url: string;
|
||||
end: number;
|
||||
endLine: 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.
|
||||
* the wrapped display text. Removes each leftover OSC sequence. Replaces each
|
||||
* horizontal-move CSI sequence with one space, so a word that the login UI lays
|
||||
* out at an absolute column reads as a space-separated word. Removes each
|
||||
* remaining ANSI CSI sequence. Returns plain text. The function only normalizes
|
||||
* the input; the URL and the prompt still pass the shape validation below.
|
||||
*/
|
||||
function stripTerminalControls(text: string): string {
|
||||
return text
|
||||
.replace(OSC8_HYPERLINK_RE, "$1")
|
||||
.replace(OSC_ANY_RE, "")
|
||||
.replace(CSI_HORIZONTAL_MOVE_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.
|
||||
* Returns true when `value` is a valid `scope` value: one to eight
|
||||
* space-separated tokens, a total length of at most {@link MAX_SCOPE_LENGTH},
|
||||
* and each token a match of {@link SCOPE_TOKEN_RE}. A double space forms an empty
|
||||
* token, which fails the token pattern, so a malformed value fails closed.
|
||||
*/
|
||||
function gapWindow(text: string, start: number, maxLength: number): string {
|
||||
return text.slice(start, start + maxLength);
|
||||
function isValidScope(value: string): boolean {
|
||||
if (value.length < 1 || value.length > MAX_SCOPE_LENGTH) return false;
|
||||
const tokens = value.split(" ");
|
||||
if (tokens.length < 1 || tokens.length > MAX_SCOPE_TOKENS) return false;
|
||||
return tokens.every((token) => SCOPE_TOKEN_RE.test(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Returns true when `value` is the one accepted `redirect_uri`. The function
|
||||
* parses the decoded value on its own and pins it to {@link
|
||||
* SETUP_TOKEN_REDIRECT_URI}, with the `https:` protocol, no userinfo, no port,
|
||||
* no query, and no fragment. It never broadens to an arbitrary HTTPS URL.
|
||||
*/
|
||||
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));
|
||||
function isValidRedirectUri(value: string): boolean {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (parsed.username !== "" || parsed.password !== "") return false;
|
||||
if (parsed.port !== "") return false;
|
||||
if (parsed.search !== "" || parsed.hash !== "") return false;
|
||||
return `${parsed.origin}${parsed.pathname}` === SETUP_TOKEN_REDIRECT_URI;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
* Returns true when the query of `parsed` matches the contract. It requires each
|
||||
* required key one time with a valid decoded value. It rejects a duplicate for
|
||||
* any key. It accepts at most {@link MAX_ADDED_KEYS} added keys, each with a
|
||||
* valid name, one value, and a decoded value of at most 256 printable ASCII
|
||||
* characters. It rejects an API-key prefix in any decoded value.
|
||||
*/
|
||||
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 };
|
||||
}
|
||||
function hasValidQuery(parsed: URL): boolean {
|
||||
// Collect the values of each key, so the parser can reject a duplicate key and
|
||||
// read the one decoded value of each key.
|
||||
const byKey = new Map<string, string[]>();
|
||||
for (const [key, value] of parsed.searchParams) {
|
||||
const values = byKey.get(key);
|
||||
if (values) values.push(value);
|
||||
else byKey.set(key, [value]);
|
||||
}
|
||||
|
||||
// Reject a duplicate for any key. After this test each key holds one value.
|
||||
for (const values of byKey.values()) {
|
||||
if (values.length !== 1) return false;
|
||||
}
|
||||
|
||||
// Reject an API-key prefix in any decoded key and any decoded value. The
|
||||
// `URLSearchParams` decoder turns a percent-encoded name into its literal
|
||||
// form, so a secret-shaped value can hide in a query name (for example,
|
||||
// `sk%2Dant%2Dredacted` decodes to `sk-ant-redacted`). The parser rejects the
|
||||
// prefix in the name and in the value, before it classifies an added key. The
|
||||
// raw-candidate check stays in place as defense in depth.
|
||||
for (const [key, values] of byKey) {
|
||||
if (API_KEY_RE.test(key)) return false;
|
||||
if (API_KEY_RE.test(values[0])) return false;
|
||||
}
|
||||
|
||||
// Require each required key.
|
||||
for (const key of SETUP_TOKEN_URL_QUERY_KEYS) {
|
||||
if (!byKey.has(key)) return false;
|
||||
}
|
||||
|
||||
// Bound the added keys and validate each one.
|
||||
const addedKeys = [...byKey.keys()].filter((key) => !SETUP_TOKEN_CORE_KEY_SET.has(key));
|
||||
if (addedKeys.length > MAX_ADDED_KEYS) return false;
|
||||
for (const key of addedKeys) {
|
||||
if (!ADDED_KEY_NAME_RE.test(key)) return false;
|
||||
if (!ADDED_VALUE_RE.test(byKey.get(key)![0])) return false;
|
||||
}
|
||||
|
||||
// Validate each required value.
|
||||
if (!CLIENT_ID_RE.test(byKey.get("client_id")![0])) return false;
|
||||
if (!CODE_RE.test(byKey.get("code")![0])) return false;
|
||||
if (!CODE_CHALLENGE_RE.test(byKey.get("code_challenge")![0])) return false;
|
||||
if (byKey.get("code_challenge_method")![0] !== "S256") return false;
|
||||
if (byKey.get("response_type")![0] !== "code") return false;
|
||||
if (!STATE_RE.test(byKey.get("state")![0])) return false;
|
||||
if (!isValidScope(byKey.get("scope")![0])) return false;
|
||||
if (!isValidRedirectUri(byKey.get("redirect_uri")![0])) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `candidate` when it is a valid authorization URL, or null. The final
|
||||
* validator applies to every candidate, from an OSC 8 URI or from a plain-text
|
||||
* reassembly. It requires the length cap {@link MAX_URL_LENGTH}, an exact
|
||||
* lowercase host prefix, no API-key prefix in the raw candidate, the `https:`
|
||||
* protocol, empty userinfo, empty port, no fragment, one of the two accepted
|
||||
* origin-and-path pairs, and a valid query.
|
||||
*/
|
||||
function validateAuthUrl(candidate: string): string | null {
|
||||
if (candidate.length > MAX_URL_LENGTH) return null;
|
||||
if (!SETUP_TOKEN_AUTH_URL_PREFIXES.some((prefix) => candidate.startsWith(prefix))) return null;
|
||||
if (API_KEY_RE.test(candidate)) return null;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== "https:") return null;
|
||||
if (parsed.username !== "" || parsed.password !== "") return null;
|
||||
if (parsed.port !== "" || parsed.hash !== "") return null;
|
||||
const pair = `${parsed.origin}${parsed.pathname}`;
|
||||
if (!SETUP_TOKEN_AUTH_URLS.includes(pair as (typeof SETUP_TOKEN_AUTH_URLS)[number])) return null;
|
||||
if (!hasValidQuery(parsed)) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassembles a wrapped plain-text URL from the physical lines and validates it.
|
||||
* The parser starts at `startLine`, then joins at most {@link MAX_URL_LINES}
|
||||
* consecutive URL-character-only physical lines into at most {@link
|
||||
* MAX_URL_LENGTH} characters. It validates the joined string after each join and
|
||||
* returns the first joined string that passes {@link validateAuthUrl}. A CLI that
|
||||
* emits the whole URL on one line passes on the first join. A CLI that wraps the
|
||||
* URL passes once the parser joins the last fragment. Returns null when no bounded
|
||||
* join validates.
|
||||
*/
|
||||
function reassembleUrl(lines: string[], startLine: number): SetupTokenUrlMatch | null {
|
||||
let joined = "";
|
||||
for (let offset = 0; offset < MAX_URL_LINES && startLine + offset < lines.length; offset += 1) {
|
||||
const trimmed = lines[startLine + offset].trim();
|
||||
if (trimmed.length === 0 || !URL_CHARS_ONLY_RE.test(trimmed)) break;
|
||||
joined += trimmed;
|
||||
if (joined.length > MAX_URL_LENGTH) break;
|
||||
const url = validateAuthUrl(joined);
|
||||
if (url) return { url, endLine: startLine + offset };
|
||||
}
|
||||
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.
|
||||
* Returns the canonical browser-code prompt when `lines` holds it on a dedicated
|
||||
* line after the URL block. The parser reads the non-blank lines after the URL
|
||||
* end line. The Claude UI can emit one full URL line per wrapped display row, so
|
||||
* it repeats the same full authorization URL on a few consecutive lines. The
|
||||
* parser skips a line that repeats `url`, then binds the prompt on the first
|
||||
* non-blank, non-repeat line. That line must match the exact prompt shape.
|
||||
* Returns null when the first non-blank, non-repeat 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();
|
||||
function findBrowserCodePrompt(lines: string[], endLine: number, url: string): string | null {
|
||||
for (let i = endLine + 1; i < lines.length; i += 1) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.length === 0) continue;
|
||||
// Skip a line that repeats the full URL. Strip the trailing prose
|
||||
// punctuation first, the same way the repeated-URL comparison needs, so a
|
||||
// repeated URL with a trailing punctuation mark still matches.
|
||||
if (trimmed.replace(TRAILING_PUNCTUATION_RE, "") === url) 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.
|
||||
* Parses Claude `setup-token` login output. Removes the terminal control
|
||||
* sequences first, so colored and hyperlinked output reads the same as plain
|
||||
* output. The parser finds the URL preamble, then a physical line that starts the
|
||||
* authorization URL within {@link MAX_PREAMBLE_TO_URL_GAP} characters of the
|
||||
* preamble, then reassembles the wrapped URL and validates the final string, then
|
||||
* binds the browser-code prompt on the dedicated line after the URL block. 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 host, 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;
|
||||
|
|
@ -232,13 +400,25 @@ export function parseSetupTokenPrompt(text: string): SetupTokenPrompt | null {
|
|||
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 region after the preamble. The URL must start inside the start window.
|
||||
const region = clean.slice(afterPreamble);
|
||||
const lines = region.split("\n");
|
||||
|
||||
// Walk the physical lines and track the character offset of each line start.
|
||||
// The URL must start within MAX_PREAMBLE_TO_URL_GAP characters of the preamble.
|
||||
let offset = 0;
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const lineStart = offset;
|
||||
offset += lines[i].length + 1;
|
||||
if (lineStart > MAX_PREAMBLE_TO_URL_GAP) break;
|
||||
const trimmed = lines[i].trim();
|
||||
if (!SETUP_TOKEN_AUTH_URL_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) continue;
|
||||
const match = reassembleUrl(lines, i);
|
||||
if (!match) continue;
|
||||
const prompt = findBrowserCodePrompt(lines, match.endLine, match.url);
|
||||
if (prompt) return { url: match.url, prompt };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- The success token parser ------------------------------------------------
|
||||
|
|
@ -305,7 +485,15 @@ function singleAnchorIndex(lines: string[], anchor: string): number | null {
|
|||
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");
|
||||
// Canonicalize the record delimiter. The real terminal redraw joins the
|
||||
// before-anchor line, the token, and the after-anchor line with a bare
|
||||
// carriage return, or a CRLF pair, instead of a line feed. Map each CRLF and
|
||||
// each bare carriage return to one line feed, so each anchor line and each
|
||||
// token fragment lands on its own line. This step changes the record
|
||||
// delimiter only. It cannot add a token byte, and the interval validation
|
||||
// below still rejects partial, extra, or prose data.
|
||||
const normalized = clean.replace(/\r\n|\r/g, "\n");
|
||||
const lines = normalized.split("\n");
|
||||
|
||||
const beforeIndex = singleAnchorIndex(lines, SETUP_TOKEN_BEFORE_ANCHOR);
|
||||
const afterIndex = singleAnchorIndex(lines, SETUP_TOKEN_AFTER_ANCHOR);
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
// 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";
|
||||
"https://claude.com/cai/oauth/authorize?client_id=cid&code=abcdefgh&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&response_type=code&scope=user&state=0123456789abcdef";
|
||||
|
||||
// The rendered login output. The preamble line, the URL line, and the dedicated
|
||||
// prompt line match the Phase 2 parser contract.
|
||||
|
|
@ -97,11 +97,14 @@ async function flush(): Promise<void> {
|
|||
|
||||
describe("runSetupTokenLogin", () => {
|
||||
it("sends one login URL through onPrompt", async () => {
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT], exitCode: 0 });
|
||||
// A success stream and a resolving sink, so the run reports success after the
|
||||
// runner delivers the credential.
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 });
|
||||
const onPrompt = vi.fn();
|
||||
const result = await runSetupTokenLogin(fake.driver, {
|
||||
onPrompt,
|
||||
provideCode: async () => BROWSER_CODE,
|
||||
onCredential: async () => {},
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(result.outcome).toBe("success");
|
||||
|
|
@ -111,12 +114,15 @@ describe("runSetupTokenLogin", () => {
|
|||
});
|
||||
|
||||
it("accepts one browser code and sends it to the prompt", async () => {
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT] });
|
||||
// The stream carries the prompt and the success record. The runner submits the
|
||||
// code, then it delivers the credential and reports success after the sink.
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT] });
|
||||
const onPrompt = vi.fn();
|
||||
const provideCode = vi.fn(async () => BROWSER_CODE);
|
||||
const promise = runSetupTokenLogin(fake.driver, {
|
||||
onPrompt,
|
||||
provideCode,
|
||||
onCredential: async () => {},
|
||||
timeoutMs: 1000,
|
||||
codeSubmitSettleMs: 0,
|
||||
});
|
||||
|
|
@ -247,27 +253,34 @@ describe("runSetupTokenLogin", () => {
|
|||
expect(onPrompt).toHaveBeenCalledWith({ url: VALID_URL, prompt: SETUP_TOKEN_PROMPT });
|
||||
});
|
||||
|
||||
it("delivers no token when the success stream has no token block", async () => {
|
||||
it("fails when a clean exit has no token block, even with a sink present", async () => {
|
||||
// The stream holds only the prompt, so the token parser binds nothing. The
|
||||
// runner delivers no credential and reports credentialDelivered false.
|
||||
// runner treats a clean exit with no bound token as a failure. It never calls
|
||||
// the sink and never reports success.
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT], exitCode: 0 });
|
||||
const onCredential = vi.fn();
|
||||
const onCredential = vi.fn(async () => {});
|
||||
const result = await runSetupTokenLogin(fake.driver, {
|
||||
onPrompt: () => {},
|
||||
provideCode: async () => BROWSER_CODE,
|
||||
onCredential,
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(result.outcome).toBe("success");
|
||||
expect(result.outcome).toBe("failure");
|
||||
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 () => {
|
||||
it("delivers the de-wrapped token once through onCredential and zeros it after", async () => {
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 });
|
||||
const onCredential = vi.fn();
|
||||
// The sink copies the token value, because the runner zeros the buffer after
|
||||
// the delivery. The test reads the copy for the value and the original for the
|
||||
// zeroing proof.
|
||||
let received: string | null = null;
|
||||
const onCredential = vi.fn(async (bytes: Buffer) => {
|
||||
received = bytes.toString("utf8");
|
||||
});
|
||||
const result = await runSetupTokenLogin(fake.driver, {
|
||||
onPrompt: () => {},
|
||||
provideCode: async () => BROWSER_CODE,
|
||||
|
|
@ -277,9 +290,11 @@ describe("runSetupTokenLogin", () => {
|
|||
expect(result.outcome).toBe("success");
|
||||
expect(result.credentialDelivered).toBe(true);
|
||||
expect(onCredential).toHaveBeenCalledTimes(1);
|
||||
expect(received).toBe(FULL_TOKEN);
|
||||
// The runner zeros the token bytes after a successful delivery.
|
||||
const bytes = onCredential.mock.calls[0][0] as Buffer;
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.toString("utf8")).toBe(FULL_TOKEN);
|
||||
expect(bytes.every((byte) => byte === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the token out of every log, result, and error field", async () => {
|
||||
|
|
@ -288,7 +303,7 @@ describe("runSetupTokenLogin", () => {
|
|||
const result = await runSetupTokenLogin(fake.driver, {
|
||||
onPrompt: () => {},
|
||||
provideCode: async () => BROWSER_CODE,
|
||||
onCredential: () => {},
|
||||
onCredential: async () => {},
|
||||
timeoutMs: 1000,
|
||||
log: (line) => {
|
||||
logs.push(line);
|
||||
|
|
@ -300,19 +315,60 @@ describe("runSetupTokenLogin", () => {
|
|||
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.
|
||||
it("fails closed when no onCredential sink is present, even on a success stream", async () => {
|
||||
// The sink is mandatory for a successful run. With no sink the runner never
|
||||
// scans for the token and treats the clean exit as a failure, 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.outcome).toBe("failure");
|
||||
expect(result.credentialDelivered).toBe(false);
|
||||
});
|
||||
|
||||
it("fails and zeros the token when the sink throws synchronously", async () => {
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 });
|
||||
let seen: Buffer | null = null;
|
||||
const result = await runSetupTokenLogin(fake.driver, {
|
||||
onPrompt: () => {},
|
||||
provideCode: async () => BROWSER_CODE,
|
||||
// A synchronous throw. The runner catches it, fails closed, and never
|
||||
// reports success.
|
||||
onCredential: (bytes) => {
|
||||
seen = bytes;
|
||||
throw new Error("synchronous sink failure");
|
||||
},
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.credentialDelivered).toBe(false);
|
||||
expect(seen).not.toBeNull();
|
||||
expect((seen as unknown as Buffer).every((byte) => byte === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails and zeros the token when the sink rejects asynchronously", async () => {
|
||||
const fake = createFakeDriver({ chunks: [PROMPT_OUTPUT, SUCCESS_OUTPUT], exitCode: 0 });
|
||||
let seen: Buffer | null = null;
|
||||
const result = await runSetupTokenLogin(fake.driver, {
|
||||
onPrompt: () => {},
|
||||
provideCode: async () => BROWSER_CODE,
|
||||
// An asynchronous rejection. The runner does not swallow it: it maps the
|
||||
// rejection to a failure and never reports success.
|
||||
onCredential: async (bytes) => {
|
||||
seen = bytes;
|
||||
throw new Error("asynchronous sink failure");
|
||||
},
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
expect(result.outcome).toBe("failure");
|
||||
expect(result.credentialDelivered).toBe(false);
|
||||
expect(seen).not.toBeNull();
|
||||
expect((seen as unknown as Buffer).every((byte) => byte === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes the fixed setup-token command", () => {
|
||||
expect(CLAUDE_SETUP_TOKEN_COMMAND).toBe("claude setup-token");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,14 +19,17 @@ import {
|
|||
// 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.
|
||||
// through the in-memory `onCredential` sink. 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.
|
||||
// Fail-closed credential delivery. The `onCredential` sink is mandatory and
|
||||
// awaited. The runner binds the token from the exact success record with
|
||||
// {@link parseSetupTokenCredential}, then it awaits the sink one time. It sets
|
||||
// `credentialDelivered` true only after the sink resolves. A missing sink, a
|
||||
// success stream with no token, and a sink rejection each end the run as a
|
||||
// failure; the runner never reports success without a resolved sink. The runner
|
||||
// zeros the token bytes in a `finally` block on every path, so the secret never
|
||||
// outlives the delivery.
|
||||
|
||||
/** The fixed Claude setup-token command. The login flow needs a PTY. */
|
||||
export const CLAUDE_SETUP_TOKEN_COMMAND = "claude setup-token";
|
||||
|
|
@ -61,14 +64,6 @@ export const CODE_SUBMIT_SETTLE_MS = 150;
|
|||
*/
|
||||
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
|
||||
|
|
@ -108,11 +103,12 @@ 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.
|
||||
* the token from the success record, then it awaits this sink one time with the
|
||||
* token bytes. The sink is mandatory and it returns a promise; the runner awaits
|
||||
* that promise before it reports success. A sink rejection ends the run as a
|
||||
* failure. The runner never logs the bytes and never stores them on the result.
|
||||
*/
|
||||
export type SetupTokenCredentialSink = (authBytes: Buffer) => void | Promise<void>;
|
||||
export type SetupTokenCredentialSink = (authBytes: Buffer) => Promise<void>;
|
||||
|
||||
export type SetupTokenOutcome = "success" | "failure" | "timeout" | "cancelled";
|
||||
|
||||
|
|
@ -133,7 +129,12 @@ export interface RunSetupTokenLoginOptions {
|
|||
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. */
|
||||
/**
|
||||
* The credential sink. The runner awaits it one time with the token bytes and
|
||||
* reports success only after it resolves. The sink is mandatory for a
|
||||
* successful run: a missing sink ends the run as a failure. The option stays
|
||||
* optional so a caller can drive a fail-closed path with no sink.
|
||||
*/
|
||||
onCredential?: SetupTokenCredentialSink;
|
||||
/**
|
||||
* The settle delay in milliseconds between the code write and the terminator
|
||||
|
|
@ -256,13 +257,16 @@ export async function runSetupTokenLogin(
|
|||
let promptSurfaced = false;
|
||||
let codeSubmitted = false;
|
||||
let submitStarted = false;
|
||||
// The runner sets this true one time, only after the mandatory sink resolves.
|
||||
let credentialDelivered = false;
|
||||
// The bound token bytes. The runner binds them from the success record during
|
||||
// the stream, holds them in memory only, awaits the sink one time after a
|
||||
// clean exit, and zeros them in a `finally` block on every path. A null value
|
||||
// means the runner never bound a token.
|
||||
let tokenBytes: Buffer | null = null;
|
||||
// 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
|
||||
|
|
@ -306,24 +310,39 @@ export async function runSetupTokenLogin(
|
|||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Binds the token from the token buffer one time and holds the bytes in
|
||||
// memory. The runner scans only when a caller provides an `onCredential` sink,
|
||||
// so it never holds the secret-bearing stream without a need. It drops the
|
||||
// token buffer as soon as the parser binds the token. The runner delivers the
|
||||
// bytes later, after a clean exit, through {@link deliverCredential}.
|
||||
const captureCredential = (): void => {
|
||||
if (!SETUP_TOKEN_CREDENTIAL_RELEASE_GATE || !onCredential || credentialDelivered) return;
|
||||
if (!onCredential || tokenBytes) return;
|
||||
const token = parseSetupTokenCredential(tokenBuffer);
|
||||
if (!token) return;
|
||||
credentialDelivered = true;
|
||||
tokenBytes = Buffer.from(token, "utf8");
|
||||
tokenBuffer = "";
|
||||
const authBytes = Buffer.from(token, "utf8");
|
||||
};
|
||||
|
||||
// Awaits the mandatory sink one time with the bound token bytes and reports
|
||||
// whether the credential landed. It returns false when the sink is missing,
|
||||
// when the runner bound no token, or when the sink rejects. It does not swallow
|
||||
// a sink rejection: it maps the rejection to a false result, so the caller ends
|
||||
// the run as a failure. It zeros the token bytes in a `finally` block on every
|
||||
// path, so the secret never outlives the delivery.
|
||||
const deliverCredential = async (): Promise<boolean> => {
|
||||
const bytes = tokenBytes;
|
||||
tokenBytes = null;
|
||||
if (!bytes) return false;
|
||||
try {
|
||||
credentialPromise = Promise.resolve(onCredential(authBytes)).catch(() => {
|
||||
log("[paperclip] Setup-token login: the credential delivery step errored.");
|
||||
});
|
||||
if (!onCredential) return false;
|
||||
await onCredential(bytes);
|
||||
log("[paperclip] Setup-token login: delivered the credential to the sink.");
|
||||
return true;
|
||||
} catch {
|
||||
log("[paperclip] Setup-token login: the credential delivery step errored.");
|
||||
return false;
|
||||
} finally {
|
||||
bytes.fill(0);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -352,10 +371,10 @@ export async function runSetupTokenLogin(
|
|||
// 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;
|
||||
if (!onCredential || tokenBytes) return;
|
||||
tokenBuffer += chunk;
|
||||
captureCredential();
|
||||
if (!credentialDelivered && tokenBuffer.length > CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS) {
|
||||
if (!tokenBytes && tokenBuffer.length > CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS) {
|
||||
tokenBuffer = tokenBuffer.slice(tokenBuffer.length - CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS);
|
||||
}
|
||||
};
|
||||
|
|
@ -396,10 +415,16 @@ export async function runSetupTokenLogin(
|
|||
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;
|
||||
// The command exited cleanly. Deliver the bound token through the mandatory
|
||||
// sink and await it. The runner fails closed: a missing sink, a clean exit
|
||||
// with no bound token, and a sink rejection each end the run as a failure. It
|
||||
// reports success only after the sink resolves.
|
||||
const delivered = await deliverCredential();
|
||||
if (!delivered) {
|
||||
log("[paperclip] Setup-token login: the credential did not land; treating the run as a failure.");
|
||||
return result("failure", exitCode);
|
||||
}
|
||||
credentialDelivered = true;
|
||||
|
||||
log("[paperclip] Setup-token login command ended successfully.");
|
||||
return result("success", exitCode);
|
||||
|
|
@ -409,6 +434,14 @@ export async function runSetupTokenLogin(
|
|||
throw new Error("setup-token login failed: the sandbox login command errored.");
|
||||
} finally {
|
||||
if (signal) signal.removeEventListener("abort", onExternalAbort);
|
||||
// Zero any bound token bytes that the delivery path did not consume, so a
|
||||
// timeout, a cancellation, or an error never leaves the secret in memory. The
|
||||
// cast restores the type that the closure assignment widens away.
|
||||
const leftover = tokenBytes as Buffer | null;
|
||||
if (leftover) {
|
||||
leftover.fill(0);
|
||||
tokenBytes = null;
|
||||
}
|
||||
// Stop a pending code-input routine, then stop the child and dispose.
|
||||
controller.abort();
|
||||
await stopAndDispose(driver, log);
|
||||
|
|
|
|||
|
|
@ -75,15 +75,19 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("claude sandbox hello probe diagnostics", () => {
|
||||
it("surfaces the final result error instead of the system/init line on failure", async () => {
|
||||
it("keeps the raw failure result out of every check and routes it to the log", async () => {
|
||||
// The non-zero result event carries a marker. The check must not repeat the
|
||||
// marker, and the redacted diagnostic must reach the server log.
|
||||
const marker = "NONPATTERNMARKERfailure";
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [
|
||||
initLine,
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 404 model not found: claude-opus-4-8","session_id":"abc"}',
|
||||
`{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 404 model not found ${marker}","session_id":"abc"}`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
|
|
@ -96,9 +100,228 @@ describe("claude sandbox hello probe diagnostics", () => {
|
|||
expect(result.status).toBe("fail");
|
||||
const failed = result.checks.find((check) => check.code === "claude_hello_probe_failed");
|
||||
expect(failed).toBeTruthy();
|
||||
expect(failed?.detail).toContain("404 model not found: claude-opus-4-8");
|
||||
// The unhelpful init line must not be what we show the operator.
|
||||
expect(failed?.detail).not.toContain('"subtype":"init"');
|
||||
// The public check carries only a fixed message and hint, no raw detail.
|
||||
expect(failed?.detail).toBeUndefined();
|
||||
const checkText = JSON.stringify(result.checks);
|
||||
expect(checkText).not.toContain(marker);
|
||||
// The unhelpful init line must never reach a check either.
|
||||
expect(checkText).not.toContain('"subtype":"init"');
|
||||
// The raw diagnostic still reaches the server log.
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).toContain(marker);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps a stdout-fallback failure line out of every check", async () => {
|
||||
// The CLI dies before a result event, so the last non-init stdout line is
|
||||
// the diagnostic. The check must not repeat its marker.
|
||||
const marker = "NONPATTERNMARKERstdout";
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [initLine, `fatal: claude crashed ${marker}`].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
const failed = result.checks.find((check) => check.code === "claude_hello_probe_failed");
|
||||
expect(failed).toBeTruthy();
|
||||
expect(failed?.detail).toBeUndefined();
|
||||
const checkText = JSON.stringify(result.checks);
|
||||
expect(checkText).not.toContain(marker);
|
||||
expect(checkText).not.toContain('"subtype":"init"');
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).toContain(marker);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("never copies a credential-bearing stderr failure line into a check", async () => {
|
||||
// A verbose CLI can print a credential to stderr on failure. The check must
|
||||
// not repeat it, and the server log must redact it.
|
||||
const secret = "sk-ant-STDERRLEAK0123456789abcdef";
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: initLine,
|
||||
stderr: `fatal: request failed with token ${secret}`,
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
const checkText = JSON.stringify(result.checks);
|
||||
expect(checkText).not.toContain(secret);
|
||||
expect(checkText).not.toContain("STDERRLEAK");
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).not.toContain(secret);
|
||||
expect(loggedText).toContain("***REDACTED***");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("keeps an auth-required probe marker out of every check", async () => {
|
||||
// The auth-required stdout and stderr carry a marker. The login-required
|
||||
// checks must not repeat it, and the login gate code must stay stable.
|
||||
const marker = "NONPATTERNMARKERauthcli";
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [
|
||||
initLine,
|
||||
`{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Please run \`claude login\` ${marker}","session_id":"abc"}`,
|
||||
].join("\n"),
|
||||
stderr: `Please run claude login ${marker}`,
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
// The login gate code stays stable so the user interface can offer login.
|
||||
expect(result.checks.some((check) => check.code === "adapter_auth_missing")).toBe(true);
|
||||
const checkText = JSON.stringify(result.checks);
|
||||
expect(checkText).not.toContain(marker);
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).toContain(marker);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("classifies an invalid or expired token as adapter_auth_missing without leaking the token", async () => {
|
||||
// Grounded on the real Claude CLI output for CLAUDE_CODE_OAUTH_TOKEN=invalid.
|
||||
// The probe exits non-zero and the result event reports a 401 authentication
|
||||
// failure with an "Invalid bearer token" message. A synthetic bearer marker
|
||||
// rides along on a retry line, so the test proves the raw text never reaches
|
||||
// a check.
|
||||
const marker = "SUPERSECRETbearerMARKERcli";
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [
|
||||
initLine,
|
||||
`{"type":"system","subtype":"api_retry","attempt":1,"error_status":401,"error":"authentication_failed: bearer ${marker} is invalid","session_id":"abc"}`,
|
||||
'{"type":"result","subtype":"success","is_error":true,"api_error_status":401,"error":"authentication_failed","result":"Failed to authenticate. API Error: 401 Invalid bearer token","session_id":"abc"}',
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
// An auth failure returns the canonical login gate code, so the user
|
||||
// interface can offer login.
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === "adapter_auth_missing")).toBe(true);
|
||||
// The raw probe text, including the bearer marker, never reaches a check.
|
||||
expect(JSON.stringify(result.checks)).not.toContain(marker);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not flag a healthy probe whose assistant text repeats a token phrase", async () => {
|
||||
// A healthy run prints an auth phrase in its answer text. The parsed result
|
||||
// is a success, so the probe stays healthy and offers no login gate.
|
||||
probeResult.value = {
|
||||
exitCode: 0,
|
||||
stdout: [
|
||||
initLine,
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello — authentication_failed means an invalid bearer token"}]},"session_id":"abc"}',
|
||||
'{"type":"result","subtype":"success","is_error":false,"result":"hello","session_id":"abc"}',
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "adapter_auth_missing")).toBe(false);
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(false);
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_passed")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a transient failure with an assistant token phrase off the login gate", async () => {
|
||||
// The probe fails on a 529 overload. The auth phrase appears only in the raw
|
||||
// stdout assistant event, not the parsed result, so the run stays transient
|
||||
// and never surfaces the login gate.
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [
|
||||
initLine,
|
||||
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"authentication_failed: the bearer token is invalid"}]},"session_id":"abc"}',
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"result":"API Error: 529 overloaded_error","session_id":"abc"}',
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "adapter_auth_missing")).toBe(false);
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(false);
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_transient_upstream")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps an unexpected successful summary out of every check", async () => {
|
||||
// The probe exits 0 but does not return `hello`. The unexpected summary is
|
||||
// untrusted output. The check must not repeat its marker.
|
||||
const marker = "NONPATTERNMARKERunexpected";
|
||||
probeResult.value = {
|
||||
exitCode: 0,
|
||||
stdout: [
|
||||
initLine,
|
||||
`{"type":"result","subtype":"success","is_error":false,"result":"Here is something else ${marker}","session_id":"abc"}`,
|
||||
].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
const unexpected = result.checks.find(
|
||||
(check) => check.code === "claude_hello_probe_unexpected_output",
|
||||
);
|
||||
expect(unexpected).toBeTruthy();
|
||||
expect(unexpected?.detail).toBeUndefined();
|
||||
const checkText = JSON.stringify(result.checks);
|
||||
expect(checkText).not.toContain(marker);
|
||||
const loggedText = JSON.stringify(warnSpy.mock.calls);
|
||||
expect(loggedText).toContain(marker);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("classifies subscription usage-limit failures as a usage-limited warning, not a hard fail", async () => {
|
||||
|
|
@ -147,26 +370,7 @@ describe("claude sandbox hello probe diagnostics", () => {
|
|||
expect(result.checks.some((check) => check.code === "claude_hello_probe_failed")).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to the last stdout line when no result event is emitted", async () => {
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: [initLine, "fatal: claude crashed unexpectedly"].join("\n"),
|
||||
stderr: "",
|
||||
};
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
const failed = result.checks.find((check) => check.code === "claude_hello_probe_failed");
|
||||
expect(failed?.detail).toContain("claude crashed unexpectedly");
|
||||
});
|
||||
|
||||
it("does not show the system/init event when it is the only stdout line", async () => {
|
||||
it("keeps the failed check free of a detail when only the system/init line is present", async () => {
|
||||
probeResult.value = {
|
||||
exitCode: 1,
|
||||
stdout: initLine,
|
||||
|
|
@ -183,6 +387,7 @@ describe("claude sandbox hello probe diagnostics", () => {
|
|||
|
||||
const failed = result.checks.find((check) => check.code === "claude_hello_probe_failed");
|
||||
expect(failed?.detail).toBeUndefined();
|
||||
expect(JSON.stringify(result.checks)).not.toContain('"subtype":"init"');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
const {
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
maybeRunSandboxInstallCommand,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
probeResult,
|
||||
} = vi.hoisted(() => {
|
||||
const probeResult: { value: { exitCode: number; stdout: string; stderr: string } } = {
|
||||
value: { exitCode: 1, stdout: "", stderr: "" },
|
||||
};
|
||||
return {
|
||||
probeResult,
|
||||
ensureAdapterExecutionTargetDirectory: vi.fn(async () => {}),
|
||||
ensureAdapterExecutionTargetCommandResolvable: vi.fn(async () => {}),
|
||||
maybeRunSandboxInstallCommand: vi.fn(async () => null),
|
||||
runAdapterExecutionTargetProcess: vi.fn(async () => ({
|
||||
exitCode: probeResult.value.exitCode,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: probeResult.value.stdout,
|
||||
stderr: probeResult.value.stderr,
|
||||
pid: 123,
|
||||
startedAt: new Date().toISOString(),
|
||||
})),
|
||||
describeAdapterExecutionTarget: vi.fn(() => "Daytona"),
|
||||
resolveAdapterExecutionTargetCwd: vi.fn(() => "/home/daytona/paperclip-workspace"),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
||||
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
||||
"@paperclipai/adapter-utils/execution-target",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
maybeRunSandboxInstallCommand,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
};
|
||||
});
|
||||
|
||||
import { testEnvironment } from "./test.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
|
||||
const sandboxTarget: AdapterExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "daytona",
|
||||
remoteCwd: "/home/daytona/paperclip-workspace",
|
||||
runner: {
|
||||
execute: async () => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
pid: null,
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const sshTarget: AdapterExecutionTarget = {
|
||||
kind: "remote",
|
||||
transport: "ssh",
|
||||
remoteCwd: "/home/agent/paperclip-workspace",
|
||||
spec: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "agent",
|
||||
remoteCwd: "/home/agent/paperclip-workspace",
|
||||
remoteWorkspacePath: "/home/agent/paperclip-workspace",
|
||||
privateKey: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
|
||||
const initLine =
|
||||
'{"type":"system","subtype":"init","cwd":"/home/daytona/paperclip-workspace","session_id":"abc","tools":["Bash","Read"]}';
|
||||
|
||||
const loginRequiredStdout = [
|
||||
initLine,
|
||||
'{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Please run `claude login` to authenticate.","session_id":"abc"}',
|
||||
].join("\n");
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("claude sandbox auth-missing check", () => {
|
||||
it("emits the canonical adapter_auth_missing check when a sandbox hello probe reports missing auth", async () => {
|
||||
// The sandbox has no ready login, so the hello probe returns a
|
||||
// login-required result. The Test must emit the neutral canonical check
|
||||
// code. The user interface reads this code to decide login eligibility; it
|
||||
// does not parse the message text or the top-level status.
|
||||
probeResult.value = { exitCode: 1, stdout: loginRequiredStdout, stderr: "" };
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sandboxTarget,
|
||||
environmentName: "Daytona",
|
||||
});
|
||||
|
||||
// A missing-auth probe is a warning, not a failure, so the environment stays
|
||||
// testable and the user interface can offer login.
|
||||
expect(result.status).toBe("warn");
|
||||
expect(result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(true);
|
||||
// The descriptive probe check stays, so existing diagnostics keep working.
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit adapter_auth_missing when a non-sandbox remote probe reports missing auth", async () => {
|
||||
// Only a sandbox target can start login. A non-sandbox remote target keeps
|
||||
// the descriptive probe check but does not carry the neutral login code.
|
||||
probeResult.value = { exitCode: 1, stdout: loginRequiredStdout, stderr: "" };
|
||||
|
||||
const result = await testEnvironment({
|
||||
companyId: "company-1",
|
||||
adapterType: "claude_local",
|
||||
config: { engine: "cli", command: "claude" },
|
||||
executionTarget: sshTarget,
|
||||
environmentName: "Remote host",
|
||||
});
|
||||
|
||||
expect(result.checks.some((check) => check.code === "claude_hello_probe_auth_required")).toBe(true);
|
||||
expect(result.checks.some((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
AdapterEnvironmentTestContext,
|
||||
|
|
@ -18,12 +15,9 @@ import {
|
|||
import {
|
||||
ensureAdapterExecutionTargetCommandResolvable,
|
||||
ensureAdapterExecutionTargetDirectory,
|
||||
maybeRunSandboxInstallCommand,
|
||||
prepareAdapterExecutionTargetRuntime,
|
||||
runAdapterExecutionTargetProcess,
|
||||
describeAdapterExecutionTarget,
|
||||
resolveAdapterExecutionTargetCwd,
|
||||
adapterExecutionTargetUsesManagedHome,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
import {
|
||||
describeClaudeFailure,
|
||||
|
|
@ -35,9 +29,14 @@ import {
|
|||
import { claudeCommandLooksLike, claudeCommandSupportsEffortFlag } from "./cli-capabilities.js";
|
||||
import { isBedrockModelId } from "./models.js";
|
||||
import { buildClaudeProbePermissionArgs } from "./permissions.js";
|
||||
import { materializeRemoteClaudeConfig, prepareClaudeConfigSeed } from "./claude-config.js";
|
||||
import { prepareSandboxClaudeProbeRuntime } from "./claude-config.js";
|
||||
import { SANDBOX_INSTALL_COMMAND } from "../index.js";
|
||||
import { resolveClaudeExecutionEngineForRun, testClaudeAcpEnvironment } from "./acp.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
|
||||
import {
|
||||
buildClaudeLoginRequiredHint,
|
||||
logRedactedSandboxProbeDiagnostic,
|
||||
} from "./probe-diagnostics.js";
|
||||
|
||||
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
|
|
@ -74,11 +73,6 @@ function lastNonInitStdoutLine(text: string): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
function truncateDetail(value: string, max = 240): string {
|
||||
const clean = value.replace(/\s+/g, " ").trim();
|
||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
||||
}
|
||||
|
||||
function summarizeProbeDetail(stdout: string, stderr: string): string | null {
|
||||
const raw = firstNonEmptyLine(stderr) || lastNonInitStdoutLine(stdout);
|
||||
if (!raw) return null;
|
||||
|
|
@ -152,77 +146,20 @@ export async function testEnvironment(
|
|||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
const installCheck = await maybeRunSandboxInstallCommand({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "claude",
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
env,
|
||||
});
|
||||
if (installCheck) checks.push(installCheck);
|
||||
const hasExplicitClaudeConfigDir = isNonEmpty(env.CLAUDE_CONFIG_DIR);
|
||||
if (targetIsRemote && adapterExecutionTargetUsesManagedHome(target) && !hasExplicitClaudeConfigDir) {
|
||||
let tempWorkspaceDir: string | null = null;
|
||||
let preparedRuntime: Awaited<ReturnType<typeof prepareAdapterExecutionTargetRuntime>> | null = null;
|
||||
try {
|
||||
const seedDir = await prepareClaudeConfigSeed(process.env, async () => {}, ctx.companyId);
|
||||
const managedRemoteCwd = target?.kind === "remote" ? target.remoteCwd : cwd;
|
||||
tempWorkspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-envtest-workspace-"));
|
||||
preparedRuntime = await prepareAdapterExecutionTargetRuntime({
|
||||
runId,
|
||||
target,
|
||||
adapterKey: "claude",
|
||||
workspaceLocalDir: tempWorkspaceDir,
|
||||
workspaceRemoteDir: managedRemoteCwd,
|
||||
timeoutSec: Math.max(1, asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45)),
|
||||
assets: [
|
||||
{
|
||||
key: "config-seed",
|
||||
localDir: seedDir,
|
||||
followSymlinks: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const runtimeRootDir =
|
||||
preparedRuntime.runtimeRootDir ?? path.posix.join(managedRemoteCwd, ".paperclip-runtime", "claude");
|
||||
const remoteClaudeConfigSeedDir =
|
||||
preparedRuntime.assetDirs["config-seed"] ?? path.posix.join(runtimeRootDir, "config-seed");
|
||||
const remoteClaudeConfigDir = path.posix.join(runtimeRootDir, "config");
|
||||
env.CLAUDE_CONFIG_DIR = remoteClaudeConfigDir;
|
||||
await materializeRemoteClaudeConfig({
|
||||
runId,
|
||||
target,
|
||||
remoteClaudeConfigDir,
|
||||
remoteClaudeConfigSeedDir,
|
||||
options: {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec: Math.max(15, asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45)),
|
||||
graceSec: 5,
|
||||
onLog: async () => {},
|
||||
},
|
||||
});
|
||||
checks.push({
|
||||
code: "claude_managed_config_dir",
|
||||
level: "info",
|
||||
message: "Sandbox probe is using Paperclip-managed Claude config materialization.",
|
||||
detail: remoteClaudeConfigDir,
|
||||
});
|
||||
} catch (err) {
|
||||
checks.push({
|
||||
code: "claude_managed_config_dir_failed",
|
||||
level: "error",
|
||||
message: "Could not materialize Paperclip-managed Claude config for the sandbox probe.",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
await preparedRuntime?.restoreWorkspace().catch(() => undefined);
|
||||
if (tempWorkspaceDir) {
|
||||
await fs.rm(tempWorkspaceDir, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
checks.push(
|
||||
...(await prepareSandboxClaudeProbeRuntime({
|
||||
runId,
|
||||
target,
|
||||
cwd,
|
||||
companyId: ctx.companyId,
|
||||
env,
|
||||
installCommand: SANDBOX_INSTALL_COMMAND,
|
||||
detectCommand: command,
|
||||
targetIsRemote,
|
||||
targetIsSandbox,
|
||||
helloProbeTimeoutSec: asNumber(config.helloProbeTimeoutSec, targetIsSandbox ? 90 : 45),
|
||||
})),
|
||||
);
|
||||
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
||||
try {
|
||||
await ensureAdapterExecutionTargetCommandResolvable(command, target, cwd, runtimeEnv);
|
||||
|
|
@ -397,7 +334,6 @@ export async function testEnvironment(
|
|||
stdout: probe.stdout,
|
||||
stderr: probe.stderr,
|
||||
});
|
||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr);
|
||||
|
||||
if (probe.timedOut) {
|
||||
checks.push({
|
||||
|
|
@ -407,25 +343,46 @@ export async function testEnvironment(
|
|||
hint: "Retry the probe. If this persists, verify Claude can run `Respond with hello` from this directory manually.",
|
||||
});
|
||||
} else if (loginMeta.requiresLogin) {
|
||||
// The raw probe output is untrusted. Route it to the log-only boundary
|
||||
// and return only a fixed public message and a safe hint.
|
||||
logRedactedSandboxProbeDiagnostic(
|
||||
"Claude CLI hello probe reported login required",
|
||||
summarizeProbeDetail(probe.stdout, probe.stderr),
|
||||
);
|
||||
checks.push({
|
||||
code: "claude_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Claude CLI is installed, but login is required.",
|
||||
...(detail ? { detail } : {}),
|
||||
hint: loginMeta.loginUrl
|
||||
? `Run \`claude login\` and complete sign-in at ${loginMeta.loginUrl}, then retry.`
|
||||
: "Run `claude login` in this environment, then retry the probe.",
|
||||
hint: buildClaudeLoginRequiredHint(loginMeta.loginUrl),
|
||||
});
|
||||
if (targetIsSandbox) {
|
||||
// Emit the neutral canonical check so the user interface can decide
|
||||
// login eligibility from a stable code. The user interface does not
|
||||
// read the message text or the top-level status.
|
||||
checks.push({
|
||||
code: ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
level: "warn",
|
||||
message: "The sandbox has no ready authentication for this adapter.",
|
||||
hint: "Provide credentials for this adapter, or start login in the sandbox.",
|
||||
});
|
||||
}
|
||||
} else if ((probe.exitCode ?? 1) === 0) {
|
||||
const summary = parsedStream.summary.trim();
|
||||
const hasHello = /\bhello\b/i.test(summary);
|
||||
if (!hasHello) {
|
||||
// The unexpected summary is untrusted probe output. Route it to the
|
||||
// log-only boundary and keep the check text fixed.
|
||||
logRedactedSandboxProbeDiagnostic(
|
||||
"Claude CLI hello probe returned unexpected output",
|
||||
summary,
|
||||
);
|
||||
}
|
||||
checks.push({
|
||||
code: hasHello ? "claude_hello_probe_passed" : "claude_hello_probe_unexpected_output",
|
||||
level: hasHello ? "info" : "warn",
|
||||
message: hasHello
|
||||
? "Claude hello probe succeeded."
|
||||
: "Claude probe ran but did not return `hello` as expected.",
|
||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
||||
...(hasHello
|
||||
? {}
|
||||
: {
|
||||
|
|
@ -433,20 +390,20 @@ export async function testEnvironment(
|
|||
}),
|
||||
});
|
||||
} else {
|
||||
// Surface the actual failure instead of the leading stream-json
|
||||
// `system/init` line: the real error lives in the final `result`
|
||||
// event (parsed) or, when the CLI dies before emitting one, the last
|
||||
// non-init stdout line — never the first one `summarizeProbeDetail`
|
||||
// returns.
|
||||
// Compose the richest raw diagnostic for the log. The real error lives
|
||||
// in the final `result` event (parsed) or, when the CLI dies before it
|
||||
// emits one, the last non-init stdout line — never the first line that
|
||||
// `summarizeProbeDetail` returns.
|
||||
const stdoutFallback = lastNonInitStdoutLine(probe.stdout);
|
||||
const failureDetail =
|
||||
(parsed ? describeClaudeFailure(parsed) : null) ||
|
||||
(firstNonEmptyLine(probe.stderr)
|
||||
? truncateDetail(firstNonEmptyLine(probe.stderr))
|
||||
: "") ||
|
||||
(stdoutFallback ? truncateDetail(stdoutFallback) : "") ||
|
||||
detail ||
|
||||
firstNonEmptyLine(probe.stderr) ||
|
||||
stdoutFallback ||
|
||||
summarizeProbeDetail(probe.stdout, probe.stderr) ||
|
||||
"";
|
||||
// The failure diagnostic is untrusted. Route it to the log-only
|
||||
// boundary and return only a fixed public message and hint.
|
||||
logRedactedSandboxProbeDiagnostic("Claude CLI hello probe failed", failureDetail);
|
||||
// Provider-quota exhaustion (usage/session limit) is classified
|
||||
// separately from generic transient upstream errors: auth works, the
|
||||
// subscription's usage window is just spent. Surface it as its own
|
||||
|
|
@ -467,7 +424,6 @@ export async function testEnvironment(
|
|||
code: "claude_hello_probe_usage_limited",
|
||||
level: "warn",
|
||||
message: "Claude hello probe hit the subscription usage limit.",
|
||||
...(failureDetail ? { detail: failureDetail } : {}),
|
||||
hint: "Authentication works; the account's usage window is exhausted. Wait for the limit to reset and re-run Test.",
|
||||
}
|
||||
: transient
|
||||
|
|
@ -475,14 +431,12 @@ export async function testEnvironment(
|
|||
code: "claude_hello_probe_transient_upstream",
|
||||
level: "warn",
|
||||
message: "Claude hello probe hit a transient upstream error (rate limit or overload).",
|
||||
...(failureDetail ? { detail: failureDetail } : {}),
|
||||
hint: "This is usually temporary. Wait a moment and re-run Test.",
|
||||
}
|
||||
: {
|
||||
code: "claude_hello_probe_failed",
|
||||
level: "error",
|
||||
message: "Claude hello probe failed.",
|
||||
...(failureDetail ? { detail: failureDetail } : {}),
|
||||
hint: `Exit code ${probe.exitCode ?? "unknown"}. Run \`claude --print - --output-format stream-json --verbose\` manually in this directory and prompt \`Respond with hello\` to debug.`,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import fs from "node:fs";
|
||||
import { getTableConfig } from "drizzle-orm/pg-core";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudeSetupTokenSessions } from "./schema/claude_setup_token_sessions.js";
|
||||
|
||||
type PgTable = Parameters<typeof getTableConfig>[0];
|
||||
|
||||
function findIndex(table: PgTable, indexName: string) {
|
||||
return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
|
||||
}
|
||||
|
||||
function indexColumns(table: PgTable, indexName: string): string[] {
|
||||
const index = findIndex(table, indexName);
|
||||
if (!index) return [];
|
||||
return index.config.columns.map((column) => (column as { name: string }).name);
|
||||
}
|
||||
|
||||
function column(table: PgTable, columnName: string) {
|
||||
return getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
|
||||
}
|
||||
|
||||
function columnIsNotNull(table: PgTable, columnName: string): boolean {
|
||||
return column(table, columnName)?.notNull ?? false;
|
||||
}
|
||||
|
||||
const ACTIVE_INDEX = "claude_setup_token_sessions_active_uq";
|
||||
|
||||
// The migration that creates the table. The test locates it by content, so a
|
||||
// later re-generation with a different name does not break the test.
|
||||
function activeIndexMigrationSql(): string {
|
||||
const migrationsDir = new URL("./migrations/", import.meta.url);
|
||||
const files = fs.readdirSync(migrationsDir).filter((name) => name.endsWith(".sql"));
|
||||
for (const name of files) {
|
||||
const content = fs.readFileSync(new URL(name, migrationsDir), "utf8");
|
||||
if (content.includes(ACTIVE_INDEX)) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
throw new Error(`no migration creates ${ACTIVE_INDEX}`);
|
||||
}
|
||||
|
||||
describe("claude setup token sessions schema", () => {
|
||||
it("keeps the scope columns and the deadline non-null", () => {
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "session_id")).toBe(true);
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "company_id")).toBe(true);
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "owner_user_id")).toBe(true);
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "adapter_type")).toBe(true);
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "environment_id")).toBe(true);
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "state")).toBe(true);
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "deadline_at")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the claim marker and the lease reference nullable", () => {
|
||||
// A row starts with no claim consumption and no lease. The service fills
|
||||
// `bound_at` only when the create path consumes the stored claim.
|
||||
expect(column(claudeSetupTokenSessions, "bound_at")).toBeDefined();
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "bound_at")).toBe(false);
|
||||
expect(column(claudeSetupTokenSessions, "lease_id")).toBeDefined();
|
||||
expect(columnIsNotNull(claudeSetupTokenSessions, "lease_id")).toBe(false);
|
||||
});
|
||||
|
||||
it("references the company and the environment", () => {
|
||||
const foreignKeys = getTableConfig(claudeSetupTokenSessions).foreignKeys;
|
||||
const referencedTables = foreignKeys.map((fk) => fk.reference().foreignTable);
|
||||
const referencedNames = referencedTables.map(
|
||||
(table) => getTableConfig(table as PgTable).name,
|
||||
);
|
||||
expect(referencedNames).toContain("companies");
|
||||
expect(referencedNames).toContain("environments");
|
||||
});
|
||||
|
||||
it("holds no secret-bearing column", () => {
|
||||
// The durable store keeps only ids, the state, and the deadlines. It never
|
||||
// holds the login URL, the browser code, the token, or a process chunk.
|
||||
const names = getTableConfig(claudeSetupTokenSessions).columns.map((c) => c.name);
|
||||
const forbidden = ["token", "code", "url", "secret", "credential", "prompt", "output"];
|
||||
for (const name of names) {
|
||||
for (const needle of forbidden) {
|
||||
expect(name.includes(needle)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes the active session over the full owner scope", () => {
|
||||
const active = findIndex(claudeSetupTokenSessions, ACTIVE_INDEX);
|
||||
expect(active).toBeDefined();
|
||||
expect(active?.config.unique).toBe(true);
|
||||
expect(indexColumns(claudeSetupTokenSessions, ACTIVE_INDEX)).toEqual([
|
||||
"company_id",
|
||||
"owner_user_id",
|
||||
"adapter_type",
|
||||
"environment_id",
|
||||
]);
|
||||
// The active index is partial; the where clause is present.
|
||||
expect(active?.config.where).toBeDefined();
|
||||
});
|
||||
|
||||
it("scopes the active partial unique index to the active states only", () => {
|
||||
const sql = activeIndexMigrationSql();
|
||||
expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`);
|
||||
expect(sql).toContain('("company_id","owner_user_id","adapter_type","environment_id")');
|
||||
// The active states are in the predicate.
|
||||
expect(sql).toContain("'starting'");
|
||||
expect(sql).toContain("'awaiting_code'");
|
||||
expect(sql).toContain("'submitting'");
|
||||
expect(sql).toContain("'persisting'");
|
||||
// The stored state and the terminal states stay out of the predicate.
|
||||
const predicate = sql.slice(sql.indexOf(ACTIVE_INDEX));
|
||||
const whereClause = predicate.slice(predicate.indexOf("WHERE"), predicate.indexOf(";"));
|
||||
expect(whereClause).not.toContain("'stored'");
|
||||
expect(whereClause).not.toContain("'completed'");
|
||||
expect(whereClause).not.toContain("'failed'");
|
||||
expect(whereClause).not.toContain("'timed_out'");
|
||||
expect(whereClause).not.toContain("'cancelled'");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
CREATE TABLE "claude_setup_token_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" text NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"owner_user_id" text NOT NULL,
|
||||
"adapter_type" text NOT NULL,
|
||||
"environment_id" uuid NOT NULL,
|
||||
"lease_id" text,
|
||||
"state" text DEFAULT 'starting' NOT NULL,
|
||||
"deadline_at" timestamp with time zone NOT NULL,
|
||||
"bound_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "claude_setup_token_sessions" ADD CONSTRAINT "claude_setup_token_sessions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "claude_setup_token_sessions" ADD CONSTRAINT "claude_setup_token_sessions_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "claude_setup_token_sessions_active_uq" ON "claude_setup_token_sessions" USING btree ("company_id","owner_user_id","adapter_type","environment_id") WHERE "claude_setup_token_sessions"."state" IN ('starting', 'awaiting_code', 'submitting', 'persisting');--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "claude_setup_token_sessions_session_id_uq" ON "claude_setup_token_sessions" USING btree ("session_id");--> statement-breakpoint
|
||||
CREATE INDEX "claude_setup_token_sessions_deadline_idx" ON "claude_setup_token_sessions" USING btree ("deadline_at");
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
ALTER TABLE "environment_leases" DROP CONSTRAINT "environment_leases_environment_id_environments_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "environment_leases" ALTER COLUMN "environment_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "environment_leases" ADD CONSTRAINT "environment_leases_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -1534,6 +1534,20 @@
|
|||
"when": 1786711898731,
|
||||
"tag": "0220_execution_workspace_runtime_leases",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 221,
|
||||
"version": "7",
|
||||
"when": 1786983881320,
|
||||
"tag": "0221_swift_justin_hammer",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 222,
|
||||
"version": "7",
|
||||
"when": 1786983999395,
|
||||
"tag": "0222_orphan_cleanup_env_reference_survives_delete",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
|
||||
import type { AgentAdapterType } from "@paperclipai/shared";
|
||||
import { companies } from "./companies.js";
|
||||
import { environments } from "./environments.js";
|
||||
|
||||
/**
|
||||
* The durable state of one Claude setup-token cleanup record. The store never
|
||||
* reuses `adapter_auth_sessions`; this table is a dedicated, non-secret cleanup
|
||||
* store. The active states hold a live login. The `stored` state marks a session
|
||||
* that wrote the owner-bound secret and now persists as a one-time claim. The
|
||||
* create path consumes the claim by setting `bound_at`. The reaper removes the
|
||||
* row after `deadline_at`. The terminal states end the login.
|
||||
*/
|
||||
export type ClaudeSetupTokenSessionState =
|
||||
| "starting"
|
||||
| "awaiting_code"
|
||||
| "submitting"
|
||||
| "persisting"
|
||||
| "stored"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "timed_out"
|
||||
| "cancelled";
|
||||
|
||||
// The active states hold the company credential slot. The partial unique index
|
||||
// applies to these states only. It does not include `stored` or a terminal
|
||||
// state, so a stored claim and a terminal record do not block a new login.
|
||||
export const CLAUDE_SETUP_TOKEN_ACTIVE_STATES = [
|
||||
"starting",
|
||||
"awaiting_code",
|
||||
"submitting",
|
||||
"persisting",
|
||||
] as const satisfies readonly ClaudeSetupTokenSessionState[];
|
||||
|
||||
// The durable store for one Claude setup-token cleanup record. One row tracks
|
||||
// one login for one owner, one adapter, and one environment. The row keeps only
|
||||
// ids, the state, the sandbox lease reference, and the deadlines. The row never
|
||||
// holds the login URL, the browser code, the token, or a process chunk.
|
||||
export const claudeSetupTokenSessions = pgTable(
|
||||
"claude_setup_token_sessions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// The opaque service session id. The store keys every update by this id.
|
||||
sessionId: text("session_id").notNull(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
// The immutable owner principal. The service sets this column one time at
|
||||
// create and never updates it.
|
||||
ownerUserId: text("owner_user_id").notNull(),
|
||||
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
|
||||
environmentId: uuid("environment_id")
|
||||
.notNull()
|
||||
.references(() => environments.id, { onDelete: "cascade" }),
|
||||
// The provider lease reference for the sandbox. The reaper reads it to
|
||||
// release a lease that a crash or a failure left behind. It is not a secret.
|
||||
leaseId: text("lease_id"),
|
||||
state: text("state").$type<ClaudeSetupTokenSessionState>().notNull().default("starting"),
|
||||
// The login deadline. The reaper removes the row after this time. The
|
||||
// claim-consumption write also checks this column with `clock_timestamp()`.
|
||||
deadlineAt: timestamp("deadline_at", { withTimezone: true }).notNull(),
|
||||
// The claim-consumption marker. It is null while the stored claim is live.
|
||||
// The create path sets it one time when it consumes the claim. A non-null
|
||||
// value marks a consumed claim, so the reaper removes the row.
|
||||
boundAt: timestamp("bound_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
// Serialize the active login on the full owner scope. Only one active
|
||||
// session can run per company, owner, adapter, and environment. The index
|
||||
// applies to the active states only; it excludes `stored` and every terminal
|
||||
// state, so a stored claim and a terminal record never block a new login.
|
||||
activeUq: uniqueIndex("claude_setup_token_sessions_active_uq")
|
||||
.on(table.companyId, table.ownerUserId, table.adapterType, table.environmentId)
|
||||
.where(sql`${table.state} IN ('starting', 'awaiting_code', 'submitting', 'persisting')`),
|
||||
// The store keys every update by the opaque session id, so it stays unique.
|
||||
sessionIdUq: uniqueIndex("claude_setup_token_sessions_session_id_uq").on(table.sessionId),
|
||||
// The reaper scans by the deadline to remove an expired record.
|
||||
deadlineIdx: index("claude_setup_token_sessions_deadline_idx").on(table.deadlineAt),
|
||||
}),
|
||||
);
|
||||
|
|
@ -10,7 +10,14 @@ export const environmentLeases = pgTable(
|
|||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
environmentId: uuid("environment_id").notNull().references(() => environments.id, { onDelete: "cascade" }),
|
||||
// A lease-less orphan sandbox is recorded here as a `pending_cleanup` row.
|
||||
// That row is the only durable teardown reference the cleanup sweep has for
|
||||
// the remote sandbox. So the environment reference must survive an
|
||||
// environment delete: `on delete set null` keeps the row, and the sweep
|
||||
// tears the orphan down from the row's immutable provider metadata. The
|
||||
// column is nullable so an orphan the acquire records after a concurrent
|
||||
// environment delete still persists with a null reference.
|
||||
environmentId: uuid("environment_id").references(() => environments.id, { onDelete: "set null" }),
|
||||
executionWorkspaceId: uuid("execution_workspace_id").references(() => executionWorkspaces.id, { onDelete: "set null" }),
|
||||
issueId: uuid("issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ export { environmentLeases } from "./environment_leases.js";
|
|||
export { environmentCustomImageTemplates } from "./environment_custom_image_templates.js";
|
||||
export { environmentCustomImageSetupSessions } from "./environment_custom_image_setup_sessions.js";
|
||||
export { adapterAuthSessions } from "./adapter_auth_sessions.js";
|
||||
export {
|
||||
claudeSetupTokenSessions,
|
||||
CLAUDE_SETUP_TOKEN_ACTIVE_STATES,
|
||||
type ClaudeSetupTokenSessionState,
|
||||
} from "./claude_setup_token_sessions.js";
|
||||
export { workspaceOperations } from "./workspace_operations.js";
|
||||
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
|
||||
const PLUGIN_VERSION = "0.1.1";
|
||||
// 0.1.2 adds `supportsSetupTokenLogin` to the driver. The version bump makes
|
||||
// the bundled-plugin boot reconcile refresh the persisted manifest for an
|
||||
// existing install, so the Claude setup-token login capability propagates.
|
||||
const PLUGIN_VERSION = "0.1.2";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
|
|
@ -41,6 +44,10 @@ const manifest: PaperclipPluginManifestV1 = {
|
|||
},
|
||||
templateIdentityPaths: ["apiUrl"],
|
||||
supportsTemplateDelete: true,
|
||||
// Daytona hosts the Claude setup-token login on a real pseudo-terminal.
|
||||
// It is the only bundled provider that implements the setup-token
|
||||
// pseudo-terminal methods, so it advertises the capability.
|
||||
supportsSetupTokenLogin: true,
|
||||
configSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ function createMockSandbox(overrides: {
|
|||
state?: string;
|
||||
recoverable?: boolean;
|
||||
workDir?: string;
|
||||
autoDestroyAt?: string | null;
|
||||
} = {}) {
|
||||
return {
|
||||
id: overrides.id ?? "sandbox-123",
|
||||
|
|
@ -53,6 +54,9 @@ function createMockSandbox(overrides: {
|
|||
recoverable: overrides.recoverable ?? false,
|
||||
target: "us",
|
||||
errorReason: null,
|
||||
// A configured provider TTL populates `autoDestroyAt` after `setTtl` +
|
||||
// `refreshData`. The default mock leaves it unset (no TTL configured).
|
||||
autoDestroyAt: overrides.autoDestroyAt ?? undefined,
|
||||
getWorkDir: vi.fn().mockResolvedValue(overrides.workDir ?? "/home/daytona"),
|
||||
getUserHomeDir: vi.fn().mockResolvedValue("/home/daytona"),
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -65,6 +69,7 @@ function createMockSandbox(overrides: {
|
|||
resize: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
archive: vi.fn().mockResolvedValue(undefined),
|
||||
setTtl: vi.fn().mockResolvedValue(undefined),
|
||||
setAutoDeleteInterval: vi.fn().mockResolvedValue(undefined),
|
||||
createSshAccess: vi.fn().mockResolvedValue({
|
||||
token: "ssh-token-secret",
|
||||
|
|
@ -344,6 +349,71 @@ describe("Daytona sandbox provider plugin", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not configure a provider ttl when the acquire carries no requested expiry", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const sandbox = createMockSandbox();
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: { image: "node:20", timeoutMs: 300000, reuseLease: false },
|
||||
});
|
||||
|
||||
// A generic caller keeps the current behavior: no provider ttl, no expiry.
|
||||
expect(sandbox.setTtl).not.toHaveBeenCalled();
|
||||
expect(lease?.expiresAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("configures a provider ttl at or before the requested expiry and returns the provider expiry", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const autoDestroyAt = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
const sandbox = createMockSandbox({ autoDestroyAt });
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const requestedExpiresAt = new Date(Date.now() + 30 * 60_000 + 30_000).toISOString();
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: { image: "node:20", timeoutMs: 300000, reuseLease: false },
|
||||
requestedExpiresAt,
|
||||
});
|
||||
|
||||
// The provider ttl is rounded DOWN to whole minutes, so the destroy time
|
||||
// never lands after the requested deadline.
|
||||
expect(sandbox.setTtl).toHaveBeenCalledTimes(1);
|
||||
expect(sandbox.setTtl).toHaveBeenCalledWith(30);
|
||||
expect(sandbox.refreshData).toHaveBeenCalled();
|
||||
// The lease carries the real provider destroy time as evidence of the bound.
|
||||
expect(lease?.expiresAt).toBe(autoDestroyAt);
|
||||
});
|
||||
|
||||
it("returns no expiry when the requested deadline is less than one minute away", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const sandbox = createMockSandbox({ autoDestroyAt: "must-not-be-read" });
|
||||
mockCreate.mockResolvedValue(sandbox);
|
||||
|
||||
const requestedExpiresAt = new Date(Date.now() + 30_000).toISOString();
|
||||
const lease = await plugin.definition.onEnvironmentAcquireLease?.({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
runId: "run-1",
|
||||
config: { image: "node:20", timeoutMs: 300000, reuseLease: false },
|
||||
requestedExpiresAt,
|
||||
});
|
||||
|
||||
// Daytona ttl granularity is one minute, so a nearer deadline maps to no
|
||||
// valid provider ttl. The provider grants no expiry and the server fails
|
||||
// closed on the null expiry.
|
||||
expect(sandbox.setTtl).not.toHaveBeenCalled();
|
||||
expect(lease?.expiresAt ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it("starts an interactive setup sandbox with redacted metadata and one-time SSH payload", async () => {
|
||||
process.env.DAYTONA_API_KEY = "host-key";
|
||||
const sandbox = createMockSandbox();
|
||||
|
|
|
|||
|
|
@ -60,6 +60,11 @@ export type {
|
|||
DaytonaPtyCreateOptions,
|
||||
DaytonaSetupTokenPtyOptions,
|
||||
} from "./setup-token-pty.js";
|
||||
import { openDaytonaSetupTokenPtySession as openSetupTokenPtySession } from "./setup-token-pty.js";
|
||||
import type {
|
||||
SetupTokenPtySession as SetupTokenPtyWorkerSession,
|
||||
DaytonaPtyProcess,
|
||||
} 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
|
||||
|
|
@ -628,6 +633,33 @@ function expiresAtForMinutes(minutes: number): string {
|
|||
return new Date(Date.now() + minutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
// Configure a provider-side time-to-live so Daytona destroys the sandbox at or
|
||||
// before the caller-requested deadline, even after a Paperclip crash or outage.
|
||||
// `setTtl` counts wall-clock time regardless of the sandbox state, so the destroy
|
||||
// happens even when the sandbox is stopped, paused, or archived. The function
|
||||
// returns the real provider destroy time (`autoDestroyAt`) as evidence of the
|
||||
// provider-side bound. It returns null when the caller sets no deadline, when the
|
||||
// deadline is invalid, or when the deadline is less than one minute away (Daytona
|
||||
// TTL granularity is one minute, so a nearer deadline maps to no valid TTL). The
|
||||
// server then fails closed on a null expiry and releases the lease.
|
||||
async function configureSandboxExpiry(input: {
|
||||
sandbox: Sandbox;
|
||||
requestedExpiresAt: string | null | undefined;
|
||||
nowMs: number;
|
||||
}): Promise<string | null> {
|
||||
const requestedMs = input.requestedExpiresAt ? Date.parse(input.requestedExpiresAt) : Number.NaN;
|
||||
if (!Number.isFinite(requestedMs)) return null;
|
||||
// Round DOWN so the provider destroy time never lands after the deadline.
|
||||
const ttlMinutes = Math.floor((requestedMs - input.nowMs) / 60_000);
|
||||
if (ttlMinutes < 1) return null;
|
||||
await input.sandbox.setTtl(ttlMinutes);
|
||||
await input.sandbox.refreshData();
|
||||
const autoDestroyAt = input.sandbox.autoDestroyAt;
|
||||
return typeof autoDestroyAt === "string" && autoDestroyAt.trim().length > 0
|
||||
? autoDestroyAt.trim()
|
||||
: null;
|
||||
}
|
||||
|
||||
function sanitizeSnapshotName(value: string | null | undefined, fallback: string): string {
|
||||
const cleaned = (value ?? fallback)
|
||||
.trim()
|
||||
|
|
@ -1224,7 +1256,27 @@ const sandboxHandleCache = (() => {
|
|||
entries.clear();
|
||||
}
|
||||
|
||||
return { get, seed, clear, reset, markFresh };
|
||||
// Resolve a cached sandbox by its provider lease id alone. The
|
||||
// login pseudo-terminal open carries only the provider lease id, not the full
|
||||
// scope, so this scans the cached handles for the one whose `sandbox.id`
|
||||
// matches. The lease was cached on acquire in the same worker, so the scan is
|
||||
// a hit for a live login lease. It returns null when no cached handle matches,
|
||||
// so the caller fails closed.
|
||||
async function findByProviderLeaseId(providerLeaseId: string): Promise<Sandbox | null> {
|
||||
if (!providerLeaseId) return null;
|
||||
for (const entry of entries.values()) {
|
||||
let sandbox: Sandbox;
|
||||
try {
|
||||
sandbox = await entry.sandbox;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (sandbox.id === providerLeaseId) return sandbox;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return { get, seed, clear, reset, markFresh, findByProviderLeaseId };
|
||||
})();
|
||||
|
||||
// Advisory writable-set store. It holds, per lease scope, the sandbox
|
||||
|
|
@ -1829,6 +1881,24 @@ async function executeInSession(
|
|||
}
|
||||
}
|
||||
|
||||
// The worker-side registry of live login pseudo-terminal sessions.
|
||||
// The worker registers each terminal under the host-owned route identifier at
|
||||
// create time, so the host closes the exact terminal by that identifier even
|
||||
// when the open reply was lost. It also indexes by the worker session identifier
|
||||
// for input and stop. The `onShutdown` hook closes every open session here.
|
||||
interface DaytonaSetupTokenPtyEntry {
|
||||
hostRouteId: string;
|
||||
workerSessionId: string;
|
||||
session: SetupTokenPtyWorkerSession;
|
||||
}
|
||||
const daytonaSetupTokenPtyByRoute = new Map<string, DaytonaSetupTokenPtyEntry>();
|
||||
const daytonaSetupTokenPtyBySession = new Map<string, DaytonaSetupTokenPtyEntry>();
|
||||
|
||||
function forgetDaytonaSetupTokenPty(entry: DaytonaSetupTokenPtyEntry): void {
|
||||
daytonaSetupTokenPtyByRoute.delete(entry.hostRouteId);
|
||||
daytonaSetupTokenPtyBySession.delete(entry.workerSessionId);
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
// Hoist the context to a module variable so the lifecycle hooks and the
|
||||
|
|
@ -1956,6 +2026,14 @@ const plugin = definePlugin({
|
|||
try {
|
||||
const remoteCwd = await resolveSandboxWorkingDirectory(sandbox);
|
||||
const shellCommand = await detectSandboxShellCommand(sandbox, toTimeoutSeconds(config.timeoutMs));
|
||||
// Configure a provider-side destroy time at or before a caller deadline, so
|
||||
// an abandoned sandbox self-destroys even if Paperclip is down. The lease
|
||||
// carries the real provider expiry (or none) as evidence of the bound.
|
||||
const expiresAt = await configureSandboxExpiry({
|
||||
sandbox,
|
||||
requestedExpiresAt: params.requestedExpiresAt,
|
||||
nowMs: Date.now(),
|
||||
});
|
||||
const workspaceSentinel = await writeWorkspaceSentinel({
|
||||
sandbox,
|
||||
remoteCwd,
|
||||
|
|
@ -1985,6 +2063,7 @@ const plugin = definePlugin({
|
|||
);
|
||||
return {
|
||||
providerLeaseId: sandbox.id,
|
||||
expiresAt,
|
||||
metadata: leaseMetadata({
|
||||
config,
|
||||
sandbox,
|
||||
|
|
@ -2588,6 +2667,84 @@ const plugin = definePlugin({
|
|||
return result;
|
||||
});
|
||||
},
|
||||
|
||||
// Open one live Claude `setup-token` login pseudo-terminal. Resolve
|
||||
// the cached sandbox by the provider lease id, run the fixed login command on a
|
||||
// real pseudo-terminal, and register the session under the host route id. Stream
|
||||
// the raw output and the exit through `ctx.setupTokenPty`, bound to the returned
|
||||
// worker session id. Fail closed when no cached sandbox matches the lease.
|
||||
async onSetupTokenPtyOpen(params) {
|
||||
const sandbox = await sandboxHandleCache.findByProviderLeaseId(params.providerLeaseId);
|
||||
if (!sandbox) {
|
||||
throw new Error(
|
||||
"Daytona setup-token login: no cached sandbox resolves the provider lease.",
|
||||
);
|
||||
}
|
||||
const session = await openSetupTokenPtySession(
|
||||
sandbox.process as unknown as DaytonaPtyProcess,
|
||||
params.command,
|
||||
);
|
||||
const workerSessionId = `pty-${randomUUID()}`;
|
||||
const entry: DaytonaSetupTokenPtyEntry = {
|
||||
hostRouteId: params.hostRouteId,
|
||||
workerSessionId,
|
||||
session,
|
||||
};
|
||||
daytonaSetupTokenPtyByRoute.set(params.hostRouteId, entry);
|
||||
daytonaSetupTokenPtyBySession.set(workerSessionId, entry);
|
||||
// Register the output listener before the first input, so no early output
|
||||
// chunk is lost. The client stamps the worker session id, so the host binds
|
||||
// the output to the open route.
|
||||
session.onData((chunk) => {
|
||||
pluginContext?.setupTokenPty.output(workerSessionId, chunk);
|
||||
});
|
||||
// Forward the child exit one time. The host resolves the login run on it.
|
||||
void session.wait().then(
|
||||
(result) => pluginContext?.setupTokenPty.exit(workerSessionId, result.exitCode),
|
||||
() => pluginContext?.setupTokenPty.exit(workerSessionId, null),
|
||||
);
|
||||
return { workerSessionId };
|
||||
},
|
||||
|
||||
// Write delayed input to an open login pseudo-terminal, keyed by the worker
|
||||
// session id. Drop the input for an unknown session.
|
||||
async onSetupTokenPtyInput(params) {
|
||||
const entry = daytonaSetupTokenPtyBySession.get(params.workerSessionId);
|
||||
if (!entry) return;
|
||||
entry.session.write(params.data);
|
||||
},
|
||||
|
||||
// Stop an open login pseudo-terminal child, keyed by the worker session id.
|
||||
async onSetupTokenPtyStop(params) {
|
||||
const entry = daytonaSetupTokenPtyBySession.get(params.workerSessionId);
|
||||
if (!entry) return;
|
||||
entry.session.kill();
|
||||
},
|
||||
|
||||
// Close an open login pseudo-terminal by the host route id and acknowledge the
|
||||
// close with the same identifier. The close is idempotent: it returns the
|
||||
// acknowledgement even when the entry is already gone, so the host confirms the
|
||||
// terminal is closed. The worker never keys the close on the worker session id.
|
||||
async onSetupTokenPtyClose(params) {
|
||||
const entry = daytonaSetupTokenPtyByRoute.get(params.hostRouteId);
|
||||
if (entry) {
|
||||
forgetDaytonaSetupTokenPty(entry);
|
||||
await entry.session.close().catch(() => undefined);
|
||||
}
|
||||
return { hostRouteId: params.hostRouteId };
|
||||
},
|
||||
|
||||
// Close every open login pseudo-terminal on an orderly shutdown, then drain the
|
||||
// sandbox handle cache, so a graceful shutdown holds no live login terminal.
|
||||
async onShutdown() {
|
||||
const openSessions = [...daytonaSetupTokenPtyByRoute.values()];
|
||||
daytonaSetupTokenPtyByRoute.clear();
|
||||
daytonaSetupTokenPtyBySession.clear();
|
||||
for (const entry of openSessions) {
|
||||
await entry.session.close().catch(() => undefined);
|
||||
}
|
||||
sandboxHandleCache.reset();
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
|
|
|
|||
|
|
@ -82,6 +82,12 @@ import type {
|
|||
PluginExternalObjectResolveResult,
|
||||
RefreshExternalObjectsParams,
|
||||
RefreshExternalObjectsResult,
|
||||
PluginSetupTokenPtyOpenParams,
|
||||
PluginSetupTokenPtyOpenResult,
|
||||
PluginSetupTokenPtyInputParams,
|
||||
PluginSetupTokenPtyStopParams,
|
||||
PluginSetupTokenPtyCloseParams,
|
||||
PluginSetupTokenPtyCloseResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -432,6 +438,32 @@ export interface PluginDefinition {
|
|||
onEnvironmentDeleteTemplate?(
|
||||
params: PluginEnvironmentDeleteTemplateParams,
|
||||
): Promise<PluginEnvironmentDeleteTemplateResult>;
|
||||
|
||||
/**
|
||||
* Called to open one live Claude `setup-token` login pseudo-terminal.
|
||||
* The worker registers the terminal under the host route identifier and returns a
|
||||
* worker session identifier for the output notification binding only. The worker
|
||||
* streams output and the exit through `ctx.setupTokenPty`, never as a reply.
|
||||
* Defining the four `onSetupTokenPty*` hooks advertises the four methods.
|
||||
*/
|
||||
onSetupTokenPtyOpen?(
|
||||
params: PluginSetupTokenPtyOpenParams,
|
||||
): Promise<PluginSetupTokenPtyOpenResult>;
|
||||
|
||||
/** Called to write delayed input to an open login pseudo-terminal, keyed by the worker session identifier. */
|
||||
onSetupTokenPtyInput?(params: PluginSetupTokenPtyInputParams): Promise<void>;
|
||||
|
||||
/** Called to stop an open login pseudo-terminal child, keyed by the worker session identifier. */
|
||||
onSetupTokenPtyStop?(params: PluginSetupTokenPtyStopParams): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called to close an open login pseudo-terminal by the host route identifier. The
|
||||
* worker closes the exact terminal registered under that identifier and returns a
|
||||
* close acknowledgement that carries the same identifier.
|
||||
*/
|
||||
onSetupTokenPtyClose?(
|
||||
params: PluginSetupTokenPtyCloseParams,
|
||||
): Promise<PluginSetupTokenPtyCloseResult>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ export {
|
|||
parseMessage,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
||||
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
||||
_resetIdCounter,
|
||||
} from "./protocol.js";
|
||||
|
||||
|
|
|
|||
|
|
@ -628,6 +628,17 @@ export interface PluginEnvironmentAcquireLeaseParams extends PluginEnvironmentDr
|
|||
*/
|
||||
adapterType?: string;
|
||||
executionWorkspaceSettings?: Record<string, unknown> | null;
|
||||
/**
|
||||
* The absolute latest time the acquired lease may stay active, as an ISO 8601
|
||||
* timestamp. A caller with an independent deadline (for example the setup-token
|
||||
* login session) sets it. A provider that materializes a sandbox must configure
|
||||
* a provider-side expiry at or before this time, and return the real provider
|
||||
* expiry in `PluginEnvironmentLease.expiresAt`. When the provider cannot bound
|
||||
* the sandbox at or before this time, it returns no expiry, so the server fails
|
||||
* closed and releases the lease. When omitted, the provider keeps its default
|
||||
* lifetime.
|
||||
*/
|
||||
requestedExpiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PluginEnvironmentResumeLeaseParams extends PluginEnvironmentDriverBaseParams {
|
||||
|
|
@ -960,6 +971,117 @@ export interface PluginRenderCloseEvent {
|
|||
nativeEvent?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup-token login pseudo-terminal (PTY) worker methods.
|
||||
// ---------------------------------------------------------------------------
|
||||
// The host drives one live Claude `setup-token` login pseudo-terminal inside a
|
||||
// sandbox provider worker. The host owns the route. It mints an opaque host
|
||||
// route identifier, carries that identifier in the open request, and keys the
|
||||
// close on that identifier. The worker registers the terminal under the host
|
||||
// route identifier and returns a worker session identifier for the output
|
||||
// notification binding only. The worker never keys a close on the worker
|
||||
// session identifier, so the host closes a worker-created terminal even when the
|
||||
// open reply was lost and no worker session identifier arrived. The worker sends
|
||||
// output and exit as notifications, never as a reply, so the host binds them by
|
||||
// the worker session identifier while the route is open.
|
||||
|
||||
/** The open request for one live login pseudo-terminal. The worker registers the terminal by `hostRouteId`. */
|
||||
export interface PluginSetupTokenPtyOpenParams {
|
||||
/** The host-owned opaque route identifier. The worker registers the terminal by it. */
|
||||
hostRouteId: string;
|
||||
/** The environment driver key, for the worker sandbox scope. */
|
||||
driverKey: string;
|
||||
/** The company that owns the login session. */
|
||||
companyId: string;
|
||||
/** The environment the login session runs in. */
|
||||
environmentId: string;
|
||||
/** The provider lease the sandbox is cached under. The worker resolves the sandbox by it. */
|
||||
providerLeaseId: string;
|
||||
/** The fixed login command. The worker runs only this command on the terminal. */
|
||||
command: string;
|
||||
}
|
||||
|
||||
/** The open reply. It returns the worker session identifier for output binding only. */
|
||||
export interface PluginSetupTokenPtyOpenResult {
|
||||
/** The worker session identifier. It binds the output and the exit notification only. */
|
||||
workerSessionId: string;
|
||||
}
|
||||
|
||||
/** The input request. It carries the worker session identifier and the raw input bytes. */
|
||||
export interface PluginSetupTokenPtyInputParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The raw input bytes to write to the terminal. */
|
||||
data: string;
|
||||
}
|
||||
|
||||
/** The stop request. It carries the worker session identifier. */
|
||||
export interface PluginSetupTokenPtyStopParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
}
|
||||
|
||||
/** The close request. The host route identifier is the authoritative key. */
|
||||
export interface PluginSetupTokenPtyCloseParams {
|
||||
/**
|
||||
* The host-owned opaque route identifier. This is the authoritative close key,
|
||||
* so the host closes the terminal even when no worker session identifier
|
||||
* arrived after a lost open reply.
|
||||
*/
|
||||
hostRouteId: string;
|
||||
/**
|
||||
* A non-authoritative worker session identifier. The worker never keys the
|
||||
* close on it. The field is optional, so a close with only the host route
|
||||
* identifier is a valid request for this lifecycle.
|
||||
*/
|
||||
workerSessionId?: string;
|
||||
}
|
||||
|
||||
/** The close reply. It acknowledges the close and carries the same host route identifier. */
|
||||
export interface PluginSetupTokenPtyCloseResult {
|
||||
/** The close acknowledgement. It carries the same host route identifier the close sent. */
|
||||
hostRouteId: string;
|
||||
}
|
||||
|
||||
/** The worker→host pseudo-terminal output notification parameters. Modeled on `execute.log`. */
|
||||
export interface PluginSetupTokenPtyOutputParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The raw terminal output bytes. */
|
||||
chunk: string;
|
||||
}
|
||||
|
||||
/** The worker→host pseudo-terminal exit notification parameters. */
|
||||
export interface PluginSetupTokenPtyExitParams {
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The child exit code, or null when the child ended with no code. */
|
||||
exitCode: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One live login pseudo-terminal session in the worker. The worker opener returns
|
||||
* it. The shape matches the sandbox provider setup-token pseudo-terminal session,
|
||||
* so a provider passes its session with no adapter.
|
||||
*/
|
||||
export interface PluginSetupTokenPtyWorkerSession {
|
||||
/** 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>;
|
||||
}
|
||||
|
||||
/** The worker→host notification method for one pseudo-terminal output chunk. */
|
||||
export const SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION = "setupTokenPty.output";
|
||||
/** The worker→host notification method for one pseudo-terminal exit. */
|
||||
export const SETUP_TOKEN_PTY_EXIT_NOTIFICATION = "setupTokenPty.exit";
|
||||
|
||||
/**
|
||||
* Map of host→worker RPC method names to their `[params, result]` types.
|
||||
*
|
||||
|
|
@ -1064,6 +1186,20 @@ export interface HostToWorkerMethods {
|
|||
params: PluginEnvironmentDeleteTemplateParams,
|
||||
result: PluginEnvironmentDeleteTemplateResult,
|
||||
];
|
||||
/** Open one live login pseudo-terminal keyed by a host-owned route identifier. */
|
||||
setupTokenPtyOpen: [
|
||||
params: PluginSetupTokenPtyOpenParams,
|
||||
result: PluginSetupTokenPtyOpenResult,
|
||||
];
|
||||
/** Write delayed input to a live login pseudo-terminal, keyed by the worker session identifier. */
|
||||
setupTokenPtyInput: [params: PluginSetupTokenPtyInputParams, result: void];
|
||||
/** Stop a live login pseudo-terminal child, keyed by the worker session identifier. */
|
||||
setupTokenPtyStop: [params: PluginSetupTokenPtyStopParams, result: void];
|
||||
/** Close a live login pseudo-terminal by the host route identifier and return a bound acknowledgement. */
|
||||
setupTokenPtyClose: [
|
||||
params: PluginSetupTokenPtyCloseParams,
|
||||
result: PluginSetupTokenPtyCloseResult,
|
||||
];
|
||||
}
|
||||
|
||||
/** Union of all host→worker method names. */
|
||||
|
|
@ -1105,6 +1241,10 @@ export const HOST_TO_WORKER_OPTIONAL_METHODS: readonly HostToWorkerMethodName[]
|
|||
"environmentCaptureTemplate",
|
||||
"environmentCancelInteractiveSetup",
|
||||
"environmentDeleteTemplate",
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2478,6 +2478,14 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
// No-op in test harness — the host runner log sink is not wired here.
|
||||
},
|
||||
},
|
||||
setupTokenPty: {
|
||||
output(_workerSessionId: string, _chunk: string) {
|
||||
// No-op in test harness — the host login route is not wired here.
|
||||
},
|
||||
exit(_workerSessionId: string, _exitCode: number | null) {
|
||||
// No-op in test harness — the host login route is not wired here.
|
||||
},
|
||||
},
|
||||
tools: {
|
||||
register(name, _decl, fn) {
|
||||
requireCapability(manifest, capabilitySet, "agent.tools.register");
|
||||
|
|
|
|||
|
|
@ -2008,6 +2008,35 @@ export interface PluginExecutionClient {
|
|||
log(stream: "stdout" | "stderr", chunk: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.setupTokenPty` — stream one live login pseudo-terminal's output and exit
|
||||
* from a sandbox provider worker to the host.
|
||||
*
|
||||
* The worker opener registers the output listener on the session and forwards
|
||||
* each raw chunk through `output(workerSessionId, chunk)`. It forwards the child
|
||||
* exit through `exit(workerSessionId, exitCode)`. Each call carries the worker
|
||||
* session identifier the open reply returned, so the host binds the output to
|
||||
* the open route by that identifier while the route is open. The host drops a
|
||||
* chunk or an exit that carries an unknown or a mismatched identifier, and it
|
||||
* never logs the raw bytes. The default is a no-op that never throws.
|
||||
*/
|
||||
export interface PluginSetupTokenPtyClient {
|
||||
/**
|
||||
* Deliver one raw output chunk of a live login pseudo-terminal.
|
||||
*
|
||||
* @param workerSessionId - The worker session identifier the open reply returned.
|
||||
* @param chunk - The raw terminal output text.
|
||||
*/
|
||||
output(workerSessionId: string, chunk: string): void;
|
||||
/**
|
||||
* Deliver the child exit of a live login pseudo-terminal.
|
||||
*
|
||||
* @param workerSessionId - The worker session identifier the open reply returned.
|
||||
* @param exitCode - The child exit code, or null when the child ended with no code.
|
||||
*/
|
||||
exit(workerSessionId: string, exitCode: number | null): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full plugin context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -2123,6 +2152,11 @@ export interface PluginContext {
|
|||
* stream. */
|
||||
execution: PluginExecutionClient;
|
||||
|
||||
/** Stream one live login pseudo-terminal's output and exit to the host.
|
||||
* The default is a no-op for a provider that opens no login
|
||||
* pseudo-terminal. */
|
||||
setupTokenPty: PluginSetupTokenPtyClient;
|
||||
|
||||
/** Register agent tool handlers. Requires `agent.tools.register`. */
|
||||
tools: PluginToolsClient;
|
||||
|
||||
|
|
|
|||
|
|
@ -100,11 +100,17 @@ import type {
|
|||
PluginEnvironmentCaptureTemplateParams,
|
||||
PluginEnvironmentCancelInteractiveSetupParams,
|
||||
PluginEnvironmentDeleteTemplateParams,
|
||||
PluginSetupTokenPtyOpenParams,
|
||||
PluginSetupTokenPtyInputParams,
|
||||
PluginSetupTokenPtyStopParams,
|
||||
PluginSetupTokenPtyCloseParams,
|
||||
PluginInvocationContext,
|
||||
WorkerToHostMethodName,
|
||||
WorkerToHostMethods,
|
||||
} from "./protocol.js";
|
||||
import {
|
||||
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
||||
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
||||
JSONRPC_VERSION,
|
||||
JSONRPC_ERROR_CODES,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
|
|
@ -1366,6 +1372,30 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
},
|
||||
},
|
||||
|
||||
setupTokenPty: {
|
||||
output(workerSessionId: string, chunk: string): void {
|
||||
// Forward one raw output chunk of a live login pseudo-terminal. The
|
||||
// notification carries the worker session identifier, so the host binds
|
||||
// the chunk to the open route by that identifier while the route is
|
||||
// open. The host drops an unknown or a mismatched identifier and never
|
||||
// logs the raw bytes. This notification carries no invocation id,
|
||||
// because it fires after the open reply returns.
|
||||
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
|
||||
if (typeof chunk !== "string" || chunk.length === 0) return;
|
||||
notifyHost(SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION, { workerSessionId, chunk });
|
||||
},
|
||||
exit(workerSessionId: string, exitCode: number | null): void {
|
||||
// Forward the child exit of a live login pseudo-terminal. The host
|
||||
// resolves the open route's wait promise by the worker session
|
||||
// identifier while the route is open.
|
||||
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
|
||||
notifyHost(SETUP_TOKEN_PTY_EXIT_NOTIFICATION, {
|
||||
workerSessionId,
|
||||
exitCode: typeof exitCode === "number" ? exitCode : null,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
tools: {
|
||||
register(
|
||||
name: string,
|
||||
|
|
@ -1582,6 +1612,18 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
case "environmentDeleteTemplate":
|
||||
return handleEnvironmentDeleteTemplate(params as PluginEnvironmentDeleteTemplateParams);
|
||||
|
||||
case "setupTokenPtyOpen":
|
||||
return handleSetupTokenPtyOpen(params as PluginSetupTokenPtyOpenParams);
|
||||
|
||||
case "setupTokenPtyInput":
|
||||
return handleSetupTokenPtyInput(params as PluginSetupTokenPtyInputParams);
|
||||
|
||||
case "setupTokenPtyStop":
|
||||
return handleSetupTokenPtyStop(params as PluginSetupTokenPtyStopParams);
|
||||
|
||||
case "setupTokenPtyClose":
|
||||
return handleSetupTokenPtyClose(params as PluginSetupTokenPtyCloseParams);
|
||||
|
||||
default:
|
||||
throw Object.assign(
|
||||
new Error(`Unknown method: ${method}`),
|
||||
|
|
@ -1633,6 +1675,10 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
if (plugin.definition.onEnvironmentCaptureTemplate) supportedMethods.push("environmentCaptureTemplate");
|
||||
if (plugin.definition.onEnvironmentCancelInteractiveSetup) supportedMethods.push("environmentCancelInteractiveSetup");
|
||||
if (plugin.definition.onEnvironmentDeleteTemplate) supportedMethods.push("environmentDeleteTemplate");
|
||||
if (plugin.definition.onSetupTokenPtyOpen) supportedMethods.push("setupTokenPtyOpen");
|
||||
if (plugin.definition.onSetupTokenPtyInput) supportedMethods.push("setupTokenPtyInput");
|
||||
if (plugin.definition.onSetupTokenPtyStop) supportedMethods.push("setupTokenPtyStop");
|
||||
if (plugin.definition.onSetupTokenPtyClose) supportedMethods.push("setupTokenPtyClose");
|
||||
|
||||
return { ok: true, supportedMethods };
|
||||
}
|
||||
|
|
@ -1981,6 +2027,34 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
return plugin.definition.onEnvironmentDeleteTemplate(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyOpen(params: PluginSetupTokenPtyOpenParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyOpen) {
|
||||
throw methodNotImplemented("setupTokenPtyOpen");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyOpen(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyInput(params: PluginSetupTokenPtyInputParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyInput) {
|
||||
throw methodNotImplemented("setupTokenPtyInput");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyInput(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyStop(params: PluginSetupTokenPtyStopParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyStop) {
|
||||
throw methodNotImplemented("setupTokenPtyStop");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyStop(params);
|
||||
}
|
||||
|
||||
async function handleSetupTokenPtyClose(params: PluginSetupTokenPtyCloseParams) {
|
||||
if (!plugin.definition.onSetupTokenPtyClose) {
|
||||
throw methodNotImplemented("setupTokenPtyClose");
|
||||
}
|
||||
return plugin.definition.onSetupTokenPtyClose(params);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event filter helper
|
||||
// -----------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import {
|
|||
createSuccessResponse,
|
||||
isJsonRpcRequest,
|
||||
isJsonRpcResponse,
|
||||
isJsonRpcNotification,
|
||||
parseMessage,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
serializeMessage,
|
||||
type JsonRpcNotification,
|
||||
type JsonRpcResponse,
|
||||
type PluginInvocationContext,
|
||||
} from "../src/protocol.js";
|
||||
|
|
@ -755,3 +757,151 @@ describe("worker execute.log emitter", () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker setup-token pseudo-terminal dispatch", () => {
|
||||
it("dispatches open, input, stop, and close, and streams output and exit as notifications", async () => {
|
||||
const hostToWorker = new PassThrough();
|
||||
const workerToHost = new PassThrough();
|
||||
const hostReadline = createInterface({ input: workerToHost });
|
||||
const pending = new Map<string, (response: JsonRpcResponse) => void>();
|
||||
const notifications: JsonRpcNotification[] = [];
|
||||
let nextRequestId = 1;
|
||||
|
||||
// The fake session the opener returns. The test drives its output and exit.
|
||||
let emitOutput: ((chunk: string) => void) | null = null;
|
||||
let resolveWait: ((value: { exitCode: number | null }) => void) | null = null;
|
||||
const inputs: string[] = [];
|
||||
let killed = 0;
|
||||
let closed = 0;
|
||||
|
||||
// The worker emits output and exit through `ctx.setupTokenPty`, bound to the
|
||||
// worker session id. The test drives them through the captured emitters.
|
||||
const controllablePlugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
emitOutput = (chunk: string) => ctx.setupTokenPty.output("ws-1", chunk);
|
||||
resolveWait = (value) => ctx.setupTokenPty.exit("ws-1", value.exitCode);
|
||||
},
|
||||
async onSetupTokenPtyOpen(params) {
|
||||
// The open carries the host route id and the fixed command. The worker
|
||||
// returns a worker session id for the output binding only.
|
||||
expect(params.hostRouteId).toBe("route-1");
|
||||
expect(params.command).toBe("claude setup-token");
|
||||
expect(params.providerLeaseId).toBe("lease-1");
|
||||
return { workerSessionId: "ws-1" };
|
||||
},
|
||||
async onSetupTokenPtyInput(params) {
|
||||
inputs.push(params.data);
|
||||
},
|
||||
async onSetupTokenPtyStop() {
|
||||
killed += 1;
|
||||
},
|
||||
async onSetupTokenPtyClose(params) {
|
||||
// The close keys on the host route id and returns a bound acknowledgement.
|
||||
closed += 1;
|
||||
return { hostRouteId: params.hostRouteId };
|
||||
},
|
||||
});
|
||||
|
||||
const worker = startWorkerRpcHost({
|
||||
plugin: controllablePlugin,
|
||||
stdin: hostToWorker,
|
||||
stdout: workerToHost,
|
||||
});
|
||||
|
||||
function callWorker(method: string, params: unknown) {
|
||||
const id = `host-${nextRequestId++}`;
|
||||
const result = new Promise<unknown>((resolve, reject) => {
|
||||
pending.set(id, (response) => {
|
||||
if ("error" in response && response.error) {
|
||||
reject(new Error(response.error.message));
|
||||
return;
|
||||
}
|
||||
resolve((response as { result?: unknown }).result);
|
||||
});
|
||||
});
|
||||
hostToWorker.write(serializeMessage(createRequest(method, params, id)));
|
||||
return result;
|
||||
}
|
||||
|
||||
hostReadline.on("line", (line) => {
|
||||
const message = parseMessage(line);
|
||||
if (isJsonRpcResponse(message)) {
|
||||
pending.get(String(message.id))?.(message);
|
||||
pending.delete(String(message.id));
|
||||
return;
|
||||
}
|
||||
if (isJsonRpcNotification(message)) {
|
||||
notifications.push(message as JsonRpcNotification);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
callWorker("initialize", {
|
||||
manifest: {
|
||||
id: "paperclip.setup-token-pty",
|
||||
apiVersion: 1,
|
||||
version: "1.0.0",
|
||||
displayName: "Setup Token PTY Test",
|
||||
description: "Test plugin",
|
||||
author: "Paperclip",
|
||||
categories: ["automation"],
|
||||
capabilities: [],
|
||||
entrypoints: {},
|
||||
},
|
||||
config: {},
|
||||
databaseNamespace: null,
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
ok: true,
|
||||
supportedMethods: expect.arrayContaining([
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
]),
|
||||
});
|
||||
|
||||
await expect(
|
||||
callWorker("setupTokenPtyOpen", {
|
||||
hostRouteId: "route-1",
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "lease-1",
|
||||
command: "claude setup-token",
|
||||
}),
|
||||
).resolves.toEqual({ workerSessionId: "ws-1" });
|
||||
|
||||
// The worker streams output as a notification bound to the worker session id.
|
||||
emitOutput?.("prompt output");
|
||||
await callWorker("setupTokenPtyInput", { workerSessionId: "ws-1", data: "browser-code" });
|
||||
await callWorker("setupTokenPtyStop", { workerSessionId: "ws-1" });
|
||||
resolveWait?.({ exitCode: 0 });
|
||||
await expect(
|
||||
callWorker("setupTokenPtyClose", { hostRouteId: "route-1" }),
|
||||
).resolves.toEqual({ hostRouteId: "route-1" });
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(inputs).toEqual(["browser-code"]);
|
||||
expect(killed).toBe(1);
|
||||
expect(closed).toBe(1);
|
||||
const outputNotes = notifications.filter(
|
||||
(note) => note.method === "setupTokenPty.output",
|
||||
);
|
||||
expect(outputNotes.map((note) => note.params)).toEqual([
|
||||
{ workerSessionId: "ws-1", chunk: "prompt output" },
|
||||
]);
|
||||
const exitNotes = notifications.filter(
|
||||
(note) => note.method === "setupTokenPty.exit",
|
||||
);
|
||||
expect(exitNotes.map((note) => note.params)).toEqual([
|
||||
{ workerSessionId: "ws-1", exitCode: 0 },
|
||||
]);
|
||||
} finally {
|
||||
worker.stop();
|
||||
hostReadline.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface EnvironmentProviderCapability {
|
|||
templateRefKind?: string;
|
||||
templateConfigBinding?: PluginEnvironmentTemplateConfigBinding;
|
||||
supportsTemplateDelete: boolean;
|
||||
supportsSetupTokenLogin: boolean;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
source?: "builtin" | "plugin";
|
||||
|
|
@ -156,6 +157,7 @@ export function getEnvironmentCapabilities(
|
|||
interactiveSetupConnectionTypes: [],
|
||||
supportsTemplateCapture: false,
|
||||
supportsTemplateDelete: false,
|
||||
supportsSetupTokenLogin: false,
|
||||
displayName: "Fake",
|
||||
source: "builtin",
|
||||
},
|
||||
|
|
@ -175,6 +177,7 @@ export function getEnvironmentCapabilities(
|
|||
templateRefKind: capability.templateRefKind,
|
||||
templateConfigBinding: capability.templateConfigBinding,
|
||||
supportsTemplateDelete: capability.supportsTemplateDelete ?? false,
|
||||
supportsSetupTokenLogin: capability.supportsSetupTokenLogin ?? false,
|
||||
displayName: capability.displayName,
|
||||
description: capability.description,
|
||||
source: capability.source ?? "plugin",
|
||||
|
|
|
|||
|
|
@ -816,6 +816,14 @@ export type {
|
|||
AdapterAuthSessionPrompt,
|
||||
AdapterAuthSessionOwnerResponse,
|
||||
StartAdapterAuthSessionRequest,
|
||||
AdapterAuthPanelMode,
|
||||
ClaudeSetupTokenSessionPrompt,
|
||||
ClaudeSetupTokenSessionResponse,
|
||||
ClaudeSetupTokenSessionOwnerResponse,
|
||||
SubmitBrowserCodeRequest,
|
||||
ClaudeSetupTokenCompletionResponse,
|
||||
SetupTokenTransportAdvisory,
|
||||
SetupTokenTransportAdvisoryCode,
|
||||
AssetImage,
|
||||
Project,
|
||||
ProjectBudgetSummary,
|
||||
|
|
@ -1436,6 +1444,8 @@ export {
|
|||
export {
|
||||
ADAPTER_AUTH_SESSION_STATUSES,
|
||||
ADAPTER_AUTH_SESSION_INTERNAL_STATUSES,
|
||||
ADAPTER_AUTH_PANEL_MODES,
|
||||
SETUP_TOKEN_TRANSPORT_ADVISORY_CODE,
|
||||
} from "./types/index.js";
|
||||
export {
|
||||
ADAPTER_AUTH_SESSION_ACTIVE_STATUSES,
|
||||
|
|
@ -1451,6 +1461,27 @@ export {
|
|||
adapterAuthSessionOwnerResponseSchema,
|
||||
startAdapterAuthSessionRequestSchema,
|
||||
} from "./validators/adapter-auth-session.js";
|
||||
export {
|
||||
startClaudeSetupTokenSessionRequestSchema,
|
||||
adapterAuthPanelModeSchema,
|
||||
claudeSetupTokenSessionResponseSchema,
|
||||
claudeSetupTokenSessionPromptSchema,
|
||||
claudeSetupTokenSessionOwnerResponseSchema,
|
||||
setupTokenTransportAdvisorySchema,
|
||||
submitBrowserCodeRequestSchema,
|
||||
browserCodeSchema,
|
||||
isValidBrowserCode,
|
||||
BROWSER_CODE_MAX_LENGTH,
|
||||
BROWSER_CODE_DISALLOWED_CHAR,
|
||||
storedSessionIdSchema,
|
||||
claudeSetupTokenCompletionResponseSchema,
|
||||
claudeSetupTokenOverwriteSchema,
|
||||
claudeOAuthTokenStatusResponseSchema,
|
||||
type StartClaudeSetupTokenSessionRequest,
|
||||
type ClaudeSetupTokenOverwrite,
|
||||
type ClaudeOAuthTokenStatusResponse,
|
||||
type BrowserCode,
|
||||
} from "./validators/claude-setup-token-session.js";
|
||||
export {
|
||||
ISSUE_REFERENCE_IDENTIFIER_RE,
|
||||
buildIssueReferenceHref,
|
||||
|
|
|
|||
|
|
@ -204,6 +204,95 @@ export interface StartAdapterAuthSessionRequest {
|
|||
ttlSeconds?: number;
|
||||
}
|
||||
|
||||
// The login-panel mode. It tells the client which login panel to render.
|
||||
//
|
||||
// - `displayed_code`: the server shows a one-time code. The user reads the code
|
||||
// into the provider prompt. The Codex login prompt above uses this mode.
|
||||
// - `submitted_browser_code`: the provider shows a code in the browser. The user
|
||||
// submits that code back to the server. The Claude login uses this mode.
|
||||
export const ADAPTER_AUTH_PANEL_MODES = [
|
||||
"displayed_code",
|
||||
"submitted_browser_code",
|
||||
] as const;
|
||||
export type AdapterAuthPanelMode = (typeof ADAPTER_AUTH_PANEL_MODES)[number];
|
||||
|
||||
// The company-and-environment Claude login session. The scope binds one login to
|
||||
// one company, one owner user, one adapter, and one environment. The scope
|
||||
// carries no agent id, so a hire flow with no agent still starts one session.
|
||||
//
|
||||
// The Codex prompt above carries a server-displayed code. The Claude login is
|
||||
// different: the provider shows a browser code, and the user submits that code.
|
||||
// So this contract carries a submitted browser code, not a displayed code.
|
||||
|
||||
// The transport advisory for a setup-token confidential response. The product
|
||||
// owner set a non-negotiable requirement: do not force TLS. Many users run
|
||||
// Paperclip over plain HTTP on a home server or a Tailscale tailnet. So the
|
||||
// setup-token routes do not block a non-confidential transport. They attach this
|
||||
// advisory to the confidential response instead. The client shows a visible,
|
||||
// non-blocking disclaimer and the login still proceeds. A confidential transport
|
||||
// (direct TLS, a local-trusted loopback, or an allowlisted TLS proxy) carries no
|
||||
// advisory.
|
||||
export const SETUP_TOKEN_TRANSPORT_ADVISORY_CODE = "insecure_transport" as const;
|
||||
export type SetupTokenTransportAdvisoryCode = typeof SETUP_TOKEN_TRANSPORT_ADVISORY_CODE;
|
||||
|
||||
// The advisory signal on a setup-token confidential response. A `null` or an
|
||||
// absent value means the transport is confidential and needs no disclaimer.
|
||||
export interface SetupTokenTransportAdvisory {
|
||||
code: SetupTokenTransportAdvisoryCode;
|
||||
}
|
||||
|
||||
// The one-time Claude login prompt. The server returns it only through an owner
|
||||
// read. It carries the authorization URL the user opens. The provider shows the
|
||||
// browser code in the browser; the user submits that code back to the server.
|
||||
// This prompt carries no server-displayed code.
|
||||
export interface ClaudeSetupTokenSessionPrompt {
|
||||
authorizationUrl: string;
|
||||
// The transport advisory. It is present and non-null when the login rides a
|
||||
// non-confidential transport. The client shows a disclaimer, not a block.
|
||||
transportAdvisory?: SetupTokenTransportAdvisory | null;
|
||||
}
|
||||
|
||||
// The public Claude login-session response. It reuses the adapter login-session
|
||||
// response fields; every field has the same meaning. It carries no secret. It
|
||||
// never carries the browser code, a token, an account identifier, or the
|
||||
// provider lease identifier. The `status` is always a public status.
|
||||
export interface ClaudeSetupTokenSessionResponse {
|
||||
sessionId: string;
|
||||
environmentId: string;
|
||||
status: AdapterAuthSessionStatus;
|
||||
expiresAt: string | null;
|
||||
failure: AdapterAuthSessionFailure | null;
|
||||
// The transport advisory. A guarded route (the browser-code submit) sets it
|
||||
// when the login rides a non-confidential transport. The status and the start
|
||||
// responses omit it, because they carry no confidential value.
|
||||
transportAdvisory?: SetupTokenTransportAdvisory | null;
|
||||
}
|
||||
|
||||
// The owner read of a Claude login session. It adds the panel mode and the
|
||||
// one-time prompt to the public response. Only the owner principal that started
|
||||
// the session reads this shape.
|
||||
export interface ClaudeSetupTokenSessionOwnerResponse
|
||||
extends ClaudeSetupTokenSessionResponse {
|
||||
panelMode: AdapterAuthPanelMode;
|
||||
prompt: ClaudeSetupTokenSessionPrompt | null;
|
||||
}
|
||||
|
||||
// The request that submits the browser code for a Claude login session. The user
|
||||
// copies the code from the browser and submits it here. The validator rejects a
|
||||
// control byte and an oversized code, so a malformed code never reaches the
|
||||
// live login process.
|
||||
export interface SubmitBrowserCodeRequest {
|
||||
browserCode: string;
|
||||
}
|
||||
|
||||
// The completion response for a Claude login session. It carries the non-secret
|
||||
// `storedSessionId` claim and no token. The `storedSessionId` is the durable
|
||||
// session id; the agent-create transaction consumes it as the one-time
|
||||
// stored-session claim.
|
||||
export interface ClaudeSetupTokenCompletionResponse {
|
||||
storedSessionId: string;
|
||||
}
|
||||
|
||||
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
|
||||
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,11 @@ export interface Environment {
|
|||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type EnvironmentDeleteBlockedReason = "managed_local" | "instance_default";
|
||||
export type EnvironmentDeleteBlockedReason =
|
||||
| "managed_local"
|
||||
| "instance_default"
|
||||
| "reusable_sandbox_lease"
|
||||
| "pending_sandbox_cleanup";
|
||||
|
||||
export interface EnvironmentDeleteBlastRadius {
|
||||
environmentId: string;
|
||||
|
|
@ -103,12 +107,28 @@ export interface EnvironmentDeleteBlastRadius {
|
|||
activeCustomImageSetupSessionCount: number;
|
||||
hasActiveRuntimeUse: boolean;
|
||||
};
|
||||
/**
|
||||
* Count of leases in the terminal `pending_cleanup` state. Each such lease
|
||||
* holds the only durable provider reference for a sandbox that a teardown
|
||||
* retry must destroy. A delete or a provider change must not remove this
|
||||
* reference, so a count above zero blocks the delete.
|
||||
*/
|
||||
pendingCleanupLeaseCount: number;
|
||||
/**
|
||||
* Count of reusable sandbox leases whose provider resource is still live.
|
||||
* Deleting the environment would remove the context used by their normal
|
||||
* release and destroy paths, so these leases block deletion.
|
||||
*/
|
||||
reusableSandboxLeaseCount: number;
|
||||
}
|
||||
|
||||
export interface EnvironmentLease {
|
||||
id: string;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
// Null only for an orphan `pending_cleanup` lease whose environment a delete
|
||||
// removed. The cleanup sweep tears the orphan down from the row's immutable
|
||||
// provider metadata, so it needs no environment row.
|
||||
environmentId: string | null;
|
||||
executionWorkspaceId: string | null;
|
||||
issueId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
|
|
|
|||
|
|
@ -285,10 +285,20 @@ export type {
|
|||
AdapterAuthSessionPrompt,
|
||||
AdapterAuthSessionOwnerResponse,
|
||||
StartAdapterAuthSessionRequest,
|
||||
AdapterAuthPanelMode,
|
||||
ClaudeSetupTokenSessionPrompt,
|
||||
ClaudeSetupTokenSessionResponse,
|
||||
ClaudeSetupTokenSessionOwnerResponse,
|
||||
SubmitBrowserCodeRequest,
|
||||
ClaudeSetupTokenCompletionResponse,
|
||||
SetupTokenTransportAdvisory,
|
||||
SetupTokenTransportAdvisoryCode,
|
||||
} from "./agent.js";
|
||||
export {
|
||||
ADAPTER_AUTH_SESSION_STATUSES,
|
||||
ADAPTER_AUTH_SESSION_INTERNAL_STATUSES,
|
||||
ADAPTER_AUTH_PANEL_MODES,
|
||||
SETUP_TOKEN_TRANSPORT_ADVISORY_CODE,
|
||||
} from "./agent.js";
|
||||
export type {
|
||||
AgentEligibilityAgent,
|
||||
|
|
|
|||
|
|
@ -215,6 +215,13 @@ export interface PluginEnvironmentDriverDeclaration {
|
|||
templateIdentityPaths?: string[];
|
||||
/** Provider supports best-effort deletion/cleanup of captured templates. */
|
||||
supportsTemplateDelete?: boolean;
|
||||
/**
|
||||
* Provider can host the Claude setup-token login on a real pseudo-terminal.
|
||||
* Only a provider with this flag exposes the setup-token pseudo-terminal
|
||||
* methods. The setup-token login server and the login UI both gate on this
|
||||
* flag, so a provider without it never starts a login.
|
||||
*/
|
||||
supportsSetupTokenLogin?: boolean;
|
||||
/** JSON Schema describing the driver's provider-specific configuration. */
|
||||
configSchema: JsonSchema;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,15 @@ export const createAgentSchema = z.object({
|
|||
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
|
||||
permissions: agentPermissionsSchema.optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional().nullable(),
|
||||
// The optional stored-session claim from a completed Claude login session. It
|
||||
// is the non-secret `storedSessionId`; it carries no token. The agent-create
|
||||
// transaction consumes it as the one-time stored-session claim.
|
||||
storedSessionId: z.string().min(1).max(256).optional(),
|
||||
// The optional apply-existing flag. When true, the caller binds the fixed
|
||||
// Claude OAuth token reference to the owner stored value with no new login
|
||||
// round trip. The server permits the no-claim bind only for a user actor and
|
||||
// only when that owner already has a stored value. It carries no token.
|
||||
applyStoredClaudeLogin: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateAgent = z.infer<typeof createAgentSchema>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BROWSER_CODE_MAX_LENGTH,
|
||||
browserCodeSchema,
|
||||
claudeSetupTokenCompletionResponseSchema,
|
||||
claudeSetupTokenSessionOwnerResponseSchema,
|
||||
isValidBrowserCode,
|
||||
startClaudeSetupTokenSessionRequestSchema,
|
||||
submitBrowserCodeRequestSchema,
|
||||
} from "./claude-setup-token-session.js";
|
||||
import { createAgentSchema, createAgentHireSchema } from "./agent.js";
|
||||
|
||||
const ENVIRONMENT_ID = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("claude setup-token session start request", () => {
|
||||
it("accepts a company-and-environment request with no agent id", () => {
|
||||
const parsed = startClaudeSetupTokenSessionRequestSchema.parse({
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
adapterType: "claude_local",
|
||||
});
|
||||
expect(parsed.environmentId).toBe(ENVIRONMENT_ID);
|
||||
expect(parsed.adapterType).toBe("claude_local");
|
||||
});
|
||||
|
||||
it("rejects a legacy ttlSeconds: the runtime does not support it", () => {
|
||||
const result = startClaudeSetupTokenSessionRequestSchema.safeParse({
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
adapterType: "claude_local",
|
||||
ttlSeconds: 300,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an agent id: the scope carries no agent id", () => {
|
||||
const result = startClaudeSetupTokenSessionRequestSchema.safeParse({
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
adapterType: "claude_local",
|
||||
agentId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser-code grammar", () => {
|
||||
it("accepts a conservative printable code", () => {
|
||||
const code = "ac_012-AZ_az.9#state";
|
||||
expect(browserCodeSchema.parse(code)).toBe(code);
|
||||
expect(isValidBrowserCode(code)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a carriage return, a line feed, a NUL, and a control byte", () => {
|
||||
for (const bad of ["ab\rcd", "ab\ncd", "ab\u0000cd", "ab\u0001cd", "ab\u007fcd", "ab cd", "ab\tcd"]) {
|
||||
expect(browserCodeSchema.safeParse(bad).success).toBe(false);
|
||||
expect(isValidBrowserCode(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a trailing newline that an anchored regex would let pass", () => {
|
||||
// The runtime `.refine()` is the authoritative guard. JavaScript `$`
|
||||
// matches before a final line feed, so an anchored printable-ASCII regex
|
||||
// accepts a trailing newline. The `.refine()` closes that trap.
|
||||
expect(browserCodeSchema.safeParse("abc\n").success).toBe(false);
|
||||
expect(isValidBrowserCode("abc\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a single control byte and accepts a printable code", () => {
|
||||
expect(browserCodeSchema.safeParse("abc").success).toBe(true);
|
||||
expect(browserCodeSchema.safeParse("valid-code_123").success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty code", () => {
|
||||
expect(browserCodeSchema.safeParse("").success).toBe(false);
|
||||
expect(isValidBrowserCode("")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an oversized code", () => {
|
||||
const oversized = "a".repeat(BROWSER_CODE_MAX_LENGTH + 1);
|
||||
expect(browserCodeSchema.safeParse(oversized).success).toBe(false);
|
||||
expect(isValidBrowserCode(oversized)).toBe(false);
|
||||
const atLimit = "a".repeat(BROWSER_CODE_MAX_LENGTH);
|
||||
expect(browserCodeSchema.parse(atLimit)).toBe(atLimit);
|
||||
});
|
||||
|
||||
it("wraps the grammar in the submit-browser-code request and rejects an extra field", () => {
|
||||
expect(submitBrowserCodeRequestSchema.parse({ browserCode: "abc123" }).browserCode).toBe(
|
||||
"abc123",
|
||||
);
|
||||
expect(submitBrowserCodeRequestSchema.safeParse({ browserCode: "a\nb" }).success).toBe(false);
|
||||
expect(
|
||||
submitBrowserCodeRequestSchema.safeParse({ browserCode: "abc", extra: 1 }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("claude setup-token owner response and completion response", () => {
|
||||
it("accepts an owner response with the submitted-browser-code panel mode and a prompt", () => {
|
||||
const parsed = claudeSetupTokenSessionOwnerResponseSchema.parse({
|
||||
sessionId: "opaque-base64url-session-id",
|
||||
environmentId: ENVIRONMENT_ID,
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: { authorizationUrl: "https://example.test/authorize" },
|
||||
});
|
||||
expect(parsed.panelMode).toBe("submitted_browser_code");
|
||||
expect(parsed.prompt?.authorizationUrl).toBe("https://example.test/authorize");
|
||||
});
|
||||
|
||||
it("rejects a token on the completion response: the claim carries no token", () => {
|
||||
expect(
|
||||
claudeSetupTokenCompletionResponseSchema.parse({ storedSessionId: "session-claim" })
|
||||
.storedSessionId,
|
||||
).toBe("session-claim");
|
||||
const withToken = claudeSetupTokenCompletionResponseSchema.safeParse({
|
||||
storedSessionId: "session-claim",
|
||||
token: "secret",
|
||||
});
|
||||
expect(withToken.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("agent create and hire carry an optional stored-session claim", () => {
|
||||
const base = { name: "Claude Agent", adapterType: "claude_local" as const };
|
||||
|
||||
it("accepts a create request without a stored-session claim", () => {
|
||||
expect(createAgentSchema.parse({ ...base }).storedSessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts a create request with a stored-session claim", () => {
|
||||
expect(
|
||||
createAgentSchema.parse({ ...base, storedSessionId: "session-claim" }).storedSessionId,
|
||||
).toBe("session-claim");
|
||||
});
|
||||
|
||||
it("accepts a hire request with a stored-session claim", () => {
|
||||
expect(
|
||||
createAgentHireSchema.parse({ ...base, storedSessionId: "session-claim" }).storedSessionId,
|
||||
).toBe("session-claim");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
import { z } from "zod";
|
||||
import { AGENT_ADAPTER_TYPES } from "../constants.js";
|
||||
import { ADAPTER_AUTH_PANEL_MODES, SETUP_TOKEN_TRANSPORT_ADVISORY_CODE } from "../types/agent.js";
|
||||
import {
|
||||
adapterAuthSessionFailureSchema,
|
||||
adapterAuthSessionStatusSchema,
|
||||
} from "./adapter-auth-session.js";
|
||||
|
||||
const isoDateTime = z.union([z.date(), z.string().datetime()]);
|
||||
|
||||
// The transport advisory schema. A guarded confidential response carries it when
|
||||
// the login rides a non-confidential transport. The client shows a disclaimer.
|
||||
export const setupTokenTransportAdvisorySchema = z.object({
|
||||
code: z.literal(SETUP_TOKEN_TRANSPORT_ADVISORY_CODE),
|
||||
}).strict();
|
||||
export type SetupTokenTransportAdvisory = z.infer<typeof setupTokenTransportAdvisorySchema>;
|
||||
|
||||
// The confirmed-overwrite capture for a replacement login. The client reads the
|
||||
// stored value metadata from the status route and passes it back when it starts
|
||||
// a replacement login. The server maps it to the owner-bound compare-and-set: it
|
||||
// rotates the value only when the captured version still matches. The server
|
||||
// still derives the owner and the definition; this body carries no owner. A
|
||||
// missing capture starts a first-write login instead.
|
||||
export const claudeSetupTokenOverwriteSchema = z.object({
|
||||
expectedSecretId: z.string().uuid(),
|
||||
expectedLatestVersion: z.number().int().min(1),
|
||||
}).strict();
|
||||
export type ClaudeSetupTokenOverwrite =
|
||||
z.infer<typeof claudeSetupTokenOverwriteSchema>;
|
||||
|
||||
// The start request for a company-and-environment Claude login session. The
|
||||
// company and the owner user come from the authenticated caller, not from this
|
||||
// body. The body names the adapter and the environment of the login. It reuses
|
||||
// the adapter login-session request fields; every field has the same meaning.
|
||||
//
|
||||
// The optional `overwrite` capture turns the login into a confirmed replacement.
|
||||
// The client sends it only after a stored token fails the agent test. The server
|
||||
// rotates the stored value under the captured version, so a concurrent change
|
||||
// fails closed with a stale conflict.
|
||||
//
|
||||
// `.strict()` rejects an extra field, so an agent id never validates. The scope
|
||||
// carries no agent id: a hire flow with no agent still starts one session. The
|
||||
// runtime does not support a caller-supplied session length, so the schema
|
||||
// exposes no `ttlSeconds` field; a legacy `ttlSeconds` fails the strict parse.
|
||||
export const startClaudeSetupTokenSessionRequestSchema = z.object({
|
||||
environmentId: z.string().uuid(),
|
||||
adapterType: z.enum(AGENT_ADAPTER_TYPES),
|
||||
overwrite: claudeSetupTokenOverwriteSchema.optional(),
|
||||
}).strict();
|
||||
export type StartClaudeSetupTokenSessionRequest =
|
||||
z.infer<typeof startClaudeSetupTokenSessionRequestSchema>;
|
||||
|
||||
// The panel-mode schema. It accepts only the two known panel modes.
|
||||
export const adapterAuthPanelModeSchema = z.enum(ADAPTER_AUTH_PANEL_MODES);
|
||||
export type AdapterAuthPanelMode = z.infer<typeof adapterAuthPanelModeSchema>;
|
||||
|
||||
// The session id is an opaque, cryptographically random string, not a UUID. So
|
||||
// the schema accepts a bounded opaque string, not a UUID.
|
||||
const sessionIdSchema = z.string().min(1).max(256);
|
||||
|
||||
// The public Claude login-session response schema. `.strict()` rejects an extra
|
||||
// field, so a prompt, a token, an account identifier, or a provider lease
|
||||
// identifier never validates.
|
||||
export const claudeSetupTokenSessionResponseSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
environmentId: z.string().uuid(),
|
||||
status: adapterAuthSessionStatusSchema,
|
||||
expiresAt: isoDateTime.nullable(),
|
||||
failure: adapterAuthSessionFailureSchema.nullable(),
|
||||
transportAdvisory: setupTokenTransportAdvisorySchema.nullable().optional(),
|
||||
}).strict();
|
||||
export type ClaudeSetupTokenSessionResponse =
|
||||
z.infer<typeof claudeSetupTokenSessionResponseSchema>;
|
||||
|
||||
// The one-time Claude login prompt schema. It carries the authorization URL the
|
||||
// user opens. It carries no server-displayed code.
|
||||
export const claudeSetupTokenSessionPromptSchema = z.object({
|
||||
authorizationUrl: z.string().min(1),
|
||||
transportAdvisory: setupTokenTransportAdvisorySchema.nullable().optional(),
|
||||
}).strict();
|
||||
export type ClaudeSetupTokenSessionPrompt =
|
||||
z.infer<typeof claudeSetupTokenSessionPromptSchema>;
|
||||
|
||||
// The owner read schema. It adds the panel mode and the one-time prompt to the
|
||||
// public response.
|
||||
export const claudeSetupTokenSessionOwnerResponseSchema =
|
||||
claudeSetupTokenSessionResponseSchema.extend({
|
||||
panelMode: adapterAuthPanelModeSchema,
|
||||
prompt: claudeSetupTokenSessionPromptSchema.nullable(),
|
||||
}).strict();
|
||||
export type ClaudeSetupTokenSessionOwnerResponse =
|
||||
z.infer<typeof claudeSetupTokenSessionOwnerResponseSchema>;
|
||||
|
||||
// The bounded maximum length of a browser code. The provider code is short. The
|
||||
// server rejects an oversized code before it reaches the live login process.
|
||||
export const BROWSER_CODE_MAX_LENGTH = 512;
|
||||
|
||||
// The conservative printable grammar for the submitted browser code. It matches
|
||||
// one character outside the visible ASCII range (0x21-0x7E). The validator
|
||||
// rejects a code that contains a match, so it rejects a space, a carriage
|
||||
// return, a line feed, a NUL, and every other control byte. A later
|
||||
// characterization test narrows this set to the exact provider format.
|
||||
export const BROWSER_CODE_DISALLOWED_CHAR = /[^\x21-\x7E]/;
|
||||
|
||||
/**
|
||||
* Returns true when the browser code obeys the grammar. The code has one or more
|
||||
* characters, no character outside the visible ASCII range, and a length at or
|
||||
* below the bounded maximum.
|
||||
*/
|
||||
export function isValidBrowserCode(code: string): boolean {
|
||||
return (
|
||||
code.length >= 1 &&
|
||||
code.length <= BROWSER_CODE_MAX_LENGTH &&
|
||||
!BROWSER_CODE_DISALLOWED_CHAR.test(code)
|
||||
);
|
||||
}
|
||||
|
||||
// The printable-ASCII allowlist pattern for the published contract. It is an
|
||||
// anchored allowlist of the visible ASCII range (0x21-0x7E). The converter reads
|
||||
// this `.regex()` from the inner `ZodString` and emits it as the OpenAPI
|
||||
// `pattern`. It is NOT the authoritative guard: JavaScript `$` matches before a
|
||||
// final line feed, so this pattern accepts a trailing newline. The `.refine()`
|
||||
// below is the authoritative guard that rejects a trailing newline.
|
||||
export const BROWSER_CODE_PATTERN = /^[\x21-\x7E]+$/u;
|
||||
|
||||
// The browser-code grammar schema. It rejects an empty code, an oversized code,
|
||||
// and a code with a control byte or any other non-printable character. The
|
||||
// `.regex()` runs before the `.refine()` so the converter serializes its
|
||||
// `pattern`; the `.refine()` stays the authoritative guard.
|
||||
export const browserCodeSchema = z
|
||||
.string()
|
||||
.min(1, "A browser code is required.")
|
||||
.max(BROWSER_CODE_MAX_LENGTH, "The browser code is too long.")
|
||||
.regex(BROWSER_CODE_PATTERN, "The browser code has an invalid character.")
|
||||
.refine((code) => !BROWSER_CODE_DISALLOWED_CHAR.test(code), {
|
||||
message: "The browser code has an invalid character.",
|
||||
});
|
||||
export type BrowserCode = z.infer<typeof browserCodeSchema>;
|
||||
|
||||
// The submit-browser-code request schema. `.strict()` rejects an extra field.
|
||||
export const submitBrowserCodeRequestSchema = z.object({
|
||||
browserCode: browserCodeSchema,
|
||||
}).strict();
|
||||
export type SubmitBrowserCodeRequest =
|
||||
z.infer<typeof submitBrowserCodeRequestSchema>;
|
||||
|
||||
// The stored-session claim schema. The `storedSessionId` is the opaque durable
|
||||
// session id. It is a non-secret claim; it carries no token.
|
||||
export const storedSessionIdSchema = z.string().min(1).max(256);
|
||||
|
||||
// The completion response schema. It carries the non-secret `storedSessionId`
|
||||
// claim and no token. `.strict()` rejects an extra field, so a token never
|
||||
// validates.
|
||||
export const claudeSetupTokenCompletionResponseSchema = z.object({
|
||||
storedSessionId: storedSessionIdSchema,
|
||||
}).strict();
|
||||
export type ClaudeSetupTokenCompletionResponse =
|
||||
z.infer<typeof claudeSetupTokenCompletionResponseSchema>;
|
||||
|
||||
// The stored Claude OAuth token status response schema. It carries only the
|
||||
// secret id and the latest version of the owner value; it carries no token.
|
||||
// `.strict()` rejects an extra field, so a token never validates. The status
|
||||
// route returns a fixed 404 for a missing or a foreign value, so a 200 body
|
||||
// always describes a present owner value.
|
||||
export const claudeOAuthTokenStatusResponseSchema = z.object({
|
||||
secretId: z.string().uuid(),
|
||||
latestVersion: z.number().int().min(1),
|
||||
}).strict();
|
||||
export type ClaudeOAuthTokenStatusResponse =
|
||||
z.infer<typeof claudeOAuthTokenStatusResponseSchema>;
|
||||
|
|
@ -187,6 +187,7 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
|
|||
templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(),
|
||||
templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(),
|
||||
supportsTemplateDelete: z.boolean().optional(),
|
||||
supportsSetupTokenLogin: z.boolean().optional(),
|
||||
configSchema: jsonSchemaSchema,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -354,6 +354,58 @@ describe("agent routes adapter validation", () => {
|
|||
expect(env.CODEX_HOME).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards a claude_local→process adapter move that drops the OAuth binding to the service unchanged", async () => {
|
||||
// The agent has the fixed Claude Code OAuth binding on the claude_local
|
||||
// adapter. A PATCH moves the agent to the process adapter and sends an empty
|
||||
// env in the same request. The route must forward the new adapter type and
|
||||
// the dropped binding to the service without a re-injection, so the
|
||||
// service-enforced binding invariant sees the removal and rejects it.
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: agentId,
|
||||
companyId: "company-1",
|
||||
name: "Claude",
|
||||
urlKey: "claude",
|
||||
role: "engineer",
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "idle",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "claude_local",
|
||||
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" } } },
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const app = await createApp();
|
||||
const res = await requestApp(app, (baseUrl) =>
|
||||
request(baseUrl)
|
||||
.patch(`/api/agents/${agentId}`)
|
||||
.send({
|
||||
adapterType: "process",
|
||||
adapterConfig: { env: {} },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
const patch = mockAgentService.update.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||
// The route forwards the requested adapter type, so the service can see the
|
||||
// adapter move.
|
||||
expect(patch.adapterType).toBe("process");
|
||||
// The route does not re-inject the fixed binding from the prior config, so
|
||||
// the service invariant sees the removal.
|
||||
const env = ((patch.adapterConfig as Record<string, unknown>).env as Record<string, unknown> | undefined) ?? {};
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it("isolates CODEX_HOME when updating a codex_local agent to set its own OPENAI_API_KEY", async () => {
|
||||
const agentId = "11111111-1111-4111-8111-111111111111";
|
||||
const app = await createApp();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ const mockSecretService = vi.hoisted(() => ({
|
|||
const mockEnvironmentService = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
releaseLease: vi.fn(),
|
||||
// The login guard reads the companies that own the environment. An empty
|
||||
// list marks an instance-global environment, so the guard stays open.
|
||||
listBoundCompanyIds: vi.fn(async () => [] as string[]),
|
||||
}));
|
||||
|
||||
const mockEnvironmentRuntime = vi.hoisted(() => ({
|
||||
|
|
|
|||
|
|
@ -864,6 +864,7 @@ describe.sequential("agent permission routes", () => {
|
|||
expect.objectContaining({
|
||||
status: "idle",
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "agent-admin-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(
|
||||
companyId,
|
||||
|
|
@ -996,6 +997,7 @@ describe.sequential("agent permission routes", () => {
|
|||
},
|
||||
},
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1058,6 +1060,7 @@ describe.sequential("agent permission routes", () => {
|
|||
},
|
||||
},
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
} finally {
|
||||
unregisterServerAdapter("failing_profile_discovery");
|
||||
|
|
@ -1096,6 +1099,7 @@ describe.sequential("agent permission routes", () => {
|
|||
model: DEFAULT_OPENCODE_LOCAL_MODEL,
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1133,6 +1137,7 @@ describe.sequential("agent permission routes", () => {
|
|||
model: "anthropic/claude-sonnet-4-5",
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1174,6 +1179,7 @@ describe.sequential("agent permission routes", () => {
|
|||
},
|
||||
},
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1397,6 +1403,7 @@ describe.sequential("agent permission routes", () => {
|
|||
expect.objectContaining({
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1482,6 +1489,7 @@ describe.sequential("agent permission routes", () => {
|
|||
adapterType: adapterCase.adapterType,
|
||||
defaultEnvironmentId: environmentId,
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "board-user", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -945,6 +945,7 @@ describe.sequential("agent skill routes", () => {
|
|||
}),
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -994,6 +995,7 @@ describe.sequential("agent skill routes", () => {
|
|||
expect.objectContaining({
|
||||
role: "security",
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockTrackAgentCreated).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
|
|
@ -1217,6 +1219,7 @@ describe.sequential("agent skill routes", () => {
|
|||
}),
|
||||
}),
|
||||
}),
|
||||
{ claudeLogin: { storedSessionId: null, ownerUserId: "local-board", applyExistingWithoutClaim: false } },
|
||||
);
|
||||
expect(mockApprovalService.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,948 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
agents,
|
||||
claudeSetupTokenSessions,
|
||||
companies,
|
||||
companySecretBindings,
|
||||
companySecretProviderConfigs,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
environments,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.ts";
|
||||
import {
|
||||
assertClaudeOAuthBindingInvariant,
|
||||
CLAUDE_OAUTH_CLAIM_REJECTED,
|
||||
CLAUDE_OAUTH_CREDENTIAL_CONFLICT,
|
||||
claudeOAuthClaimRejectedError,
|
||||
isFixedClaudeOAuthBinding,
|
||||
secretService,
|
||||
} from "../services/secrets.js";
|
||||
import {
|
||||
createDbSetupTokenCleanupStore,
|
||||
type SetupTokenSessionScope,
|
||||
} from "../services/setup-token-session.js";
|
||||
import { createSetupTokenSecretWriter } from "../services/setup-token-transport-binding.js";
|
||||
|
||||
// The fixed Claude Code OAuth binding. It is a user-secret reference to the
|
||||
// fixed key. Any other shape is a replacement or a weaker binding.
|
||||
const FIXED_BINDING = { type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" } as const;
|
||||
const withEnv = (env: Record<string, unknown>) => ({ env });
|
||||
|
||||
// --- The pure server-enforced binding invariant (no database) ----------------
|
||||
|
||||
describe("assertClaudeOAuthBindingInvariant", () => {
|
||||
it("reports that a create introduces the fixed binding", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
priorConfig: null,
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: true, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("reports that a write keeps an existing fixed binding", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: true });
|
||||
});
|
||||
|
||||
it("permits a normal removal of the fixed binding", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({}),
|
||||
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("permits a normal plain-value replacement of the fixed binding", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: { type: "plain", value: "sk-fake" } }),
|
||||
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("permits a re-point to a different user-secret key", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: { type: "user_secret_ref", key: "OTHER" } }),
|
||||
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("permits a re-point to a company-secret reference", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: { type: "secret_ref", secretId: randomUUID(), version: "latest" },
|
||||
}),
|
||||
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("permits a removal of the fixed binding by a move to another adapter type", () => {
|
||||
// The prior config has the fixed binding on claude_local. The write moves
|
||||
// the agent to the process adapter and drops the binding in the same write.
|
||||
// The binding behaves like a normal environment variable, so the write is
|
||||
// allowed and reports no fixed binding.
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "process",
|
||||
nextConfig: withEnv({}),
|
||||
priorConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("does nothing for a non-claude_local create with no prior fixed binding", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "process",
|
||||
nextConfig: withEnv({}),
|
||||
priorConfig: withEnv({ SOME_OTHER_KEY: { type: "plain", value: "x" } }),
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("rejects the fixed binding together with a plain ANTHROPIC_API_KEY and names no value", () => {
|
||||
let thrown: Error | null = null;
|
||||
try {
|
||||
assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING,
|
||||
ANTHROPIC_API_KEY: { type: "plain", value: "sk-ant-secret" },
|
||||
}),
|
||||
priorConfig: null,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error as Error;
|
||||
}
|
||||
expect(thrown?.message).toBe(CLAUDE_OAUTH_CREDENTIAL_CONFLICT);
|
||||
expect(thrown?.message).not.toContain("sk-ant-secret");
|
||||
expect(thrown?.message).not.toContain("ANTHROPIC_API_KEY");
|
||||
});
|
||||
|
||||
it("rejects the fixed binding together with an ANTHROPIC_API_KEY secret reference", () => {
|
||||
expect(() =>
|
||||
assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING,
|
||||
ANTHROPIC_API_KEY: { type: "secret_ref", secretId: randomUUID(), version: "latest" },
|
||||
}),
|
||||
priorConfig: null,
|
||||
}),
|
||||
).toThrowError(CLAUDE_OAUTH_CREDENTIAL_CONFLICT);
|
||||
});
|
||||
|
||||
it("ignores an empty ANTHROPIC_API_KEY plain value", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "claude_local",
|
||||
nextConfig: withEnv({
|
||||
CLAUDE_CODE_OAUTH_TOKEN: FIXED_BINDING,
|
||||
ANTHROPIC_API_KEY: { type: "plain", value: " " },
|
||||
}),
|
||||
priorConfig: null,
|
||||
});
|
||||
expect(decision.introducesBinding).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing for a non-claude_local adapter", () => {
|
||||
const decision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: "codex_local",
|
||||
nextConfig: withEnv({ CLAUDE_CODE_OAUTH_TOKEN: { type: "plain", value: "anything" } }),
|
||||
priorConfig: null,
|
||||
});
|
||||
expect(decision).toEqual({ introducesBinding: false, keepsBinding: false });
|
||||
});
|
||||
|
||||
it("recognizes only the exact fixed binding shape", () => {
|
||||
expect(isFixedClaudeOAuthBinding(FIXED_BINDING)).toBe(true);
|
||||
expect(isFixedClaudeOAuthBinding({ type: "plain", value: "x" })).toBe(false);
|
||||
expect(isFixedClaudeOAuthBinding({ type: "user_secret_ref", key: "OTHER" })).toBe(false);
|
||||
expect(isFixedClaudeOAuthBinding(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("raises a fixed 409 claim error", () => {
|
||||
const error = claudeOAuthClaimRejectedError();
|
||||
expect(error.status).toBe(409);
|
||||
expect(error.message).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
|
||||
});
|
||||
});
|
||||
|
||||
// --- The stored-session claim on the create and hire paths (Postgres) --------
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping Claude OAuth binding claim tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let connectionString!: string;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-claude-oauth-binding-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("claude-oauth-binding");
|
||||
stopDb = started.cleanup;
|
||||
connectionString = started.connectionString;
|
||||
db = createDb(connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(companySecretProviderConfigs);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(claudeSetupTokenSessions);
|
||||
await db.delete(agents);
|
||||
await db.delete(environments);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
interface Scope {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
ownerUserId: string;
|
||||
}
|
||||
|
||||
async function seedScope(): Promise<Scope> {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: `sandbox-${environmentId.slice(0, 8)}`,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "fake" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return { companyId, environmentId, ownerUserId: `user-${randomUUID().slice(0, 8)}` };
|
||||
}
|
||||
|
||||
async function seedStoredClaim(scope: Scope, deadlineMs: number, state = "stored"): Promise<string> {
|
||||
const sessionId = randomUUID();
|
||||
await createDbSetupTokenCleanupStore(db).record({
|
||||
sessionId,
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
adapterType: "claude_local",
|
||||
environmentId: scope.environmentId,
|
||||
leaseId: "lease-1",
|
||||
deadline: deadlineMs,
|
||||
state: state as never,
|
||||
boundAt: null,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function createInput(scope: Scope, extraEnv: Record<string, unknown> = {}) {
|
||||
return {
|
||||
name: "Claude Login Agent",
|
||||
role: "engineer",
|
||||
status: "idle" as const,
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING }, ...extraEnv } },
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readClaim(sessionId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(claudeSetupTokenSessions)
|
||||
.where(eq(claudeSetupTokenSessions.sessionId, sessionId));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async function countAgents(companyId: string): Promise<number> {
|
||||
const rows = await db.select().from(agents).where(eq(agents.companyId, companyId));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// Seed one stored owner value for the fixed Claude OAuth definition. The
|
||||
// apply-existing path binds the fixed reference only when this value exists.
|
||||
async function seedStoredOwnerValue(scope: Scope, value = "sk-owner-token"): Promise<void> {
|
||||
await secretService(db).completeClaudeOAuthUserSecret(scope.companyId, scope.ownerUserId, {
|
||||
sessionId: randomUUID(),
|
||||
mode: "first_write",
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
async function countUserSecretDefinitions(companyId: string): Promise<number> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userSecretDefinitions)
|
||||
.where(eq(userSecretDefinitions.companyId, companyId));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function countDeclarationsForAgent(agentId: string): Promise<number> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userSecretDeclarations)
|
||||
.where(eq(userSecretDeclarations.targetId, agentId));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
it("inserts the fixed binding and consumes a valid claim exactly once", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
const persisted = created.adapterConfig as { env: Record<string, unknown> };
|
||||
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({
|
||||
type: "user_secret_ref",
|
||||
key: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
});
|
||||
// The claim is consumed once.
|
||||
expect((await readClaim(sessionId))?.boundAt).not.toBeNull();
|
||||
|
||||
// A replay of the same claim inserts no second agent.
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect(await countAgents(scope.companyId)).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects a missing claim and inserts no binding", async () => {
|
||||
const scope = await seedScope();
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: undefined, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect(await countAgents(scope.companyId)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a foreign-owner claim and leaves it unconsumed", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: "intruder" },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect((await readClaim(sessionId))?.boundAt).toBeNull();
|
||||
expect(await countAgents(scope.companyId)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects an expired claim and leaves it unconsumed", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() - 1_000);
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect((await readClaim(sessionId))?.boundAt).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a non-stored claim and leaves it unconsumed", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "awaiting_code");
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect((await readClaim(sessionId))?.boundAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a byte-identical error for every reject reason", async () => {
|
||||
const messages: string[] = [];
|
||||
// Missing claim.
|
||||
const missingScope = await seedScope();
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(missingScope.companyId, createInput(missingScope), {
|
||||
claudeLogin: { storedSessionId: undefined, ownerUserId: missingScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
.catch((error: Error) => error.message),
|
||||
);
|
||||
// Foreign owner.
|
||||
const foreignScope = await seedScope();
|
||||
const foreignSession = await seedStoredClaim(foreignScope, Date.now() + 60_000);
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(foreignScope.companyId, createInput(foreignScope), {
|
||||
claudeLogin: { storedSessionId: foreignSession, ownerUserId: "intruder" },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
.catch((error: Error) => error.message),
|
||||
);
|
||||
// Expired.
|
||||
const expiredScope = await seedScope();
|
||||
const expiredSession = await seedStoredClaim(expiredScope, Date.now() - 1_000);
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(expiredScope.companyId, createInput(expiredScope), {
|
||||
claudeLogin: { storedSessionId: expiredSession, ownerUserId: expiredScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
.catch((error: Error) => error.message),
|
||||
);
|
||||
// Non-stored.
|
||||
const nonStoredScope = await seedScope();
|
||||
const nonStoredSession = await seedStoredClaim(nonStoredScope, Date.now() + 60_000, "submitting");
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(nonStoredScope.companyId, createInput(nonStoredScope), {
|
||||
claudeLogin: { storedSessionId: nonStoredSession, ownerUserId: nonStoredScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
.catch((error: Error) => error.message),
|
||||
);
|
||||
// Already consumed.
|
||||
const consumedScope = await seedScope();
|
||||
const consumedSession = await seedStoredClaim(consumedScope, Date.now() + 60_000);
|
||||
await agentService(db).create(consumedScope.companyId, createInput(consumedScope), {
|
||||
claudeLogin: { storedSessionId: consumedSession, ownerUserId: consumedScope.ownerUserId },
|
||||
});
|
||||
messages.push(
|
||||
await agentService(db)
|
||||
.create(consumedScope.companyId, createInput(consumedScope), {
|
||||
claudeLogin: { storedSessionId: consumedSession, ownerUserId: consumedScope.ownerUserId },
|
||||
})
|
||||
.then(() => "no-error")
|
||||
.catch((error: Error) => error.message),
|
||||
);
|
||||
|
||||
expect(new Set(messages)).toEqual(new Set([CLAUDE_OAUTH_CLAIM_REJECTED]));
|
||||
});
|
||||
|
||||
it("consumes one claim exactly once under two concurrent creates", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
}),
|
||||
]);
|
||||
|
||||
const fulfilled = results.filter((result) => result.status === "fulfilled");
|
||||
const rejected = results.filter((result) => result.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason.message).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
|
||||
expect(await countAgents(scope.companyId)).toBe(1);
|
||||
});
|
||||
|
||||
it("inserts no binding when the claim row lock holds until after the deadline", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 3_600_000);
|
||||
|
||||
const lockDb = createDb(connectionString);
|
||||
let signalLocked!: () => void;
|
||||
const locked = new Promise<void>((resolve) => {
|
||||
signalLocked = resolve;
|
||||
});
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
const lockHeld = lockDb.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`SELECT bound_at FROM claude_setup_token_sessions WHERE session_id = ${sessionId} FOR UPDATE`,
|
||||
);
|
||||
await tx.execute(
|
||||
sql`UPDATE claude_setup_token_sessions SET deadline_at = clock_timestamp() + interval '300 milliseconds' WHERE session_id = ${sessionId}`,
|
||||
);
|
||||
signalLocked();
|
||||
await gate;
|
||||
});
|
||||
|
||||
await locked;
|
||||
const createPromise = agentService(db)
|
||||
.create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
})
|
||||
.then(() => "created")
|
||||
.catch((error: Error) => error.message);
|
||||
await new Promise((resolve) => setTimeout(resolve, 600));
|
||||
releaseGate();
|
||||
await lockHeld;
|
||||
|
||||
expect(await createPromise).toBe(CLAUDE_OAUTH_CLAIM_REJECTED);
|
||||
expect((await readClaim(sessionId))?.boundAt).toBeNull();
|
||||
expect(await countAgents(scope.companyId)).toBe(0);
|
||||
await lockDb.$client.end();
|
||||
});
|
||||
|
||||
it("removes the fixed binding on a normal update", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
const updated = await agentService(db).update(created.id, { adapterConfig: { env: {} } });
|
||||
|
||||
const updatedEnv = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(updatedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it("re-points the binding to a plain value on a normal update", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
const updated = await agentService(db).update(created.id, {
|
||||
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { type: "plain", value: "sk-fake" } } },
|
||||
});
|
||||
|
||||
const updatedEnv = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(updatedEnv.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({ type: "plain", value: "sk-fake" });
|
||||
});
|
||||
|
||||
it("removes the binding by a move to another adapter type on a normal update", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
// The write moves the agent to the process adapter and drops the binding in
|
||||
// the same PATCH. The binding is a normal environment variable, so the write
|
||||
// succeeds and drops both the fixed binding and the claude_local adapter.
|
||||
const updated = await agentService(db).update(created.id, {
|
||||
adapterType: "process",
|
||||
adapterConfig: { env: {} },
|
||||
});
|
||||
|
||||
expect(updated?.adapterType).toBe("process");
|
||||
const updatedEnv = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(updatedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an existing binding when the update changes another field", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { storedSessionId: sessionId, ownerUserId: scope.ownerUserId },
|
||||
});
|
||||
|
||||
const updated = await agentService(db).update(created.id, {
|
||||
adapterConfig: { model: "claude-opus", env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING } } },
|
||||
});
|
||||
const env = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
|
||||
});
|
||||
|
||||
it("rejects a newly introduced binding on update, because it carries no claim", async () => {
|
||||
const scope = await seedScope();
|
||||
const created = await agentService(db).create(scope.companyId, {
|
||||
name: "Api Key Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: {} },
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
agentService(db).update(created.id, {
|
||||
adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING } } },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
|
||||
const reloaded = await agentService(db).getById(created.id);
|
||||
const reloadedEnv = (reloaded?.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(reloadedEnv.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- The user-actor apply-existing path (no login round trip) --------------
|
||||
|
||||
it("binds the fixed reference from a stored owner value with no claim", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
});
|
||||
|
||||
const persisted = created.adapterConfig as { env: Record<string, unknown> };
|
||||
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject({
|
||||
type: "user_secret_ref",
|
||||
key: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
});
|
||||
// The response carries the reference only. It carries no token value.
|
||||
expect(JSON.stringify(created)).not.toContain("sk-owner-token");
|
||||
expect(await countAgents(scope.companyId)).toBe(1);
|
||||
// The path consumes no setup-token claim, so it needs no login round trip.
|
||||
const claims = await db.select().from(claudeSetupTokenSessions);
|
||||
expect(claims).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("binds on a hire-shaped create that needs board approval", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
const created = await agentService(
|
||||
db,
|
||||
).create(scope.companyId, { ...createInput(scope), status: "pending_approval" }, {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
});
|
||||
|
||||
expect(created.status).toBe("pending_approval");
|
||||
const persisted = created.adapterConfig as { env: Record<string, unknown> };
|
||||
expect(persisted.env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
|
||||
});
|
||||
|
||||
it("binds the fixed reference on an update from a stored owner value with no claim", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
const created = await agentService(db).create(scope.companyId, {
|
||||
name: "Claude Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: {} },
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
|
||||
const updated = await agentService(db).update(
|
||||
created.id,
|
||||
{ adapterConfig: { env: { CLAUDE_CODE_OAUTH_TOKEN: { ...FIXED_BINDING } } } },
|
||||
{ claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true } },
|
||||
);
|
||||
|
||||
const env = (updated?.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toMatchObject(FIXED_BINDING);
|
||||
});
|
||||
|
||||
it("rejects apply-existing when the owner has no stored value and binds nothing", async () => {
|
||||
const scope = await seedScope();
|
||||
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
|
||||
// A rejected bind creates neither the agent nor the fixed definition.
|
||||
expect(await countAgents(scope.companyId)).toBe(0);
|
||||
expect(await countUserSecretDefinitions(scope.companyId)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects apply-existing when no owner is derived (an agent actor)", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
// The route sets no owner for an agent actor. The gate rejects the no-claim
|
||||
// bind, so an agent actor never binds the fixed reference.
|
||||
await expect(
|
||||
agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: null, applyExistingWithoutClaim: true },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect(await countAgents(scope.companyId)).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects apply-existing when the stored value belongs to another company", async () => {
|
||||
const ownerScope = await seedScope();
|
||||
await seedStoredOwnerValue(ownerScope);
|
||||
// The same owner, a different company. The owner value is company-scoped, so
|
||||
// the status read finds nothing for the foreign company.
|
||||
const foreignScope = await seedScope();
|
||||
foreignScope.ownerUserId = ownerScope.ownerUserId;
|
||||
|
||||
await expect(
|
||||
agentService(db).create(foreignScope.companyId, createInput(foreignScope), {
|
||||
claudeLogin: { ownerUserId: foreignScope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
}),
|
||||
).rejects.toMatchObject({ message: CLAUDE_OAUTH_CLAIM_REJECTED });
|
||||
expect(await countAgents(foreignScope.companyId)).toBe(0);
|
||||
});
|
||||
|
||||
it("binds nothing from the flag alone when the config carries no fixed binding", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
// The apply-existing flag is set, but the config carries no fixed binding. The
|
||||
// gate mints the fixed reference only when the client presents the exact fixed
|
||||
// binding, so the flag alone binds nothing and creates no fixed definition.
|
||||
const created = await agentService(db).create(
|
||||
scope.companyId,
|
||||
{
|
||||
name: "Plain Agent",
|
||||
role: "engineer",
|
||||
status: "idle",
|
||||
adapterType: "claude_local",
|
||||
defaultEnvironmentId: scope.environmentId,
|
||||
adapterConfig: { env: {} },
|
||||
runtimeConfig: {},
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
},
|
||||
{ claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true } },
|
||||
);
|
||||
|
||||
const env = (created.adapterConfig as { env: Record<string, unknown> }).env;
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
|
||||
expect(await countDeclarationsForAgent(created.id)).toBe(0);
|
||||
});
|
||||
|
||||
it("derives the persisted binding shape, so a client secret selector never rides through", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope);
|
||||
|
||||
// A client adds a secret id and a foreign owner to the binding. The persist
|
||||
// normalization keeps only the fixed reference fields, so the extra selectors
|
||||
// never reach the stored binding. The runtime derives the owner from context.
|
||||
const created = await agentService(db).create(
|
||||
scope.companyId,
|
||||
createInput(scope, {
|
||||
CLAUDE_CODE_OAUTH_TOKEN: {
|
||||
type: "user_secret_ref",
|
||||
key: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
secretId: "attacker-secret",
|
||||
ownerUserId: "intruder",
|
||||
},
|
||||
}),
|
||||
{ claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true } },
|
||||
);
|
||||
|
||||
const binding = (created.adapterConfig as { env: Record<string, Record<string, unknown>> }).env
|
||||
.CLAUDE_CODE_OAUTH_TOKEN;
|
||||
expect(binding).toMatchObject({ type: "user_secret_ref", key: "CLAUDE_CODE_OAUTH_TOKEN" });
|
||||
expect(binding.secretId).toBeUndefined();
|
||||
expect(binding.ownerUserId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves the bound reference to the stored value without leaking it in a log", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope, "sk-secret-resolve");
|
||||
const created = await agentService(db).create(scope.companyId, createInput(scope), {
|
||||
claudeLogin: { ownerUserId: scope.ownerUserId, applyExistingWithoutClaim: true },
|
||||
});
|
||||
|
||||
// The successful bind writes a declaration for the agent, so the fixed
|
||||
// reference resolves at runtime. The declaration and the definition carry no
|
||||
// token value.
|
||||
expect(await countDeclarationsForAgent(created.id)).toBe(1);
|
||||
const definitions = await db.select().from(userSecretDefinitions);
|
||||
expect(JSON.stringify(definitions)).not.toContain("sk-secret-resolve");
|
||||
});
|
||||
|
||||
// --- The atomic credential-claim writer (item 2) ---------------------------
|
||||
|
||||
function claimScope(scope: Scope): SetupTokenSessionScope {
|
||||
return {
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
targetAgentId: null,
|
||||
adapterType: "claude_local",
|
||||
environmentId: scope.environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
it("commits the stored transition and the secret together on a first write", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "submitting");
|
||||
const writer = createSetupTokenSecretWriter({ db });
|
||||
|
||||
await writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-FIRSTWRITE" });
|
||||
|
||||
// The durable row transitioned to `stored` and stays unconsumed.
|
||||
const claim = await readClaim(sessionId);
|
||||
expect(claim?.state).toBe("stored");
|
||||
expect(claim?.boundAt).toBeNull();
|
||||
// The owner value committed in the same transaction.
|
||||
const status = await secretService(db).readClaudeOAuthUserSecretStatus(
|
||||
scope.companyId,
|
||||
scope.ownerUserId,
|
||||
);
|
||||
expect(status).not.toBeNull();
|
||||
expect(status?.latestVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("rolls back the transition and writes no secret when the secret write fails", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "submitting");
|
||||
// Inject a completion that fails after the transition. The whole transaction
|
||||
// rolls back, so neither the claim nor the secret commits.
|
||||
const writer = createSetupTokenSecretWriter({
|
||||
db,
|
||||
completionForTx: () => ({
|
||||
async completeClaudeOAuthUserSecret() {
|
||||
throw new Error("storage down");
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-ROLLBACK" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
const claim = await readClaim(sessionId);
|
||||
expect(claim?.state).toBe("submitting");
|
||||
expect(claim?.boundAt).toBeNull();
|
||||
expect(
|
||||
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
|
||||
).toBeNull();
|
||||
expect(await countUserSecretDefinitions(scope.companyId)).toBe(0);
|
||||
});
|
||||
|
||||
it("writes no secret for an expired claim", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() - 1_000, "submitting");
|
||||
const writer = createSetupTokenSecretWriter({ db });
|
||||
|
||||
await expect(
|
||||
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-EXPIRED" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect((await readClaim(sessionId))?.state).toBe("submitting");
|
||||
expect(
|
||||
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("writes no secret for an already-consumed claim", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
// Consume the stored claim first, so `bound_at` is set and the predecessor
|
||||
// predicate no longer matches.
|
||||
await createDbSetupTokenCleanupStore(db).consumeStoredClaim({
|
||||
sessionId,
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
adapterType: "claude_local",
|
||||
environmentId: scope.environmentId,
|
||||
});
|
||||
const writer = createSetupTokenSecretWriter({ db });
|
||||
|
||||
await expect(
|
||||
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-BOUND" }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(
|
||||
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("rotates the stored value under the captured version on a confirmed overwrite", async () => {
|
||||
const scope = await seedScope();
|
||||
await seedStoredOwnerValue(scope, "sk-old-value");
|
||||
const before = await secretService(db).readClaudeOAuthUserSecretStatus(
|
||||
scope.companyId,
|
||||
scope.ownerUserId,
|
||||
);
|
||||
expect(before).not.toBeNull();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000, "submitting");
|
||||
const writer = createSetupTokenSecretWriter({ db });
|
||||
|
||||
await writer({
|
||||
scope: {
|
||||
...claimScope(scope),
|
||||
confirmedOverwrite: {
|
||||
expectedSecretId: before!.secretId,
|
||||
expectedLatestVersion: before!.latestVersion,
|
||||
},
|
||||
},
|
||||
sessionId,
|
||||
token: "sk-ant-oat01-ROTATED",
|
||||
});
|
||||
|
||||
// The claim transitioned and the stored value rotated to a new version.
|
||||
expect((await readClaim(sessionId))?.state).toBe("stored");
|
||||
const after = await secretService(db).readClaudeOAuthUserSecretStatus(
|
||||
scope.companyId,
|
||||
scope.ownerUserId,
|
||||
);
|
||||
expect(after?.secretId).toBe(before!.secretId);
|
||||
expect(after?.latestVersion).toBe(before!.latestVersion + 1);
|
||||
});
|
||||
|
||||
it("deletes a cleanup row only under the full owner scope", async () => {
|
||||
const scope = await seedScope();
|
||||
const sessionId = await seedStoredClaim(scope, Date.now() + 60_000);
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
const identity = {
|
||||
sessionId,
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
adapterType: "claude_local",
|
||||
environmentId: scope.environmentId,
|
||||
};
|
||||
|
||||
// A foreign-owner identity with the right session id does not delete the row.
|
||||
await store.remove({ ...identity, ownerUserId: "intruder" });
|
||||
expect(await readClaim(sessionId)).toBeTruthy();
|
||||
|
||||
// The full-scope identity deletes the row.
|
||||
await store.remove(identity);
|
||||
expect(await readClaim(sessionId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -196,32 +196,72 @@ describe("resolveBundledCatalogRoot", () => {
|
|||
// ensureBundledPlugins (fail-safe installer)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type LooseRow = {
|
||||
id: string;
|
||||
pluginKey: string;
|
||||
status: string;
|
||||
version?: string;
|
||||
manifestJson?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Build a minimal manifest for a persisted row or a shipped bundle. The reconcile
|
||||
// step compares the bundle version with the persisted version.
|
||||
function makeManifest(pluginKey: string, version: string) {
|
||||
return { id: pluginKey, apiVersion: 1, version } as unknown as import("@paperclipai/shared").PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
function makeDeps(overrides?: {
|
||||
rows?: Record<string, { id: string; pluginKey: string; status: string } | null>;
|
||||
rows?: Record<string, LooseRow | null>;
|
||||
bundleManifestExists?: (localPath: string) => boolean;
|
||||
installError?: Error;
|
||||
/** Version the shipped bundle manifest reports, keyed by pluginKey. */
|
||||
bundleVersionByKey?: Record<string, string>;
|
||||
}) {
|
||||
const rows = overrides?.rows ?? {};
|
||||
const installedRows = new Map(Object.entries(rows));
|
||||
const normalize = (row: LooseRow | null): (LooseRow & { version: string; manifestJson: Record<string, unknown> }) | null => {
|
||||
if (!row) return null;
|
||||
const version = row.version ?? "0.1.0";
|
||||
return {
|
||||
...row,
|
||||
version,
|
||||
manifestJson: row.manifestJson ?? { id: row.pluginKey, apiVersion: 1, version },
|
||||
};
|
||||
};
|
||||
const installedRows = new Map(
|
||||
Object.entries(rows).map(([key, row]) => [key, normalize(row)] as const),
|
||||
);
|
||||
const installPlugin = vi.fn(async ({ localPath }: { localPath: string }) => {
|
||||
if (overrides?.installError) throw overrides.installError;
|
||||
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) =>
|
||||
localPath.endsWith(candidate.relativePath),
|
||||
);
|
||||
const pluginKey = entry?.pluginKey ?? "unknown";
|
||||
installedRows.set(pluginKey, { id: `id-${pluginKey}`, pluginKey, status: "installed" });
|
||||
installedRows.set(pluginKey, normalize({ id: `id-${pluginKey}`, pluginKey, status: "installed" }));
|
||||
return { manifest: { id: pluginKey } };
|
||||
});
|
||||
const update = vi.fn(async () => undefined);
|
||||
const loadManifest = vi.fn(async (localPath: string) => {
|
||||
const entry = BUNDLED_PLUGIN_CATALOG.find((candidate) =>
|
||||
localPath.endsWith(candidate.relativePath),
|
||||
);
|
||||
const pluginKey = entry?.pluginKey ?? "unknown";
|
||||
const persisted = installedRows.get(pluginKey);
|
||||
// Default: the bundle version matches the persisted version, so no
|
||||
// reconcile fires unless a test overrides `bundleVersionByKey`.
|
||||
const version = overrides?.bundleVersionByKey?.[pluginKey] ?? persisted?.version ?? "0.1.0";
|
||||
return makeManifest(pluginKey, version);
|
||||
});
|
||||
const deps: BundledPluginProvisionerDeps = {
|
||||
registry: {
|
||||
getByKey: vi.fn(async (pluginKey: string) => installedRows.get(pluginKey) ?? null),
|
||||
},
|
||||
loader: { installPlugin } as unknown as BundledPluginProvisionerDeps["loader"],
|
||||
update,
|
||||
} as unknown as BundledPluginProvisionerDeps["registry"],
|
||||
loader: { installPlugin, loadManifest } as unknown as BundledPluginProvisionerDeps["loader"],
|
||||
lifecycle: { load: vi.fn(async () => undefined) },
|
||||
logger: { info: vi.fn(), error: vi.fn() },
|
||||
bundleManifestExists: overrides?.bundleManifestExists ?? (() => true),
|
||||
};
|
||||
return { deps, installPlugin };
|
||||
return { deps, installPlugin, update, loadManifest };
|
||||
}
|
||||
|
||||
const K8S: ResolvedBundledPlugin = {
|
||||
|
|
@ -258,6 +298,64 @@ describe("ensureBundledPlugins", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("reconciles the persisted manifest of a present plugin when the bundle version changed", async () => {
|
||||
const { deps, installPlugin, update } = makeDeps({
|
||||
rows: {
|
||||
[DAYTONA.pluginKey]: {
|
||||
id: "row-daytona",
|
||||
pluginKey: DAYTONA.pluginKey,
|
||||
status: "ready",
|
||||
version: "0.1.1",
|
||||
},
|
||||
},
|
||||
bundleVersionByKey: { [DAYTONA.pluginKey]: "0.1.2" },
|
||||
});
|
||||
await ensureBundledPlugins([DAYTONA], deps, { reinstallUninstalled: false });
|
||||
// The plugin stays present, so it is never re-installed.
|
||||
expect(installPlugin).not.toHaveBeenCalled();
|
||||
// The persisted manifest is refreshed to the shipped bundle version, so a
|
||||
// capability added to the bundle reaches the existing install.
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
"row-daytona",
|
||||
expect.objectContaining({ version: "0.1.2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reconcile when the persisted version already matches the bundle", async () => {
|
||||
const { deps, update } = makeDeps({
|
||||
rows: {
|
||||
[DAYTONA.pluginKey]: {
|
||||
id: "row-daytona",
|
||||
pluginKey: DAYTONA.pluginKey,
|
||||
status: "ready",
|
||||
version: "0.1.2",
|
||||
},
|
||||
},
|
||||
bundleVersionByKey: { [DAYTONA.pluginKey]: "0.1.2" },
|
||||
});
|
||||
await ensureBundledPlugins([DAYTONA], deps, { reinstallUninstalled: false });
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows a reconcile error and continues boot", async () => {
|
||||
const { deps, update } = makeDeps({
|
||||
rows: {
|
||||
[DAYTONA.pluginKey]: {
|
||||
id: "row-daytona",
|
||||
pluginKey: DAYTONA.pluginKey,
|
||||
status: "ready",
|
||||
version: "0.1.1",
|
||||
},
|
||||
},
|
||||
bundleVersionByKey: { [DAYTONA.pluginKey]: "0.1.2" },
|
||||
});
|
||||
(update as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error("db down"));
|
||||
await expect(
|
||||
ensureBundledPlugins([DAYTONA], deps, { reinstallUninstalled: false }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(deps.logger.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reinstalls a soft-uninstalled plugin in managed mode", async () => {
|
||||
const { deps, installPlugin } = makeDeps({
|
||||
rows: {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const mockEnvironmentService = vi.hoisted(() => ({
|
|||
update: vi.fn(),
|
||||
removeIfDeletable: vi.fn(),
|
||||
getDeleteBlastRadius: vi.fn(),
|
||||
hasUnresolvedPendingCleanupLeases: vi.fn(),
|
||||
listLeases: vi.fn(),
|
||||
getLeaseById: vi.fn(),
|
||||
}));
|
||||
|
|
@ -150,6 +151,8 @@ function createDeleteBlastRadius(overrides: Partial<{
|
|||
secretBindingCount: number;
|
||||
activeLeaseCount: number;
|
||||
activeCustomImageSetupSessionCount: number;
|
||||
pendingCleanupLeaseCount: number;
|
||||
reusableSandboxLeaseCount: number;
|
||||
}> = {}) {
|
||||
const staticReferences = {
|
||||
isManagedLocal: overrides.isManagedLocal ?? false,
|
||||
|
|
@ -167,14 +170,20 @@ function createDeleteBlastRadius(overrides: Partial<{
|
|||
(overrides.activeLeaseCount ?? 0) > 0
|
||||
|| (overrides.activeCustomImageSetupSessionCount ?? 0) > 0,
|
||||
};
|
||||
const pendingCleanupLeaseCount = overrides.pendingCleanupLeaseCount ?? 0;
|
||||
const reusableSandboxLeaseCount = overrides.reusableSandboxLeaseCount ?? 0;
|
||||
const deleteBlockedReasons = [
|
||||
...(staticReferences.isManagedLocal ? ["managed_local" as const] : []),
|
||||
...(staticReferences.isInstanceDefault ? ["instance_default" as const] : []),
|
||||
...(pendingCleanupLeaseCount > 0 ? ["pending_sandbox_cleanup" as const] : []),
|
||||
...(reusableSandboxLeaseCount > 0 ? ["reusable_sandbox_lease" as const] : []),
|
||||
];
|
||||
return {
|
||||
environmentId: "env-1",
|
||||
canDelete: deleteBlockedReasons.length === 0,
|
||||
deleteBlockedReasons,
|
||||
pendingCleanupLeaseCount,
|
||||
reusableSandboxLeaseCount,
|
||||
staticReferences,
|
||||
activeRuntimeUse,
|
||||
};
|
||||
|
|
@ -255,6 +264,8 @@ describe("environment routes", () => {
|
|||
mockEnvironmentService.update.mockReset();
|
||||
mockEnvironmentService.removeIfDeletable.mockReset();
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockReset();
|
||||
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockReset();
|
||||
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(false);
|
||||
mockEnvironmentService.listLeases.mockReset();
|
||||
mockEnvironmentService.getLeaseById.mockReset();
|
||||
mockExecutionWorkspaceService.clearEnvironmentSelection.mockReset();
|
||||
|
|
@ -1078,6 +1089,8 @@ describe("environment routes", () => {
|
|||
environmentId: "env-1",
|
||||
canDelete: true,
|
||||
deleteBlockedReasons: [],
|
||||
pendingCleanupLeaseCount: 0,
|
||||
reusableSandboxLeaseCount: 0,
|
||||
staticReferences: {
|
||||
isManagedLocal: false,
|
||||
isInstanceDefault: false,
|
||||
|
|
@ -1522,6 +1535,153 @@ describe("environment routes", () => {
|
|||
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects deleting an environment with a pending sandbox cleanup", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
||||
pendingCleanupLeaseCount: 1,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe(
|
||||
"Cannot delete this environment while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.",
|
||||
);
|
||||
expect(res.body.details).toEqual({ deleteBlockedReasons: ["pending_sandbox_cleanup"] });
|
||||
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects deleting an environment with a live reusable sandbox lease", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
driver: "sandbox" as const,
|
||||
name: "Reusable Sandbox Fixture",
|
||||
config: {
|
||||
provider: "fake-plugin",
|
||||
image: "fixture:test",
|
||||
reuseLease: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.getDeleteBlastRadius.mockResolvedValue(createDeleteBlastRadius({
|
||||
activeLeaseCount: 1,
|
||||
reusableSandboxLeaseCount: 1,
|
||||
}));
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app).delete("/api/environments/env-1");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe(
|
||||
"Cannot delete this environment while it has a reusable sandbox lease. Remove the associated execution workspace or issue so Paperclip can destroy the sandbox, then retry.",
|
||||
);
|
||||
expect(res.body.details).toEqual({ deleteBlockedReasons: ["reusable_sandbox_lease"] });
|
||||
expect(mockEnvironmentService.removeIfDeletable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a driver or provider config change while a sandbox cleanup is pending", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true);
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-ssh")
|
||||
.send({
|
||||
config: {
|
||||
host: "changed.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.error).toBe(
|
||||
"Cannot change the driver or provider config while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.",
|
||||
);
|
||||
expect(res.body.details).toEqual({ code: "environment_pending_sandbox_cleanup" });
|
||||
expect(mockEnvironmentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a non-provider update while a sandbox cleanup is pending", async () => {
|
||||
const environment = {
|
||||
...createEnvironment(),
|
||||
id: "env-ssh",
|
||||
driver: "ssh" as const,
|
||||
name: "SSH Fixture",
|
||||
config: {
|
||||
host: "ssh.example.test",
|
||||
port: 22,
|
||||
username: "ssh-user",
|
||||
remoteWorkspacePath: "/srv/paperclip/workspace",
|
||||
privateKey: null,
|
||||
privateKeySecretRef: null,
|
||||
knownHosts: null,
|
||||
strictHostKeyChecking: true,
|
||||
},
|
||||
};
|
||||
mockEnvironmentService.getById.mockResolvedValue(environment);
|
||||
mockEnvironmentService.hasUnresolvedPendingCleanupLeases.mockResolvedValue(true);
|
||||
mockEnvironmentService.update.mockResolvedValue({ ...environment, description: "Updated" });
|
||||
const app = createApp({
|
||||
type: "board",
|
||||
userId: "admin-1",
|
||||
source: "local_implicit",
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch("/api/environments/env-ssh")
|
||||
.send({ description: "Updated" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockEnvironmentService.hasUnresolvedPendingCleanupLeases).not.toHaveBeenCalled();
|
||||
expect(mockEnvironmentService.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("describes an environment's secret refs with owner metadata", async () => {
|
||||
const secretId = "22222222-2222-2222-2222-222222222222";
|
||||
mockEnvironmentService.getById.mockResolvedValue({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -397,6 +397,8 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
|||
environmentId,
|
||||
canDelete: false,
|
||||
deleteBlockedReasons: ["instance_default"],
|
||||
pendingCleanupLeaseCount: 0,
|
||||
reusableSandboxLeaseCount: 0,
|
||||
staticReferences: {
|
||||
isManagedLocal: false,
|
||||
isInstanceDefault: true,
|
||||
|
|
@ -488,6 +490,374 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
|||
expect(deletedRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("blocks delete while a pending_cleanup lease still holds the sandbox reference", async () => {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Pending Cleanup Guard",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {
|
||||
host: "pending.example.test",
|
||||
port: 22,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await svc.insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "fake-plugin",
|
||||
providerLeaseId: "sandbox-1",
|
||||
failureReason: "teardown_failed",
|
||||
});
|
||||
|
||||
const impact = await svc.getDeleteBlastRadius(environmentId);
|
||||
expect(impact?.canDelete).toBe(false);
|
||||
expect(impact?.deleteBlockedReasons).toContain("pending_sandbox_cleanup");
|
||||
expect(impact?.pendingCleanupLeaseCount).toBe(1);
|
||||
expect(await svc.hasUnresolvedPendingCleanupLeases(environmentId)).toBe(true);
|
||||
|
||||
const removed = await svc.removeIfDeletable(environmentId);
|
||||
const rows = await db.select().from(environments).where(eq(environments.id, environmentId));
|
||||
expect(removed).toBeNull();
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("atomically blocks delete while a reusable sandbox lease is still live", async () => {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Reusable Sandbox Guard",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: {
|
||||
provider: "fake",
|
||||
image: "ubuntu:24.04",
|
||||
reuseLease: true,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const lease = await svc.acquireLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
leasePolicy: "reuse_by_environment",
|
||||
provider: "fake",
|
||||
providerLeaseId: "sandbox-reusable-1",
|
||||
metadata: {
|
||||
driver: "sandbox",
|
||||
provider: "fake",
|
||||
image: "ubuntu:24.04",
|
||||
reuseLease: true,
|
||||
},
|
||||
});
|
||||
|
||||
const activeImpact = await svc.getDeleteBlastRadius(environmentId);
|
||||
expect(activeImpact?.canDelete).toBe(false);
|
||||
expect(activeImpact?.deleteBlockedReasons).toContain("reusable_sandbox_lease");
|
||||
expect(activeImpact?.reusableSandboxLeaseCount).toBe(1);
|
||||
expect(await svc.removeIfDeletable(environmentId)).toBeNull();
|
||||
|
||||
// A released reusable lease still owns a provider sandbox that may be
|
||||
// resumed, so it must remain protected until scoped cleanup destroys it.
|
||||
await svc.releaseLease(lease.id, "released");
|
||||
const releasedImpact = await svc.getDeleteBlastRadius(environmentId);
|
||||
expect(releasedImpact?.canDelete).toBe(false);
|
||||
expect(releasedImpact?.reusableSandboxLeaseCount).toBe(1);
|
||||
expect(await svc.removeIfDeletable(environmentId)).toBeNull();
|
||||
|
||||
const rows = await db.select().from(environments).where(eq(environments.id, environmentId));
|
||||
expect(rows).toHaveLength(1);
|
||||
const storedLease = await svc.getLeaseById(lease.id);
|
||||
expect(storedLease?.environmentId).toBe(environmentId);
|
||||
});
|
||||
|
||||
it("allows delete with an active ephemeral lease", async () => {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Ephemeral Sandbox",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "fake", image: "ubuntu:24.04" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await svc.acquireLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
leasePolicy: "ephemeral",
|
||||
provider: "fake",
|
||||
providerLeaseId: "sandbox-ephemeral-1",
|
||||
});
|
||||
|
||||
const impact = await svc.getDeleteBlastRadius(environmentId);
|
||||
expect(impact?.activeRuntimeUse.activeLeaseCount).toBe(1);
|
||||
expect(impact?.reusableSandboxLeaseCount).toBe(0);
|
||||
expect(impact?.canDelete).toBe(true);
|
||||
expect((await svc.removeIfDeletable(environmentId))?.id).toBe(environmentId);
|
||||
});
|
||||
|
||||
it("allows delete once the pending_cleanup lease resolves", async () => {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Resolved Cleanup Guard",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {
|
||||
host: "resolved.example.test",
|
||||
port: 22,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
const lease = await svc.insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "fake-plugin",
|
||||
providerLeaseId: "sandbox-1",
|
||||
failureReason: "teardown_failed",
|
||||
});
|
||||
// The sweep marks a cleaned orphan `expired`, so it leaves the terminal
|
||||
// `pending_cleanup` state and no longer blocks the delete.
|
||||
await svc.releaseLease(lease.id, "expired", { cleanupStatus: "success" });
|
||||
|
||||
const impact = await svc.getDeleteBlastRadius(environmentId);
|
||||
expect(impact?.canDelete).toBe(true);
|
||||
expect(impact?.pendingCleanupLeaseCount).toBe(0);
|
||||
expect(impact?.reusableSandboxLeaseCount).toBe(0);
|
||||
expect(await svc.hasUnresolvedPendingCleanupLeases(environmentId)).toBe(false);
|
||||
|
||||
const removed = await svc.removeIfDeletable(environmentId);
|
||||
const rows = await db.select().from(environments).where(eq(environments.id, environmentId));
|
||||
expect(removed?.id).toBe(environmentId);
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records the orphan with a null reference when a delete already removed the environment", async () => {
|
||||
// A delete wins the untracked window before the orphan record lands. The
|
||||
// record must still persist, so the sweep keeps a durable handle to the
|
||||
// remote sandbox.
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Deleted Before Record",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {
|
||||
host: "deleted.example.test",
|
||||
port: 22,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const removed = await svc.removeIfDeletable(environmentId);
|
||||
expect(removed?.id).toBe(environmentId);
|
||||
|
||||
// The foreign key does not reject the insert. The insert reads the absent
|
||||
// environment and stores a null reference with the immutable provider
|
||||
// metadata, so the teardown still runs later.
|
||||
const lease = await svc.insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "fake-plugin",
|
||||
providerLeaseId: "sandbox-orphan-1",
|
||||
metadata: { provider: "fake-plugin", config: { provider: "fake-plugin" } },
|
||||
failureReason: "acquire_rejected_teardown_failed",
|
||||
});
|
||||
|
||||
expect(lease.environmentId).toBeNull();
|
||||
expect(lease.status).toBe("pending_cleanup");
|
||||
expect(lease.provider).toBe("fake-plugin");
|
||||
expect(lease.providerLeaseId).toBe("sandbox-orphan-1");
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, lease.id));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.environmentId).toBeNull();
|
||||
expect(rows[0]?.metadata).toMatchObject({ provider: "fake-plugin" });
|
||||
});
|
||||
|
||||
it("keeps the orphan when an environment delete and the orphan record race", async () => {
|
||||
// Race the delete against the orphan record. The record locks the
|
||||
// environment row `for update`, and the delete refuses while a
|
||||
// `pending_cleanup` lease exists, so the two writes serialize into one of
|
||||
// two safe outcomes. The orphan row persists in both.
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Delete Vs Record Race",
|
||||
driver: "ssh",
|
||||
status: "active",
|
||||
config: {
|
||||
host: "race.example.test",
|
||||
port: 22,
|
||||
username: "fixture",
|
||||
remoteWorkspacePath: "/srv/paperclip",
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const [removed, lease] = await Promise.all([
|
||||
svc.removeIfDeletable(environmentId),
|
||||
svc.insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "fake-plugin",
|
||||
providerLeaseId: "sandbox-orphan-2",
|
||||
metadata: { provider: "fake-plugin", config: { provider: "fake-plugin" } },
|
||||
failureReason: "acquire_rejected_teardown_failed",
|
||||
}),
|
||||
]);
|
||||
|
||||
// The orphan row always persists, so no ordering strands the sandbox.
|
||||
const leaseRows = await db
|
||||
.select()
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.status, "pending_cleanup"));
|
||||
expect(leaseRows).toHaveLength(1);
|
||||
expect(leaseRows[0]?.id).toBe(lease.id);
|
||||
expect(leaseRows[0]?.provider).toBe("fake-plugin");
|
||||
|
||||
const environmentRows = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(eq(environments.id, environmentId));
|
||||
if (removed) {
|
||||
// The delete won. The environment is gone, and the orphan keeps a null
|
||||
// reference with its immutable provider metadata.
|
||||
expect(environmentRows).toHaveLength(0);
|
||||
expect(leaseRows[0]?.environmentId).toBeNull();
|
||||
} else {
|
||||
// The record won. The orphan keeps the environment reference, and the
|
||||
// delete refused because the `pending_cleanup` lease now exists.
|
||||
expect(environmentRows).toHaveLength(1);
|
||||
expect(leaseRows[0]?.environmentId).toBe(environmentId);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the recorded provider immutable when a provider change and the orphan record race", async () => {
|
||||
// Race a provider change against the orphan record for the original
|
||||
// provider. The recorded lease must keep the original provider and its
|
||||
// immutable teardown metadata, so a later retry targets the original
|
||||
// provider, never the reconfigured one.
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
const now = new Date();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: "Provider Change Vs Record Race",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "provider-a" },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const [, lease] = await Promise.all([
|
||||
svc.update(environmentId, {
|
||||
driver: "sandbox",
|
||||
config: { provider: "provider-b" },
|
||||
}),
|
||||
svc.insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
provider: "provider-a",
|
||||
providerLeaseId: "sandbox-orphan-3",
|
||||
metadata: { provider: "provider-a", config: { provider: "provider-a" } },
|
||||
failureReason: "acquire_rejected_teardown_failed",
|
||||
}),
|
||||
]);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, lease.id));
|
||||
expect(rows[0]?.provider).toBe("provider-a");
|
||||
expect(rows[0]?.metadata).toMatchObject({ provider: "provider-a" });
|
||||
|
||||
// The environment now points at the reconfigured provider, but the recorded
|
||||
// teardown context stays independent of it.
|
||||
const environmentRows = await db
|
||||
.select()
|
||||
.from(environments)
|
||||
.where(eq(environments.id, environmentId));
|
||||
expect((environmentRows[0]?.config as { provider?: string } | null)?.provider).toBe("provider-b");
|
||||
});
|
||||
|
||||
it("creates and then reuses the default local environment for a company", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
|
@ -783,6 +1153,47 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
|||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lists the companies that own a managed sandbox environment", async () => {
|
||||
const companyA = randomUUID();
|
||||
const companyB = randomUUID();
|
||||
await db.insert(companies).values([
|
||||
{ id: companyA, name: "Company A", status: "active", issuePrefix: "CA", createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: companyB, name: "Company B", status: "active", issuePrefix: "CB", createdAt: new Date(), updatedAt: new Date() },
|
||||
]);
|
||||
// Two companies provision the same managed sandbox slot, so both bind to the
|
||||
// one environment row.
|
||||
const created = await svc.ensureManagedSandboxEnvironment({
|
||||
companyId: companyA,
|
||||
name: "Daytona",
|
||||
provider: "daytona",
|
||||
config: { target: "us" },
|
||||
});
|
||||
const environmentId = created.environment.id;
|
||||
await svc.ensureManagedSandboxEnvironment({
|
||||
companyId: companyB,
|
||||
name: "Daytona",
|
||||
provider: "daytona",
|
||||
config: { target: "us" },
|
||||
});
|
||||
|
||||
const owners = await svc.listBoundCompanyIds(environmentId);
|
||||
expect(owners.slice().sort()).toEqual([companyA, companyB].sort());
|
||||
|
||||
// An operator-created environment has no managed binding, so it lists no
|
||||
// owner and stays instance-global.
|
||||
const unboundId = randomUUID();
|
||||
await db.insert(environments).values({
|
||||
id: unboundId,
|
||||
name: "Operator sandbox",
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "kubernetes" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
expect(await svc.listBoundCompanyIds(unboundId)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves tenant env vars across managed sandbox boot reconciles", async () => {
|
||||
// The cloud contract lets tenants add env vars — and only env vars —
|
||||
// to the managed sandbox environment. The provisioner reconciles
|
||||
|
|
@ -1348,4 +1759,104 @@ describeEmbeddedPostgres("environmentService leases", () => {
|
|||
const rows = await db.select().from(environments);
|
||||
expect(rows.filter((row) => row.driver === "ssh")).toHaveLength(2);
|
||||
});
|
||||
|
||||
describe("acquireLease company-binding guard", () => {
|
||||
async function seedSandboxEnvironment(name = "Managed Sandbox") {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "daytona" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return { companyId, environmentId };
|
||||
}
|
||||
|
||||
async function bindEnvironmentToCompany(environmentId: string, companyId: string) {
|
||||
await db.insert(builtInManagedResources).values({
|
||||
companyId,
|
||||
bundleKey: "managed-environment",
|
||||
resourceKind: "environment",
|
||||
resourceKey: "managed-sandbox",
|
||||
resourceId: environmentId,
|
||||
stockVersion: "1",
|
||||
stockHash: "hash",
|
||||
});
|
||||
}
|
||||
|
||||
it("rejects an acquire when a bind lands after the route check", async () => {
|
||||
const { companyId, environmentId } = await seedSandboxEnvironment();
|
||||
// The other company owns a managed binding that a reconciliation committed
|
||||
// after the route guard read an empty binding list.
|
||||
const otherCompanyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: otherCompanyId,
|
||||
name: "Other Co",
|
||||
issuePrefix: "OTA",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await bindEnvironmentToCompany(environmentId, otherCompanyId);
|
||||
|
||||
await expect(
|
||||
svc.acquireLease({ companyId, environmentId, assertCompanyBinding: true }),
|
||||
).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "environment_company_mismatch" },
|
||||
});
|
||||
// The insert never ran, so the caller holds no lease.
|
||||
const leaseRows = await db
|
||||
.select()
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.environmentId, environmentId));
|
||||
expect(leaseRows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("acquires when the environment is unbound (instance-global)", async () => {
|
||||
const { companyId, environmentId } = await seedSandboxEnvironment("Unbound Sandbox");
|
||||
|
||||
const lease = await svc.acquireLease({ companyId, environmentId, assertCompanyBinding: true });
|
||||
expect(lease.status).toBe("active");
|
||||
expect(lease.companyId).toBe(companyId);
|
||||
});
|
||||
|
||||
it("acquires when the environment is bound to the caller company", async () => {
|
||||
const { companyId, environmentId } = await seedSandboxEnvironment("Own Sandbox");
|
||||
await bindEnvironmentToCompany(environmentId, companyId);
|
||||
|
||||
const lease = await svc.acquireLease({ companyId, environmentId, assertCompanyBinding: true });
|
||||
expect(lease.status).toBe("active");
|
||||
});
|
||||
|
||||
it("does not check the binding for callers that omit the flag", async () => {
|
||||
const { companyId, environmentId } = await seedSandboxEnvironment("Legacy Path Sandbox");
|
||||
const otherCompanyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: otherCompanyId,
|
||||
name: "Other Co 2",
|
||||
issuePrefix: "OTB",
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await bindEnvironmentToCompany(environmentId, otherCompanyId);
|
||||
|
||||
// The heartbeat run path does not set the flag, so the binding check does
|
||||
// not run and the lease acquires as before.
|
||||
const lease = await svc.acquireLease({ companyId, environmentId });
|
||||
expect(lease.status).toBe("active");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
// Test worker fixture for the host-owned setup-token login pseudo-terminal route
|
||||
// gate. The fixture drives the manager route state machine through
|
||||
// the four typed methods (open, input, stop, close) and the output and exit
|
||||
// notifications.
|
||||
//
|
||||
// The manager allowlists `command` to the fixed `CLAUDE_SETUP_TOKEN_COMMAND`. The
|
||||
// test encodes a JSON directive in the forwarded `providerLeaseId`, so one fixture
|
||||
// serves every route-gate case:
|
||||
// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply"
|
||||
// - `workerSessionId`: the worker session id the open reply returns (default "ws-1")
|
||||
// - `outputs`: an array of `{ chunk, sid? }`. The fixture emits each as an output
|
||||
// notification after the open reply. `sid` defaults to the real worker session
|
||||
// id; a test sets a wrong `sid` to prove the host drops a mismatched
|
||||
// notification.
|
||||
// - `exitCode`: when set, the fixture emits an exit notification after the outputs.
|
||||
// - `closeMode`: "ack" | "bad-ack" | "no-ack" (default "ack"). It controls the
|
||||
// close reply, so a test proves the host retires the worker on an unconfirmed
|
||||
// close.
|
||||
const readline = require("node:readline");
|
||||
|
||||
function send(message) {
|
||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
// The registered terminals, keyed by the host route id. Each entry records the
|
||||
// bound worker session id and the close directive.
|
||||
const routes = new Map();
|
||||
|
||||
function parseDirective(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
const message = JSON.parse(line);
|
||||
const method = message && typeof message.method === "string" ? message.method : null;
|
||||
const params = message.params ?? {};
|
||||
|
||||
if (method === "initialize") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
result: {
|
||||
ok: true,
|
||||
supportedMethods: [
|
||||
"setupTokenPtyOpen",
|
||||
"setupTokenPtyInput",
|
||||
"setupTokenPtyStop",
|
||||
"setupTokenPtyClose",
|
||||
],
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyOpen") {
|
||||
const directive = parseDirective(params.providerLeaseId);
|
||||
const mode = directive.mode ?? "normal";
|
||||
const workerSessionId = directive.workerSessionId ?? "ws-1";
|
||||
const closeMode = directive.closeMode ?? "ack";
|
||||
routes.set(params.hostRouteId, { workerSessionId, closeMode });
|
||||
|
||||
if (mode === "no-open-reply") {
|
||||
// Never reply, so the host open call times out.
|
||||
return;
|
||||
}
|
||||
if (mode === "malformed-open") {
|
||||
// Reply with no worker session id, so the host terminalizes the route.
|
||||
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
||||
return;
|
||||
}
|
||||
|
||||
const reply = () =>
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { workerSessionId } });
|
||||
reply();
|
||||
if (mode === "duplicate-open-reply") {
|
||||
// Send a second open reply for the same request id. The host drops it.
|
||||
reply();
|
||||
}
|
||||
|
||||
// Emit the scripted output and the exit after the open reply, so the host
|
||||
// binds the route first.
|
||||
setImmediate(() => {
|
||||
const outputs = Array.isArray(directive.outputs) ? directive.outputs : [];
|
||||
for (const entry of outputs) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.output",
|
||||
params: {
|
||||
workerSessionId: entry.sid ?? workerSessionId,
|
||||
chunk: entry.chunk,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (typeof directive.exitCode === "number") {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.exit",
|
||||
params: { workerSessionId, exitCode: directive.exitCode },
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyInput") {
|
||||
// Echo the input back as one output notification for the bound session, so a
|
||||
// test proves the input reaches the worker and the output routes back.
|
||||
for (const entry of routes.values()) {
|
||||
if (entry.workerSessionId === params.workerSessionId) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "setupTokenPty.output",
|
||||
params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyStop") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "setupTokenPtyClose") {
|
||||
const entry = routes.get(params.hostRouteId);
|
||||
routes.delete(params.hostRouteId);
|
||||
const closeMode = entry ? entry.closeMode : "ack";
|
||||
if (closeMode === "no-ack") {
|
||||
// Never reply, so the host close call times out and the host retires us.
|
||||
return;
|
||||
}
|
||||
if (closeMode === "bad-ack") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: "mismatched-route" } });
|
||||
return;
|
||||
}
|
||||
send({ jsonrpc: "2.0", id: message.id, result: { hostRouteId: params.hostRouteId } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "shutdown") {
|
||||
send({ jsonrpc: "2.0", id: message.id, result: {} });
|
||||
setImmediate(() => process.exit(0));
|
||||
return;
|
||||
}
|
||||
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
id: message.id,
|
||||
error: { code: -32601, message: `Unhandled method: ${method}` },
|
||||
});
|
||||
});
|
||||
|
|
@ -49,6 +49,8 @@ if (!embeddedPostgresSupport.supported) {
|
|||
const ATTEMPTS_KEY = "pendingCleanupRetryAttempts";
|
||||
const CAP_WARNED_KEY = "pendingCleanupRetryCapWarned";
|
||||
const ATTEMPT_CAP = 5;
|
||||
// The sweep reads at most this many oldest pending_cleanup rows per tick.
|
||||
const SWEEP_PAGE_SIZE = 20;
|
||||
|
||||
describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
|
@ -98,7 +100,7 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
|
|||
|
||||
async function insertPendingCleanupLease(input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
environmentId: string | null;
|
||||
updatedAt: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<string> {
|
||||
|
|
@ -128,6 +130,34 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
|
|||
return { destroyRunLease } as unknown as HeartbeatEnvironmentRuntime;
|
||||
}
|
||||
|
||||
// An orphan ephemeral lease that a failed acquire records. The sweep tears it
|
||||
// down through retryPendingSandboxTeardown, not through destroyRunLease.
|
||||
async function insertOrphanEphemeralLease(input: {
|
||||
companyId: string;
|
||||
environmentId: string | null;
|
||||
updatedAt: Date;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<string> {
|
||||
const id = randomUUID();
|
||||
await db.insert(environmentLeases).values({
|
||||
id,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
status: "pending_cleanup",
|
||||
leasePolicy: "ephemeral",
|
||||
provider: "fake",
|
||||
providerLeaseId: `sandbox://fake/${id}`,
|
||||
cleanupStatus: "failed",
|
||||
metadata: { driver: "sandbox", provider: "fake", ...(input.metadata ?? {}) },
|
||||
acquiredAt: input.updatedAt,
|
||||
lastUsedAt: input.updatedAt,
|
||||
releasedAt: input.updatedAt,
|
||||
createdAt: input.updatedAt,
|
||||
updatedAt: input.updatedAt,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
it("test_pending_cleanup_sweep_retries_and_destroys_lease", async () => {
|
||||
const { companyId, environmentId } = await seedCompanyAndEnvironment();
|
||||
const leaseId = await insertPendingCleanupLease({
|
||||
|
|
@ -301,6 +331,345 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
|
|||
expect(metadata?.status).toBe("pending_cleanup");
|
||||
});
|
||||
|
||||
// Two unified sweeps can overlap on one orphan ephemeral lease. The master
|
||||
// sweep is the single owner of the pending_cleanup rows. Its atomic per-attempt
|
||||
// claim must let only one sweep tear the sandbox down, and the winning sweep
|
||||
// must leave the lease in a final successful state.
|
||||
it("test_concurrent_unified_sweeps_tear_an_orphan_down_once", async () => {
|
||||
const { companyId, environmentId } = await seedCompanyAndEnvironment();
|
||||
const leaseId = await insertOrphanEphemeralLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
// The orphan teardown succeeds and does not release the lease; the sweep
|
||||
// releases it. The counter records how many sweeps reached the teardown.
|
||||
let teardownCount = 0;
|
||||
const retryPendingSandboxTeardown = vi.fn(async () => {
|
||||
teardownCount += 1;
|
||||
});
|
||||
const runtime = { retryPendingSandboxTeardown } as unknown as HeartbeatEnvironmentRuntime;
|
||||
|
||||
// Two sweeps run on two separate database clients, so they truly overlap.
|
||||
// A single client serializes the queries on one connection and hides the
|
||||
// race. The second client connects to the same embedded database.
|
||||
const dbB = createDb(tempDb!.connectionString);
|
||||
try {
|
||||
// Warm up the second connection first, so the two sweeps start together.
|
||||
await dbB.select({ id: environmentLeases.id }).from(environmentLeases).limit(1);
|
||||
|
||||
const heartbeatA = heartbeatService(db, { environmentRuntime: runtime });
|
||||
const heartbeatB = heartbeatService(dbB, { environmentRuntime: runtime });
|
||||
|
||||
const backoffMs = 5 * 60 * 1000;
|
||||
const [first, second] = await Promise.all([
|
||||
heartbeatA.sweepPendingCleanupLeases({ backoffMs }),
|
||||
heartbeatB.sweepPendingCleanupLeases({ backoffMs }),
|
||||
]);
|
||||
|
||||
// Only one sweep claimed the attempt and tore the sandbox down.
|
||||
expect(teardownCount).toBe(1);
|
||||
expect(first.destroyed + second.destroyed).toBe(1);
|
||||
} finally {
|
||||
await (dbB as unknown as { $client: { end: () => Promise<void> } }).$client.end();
|
||||
}
|
||||
|
||||
// The winning sweep left the lease in a final successful state and advanced
|
||||
// the attempt count by exactly one.
|
||||
const finalRow = await db
|
||||
.select({
|
||||
status: environmentLeases.status,
|
||||
cleanupStatus: environmentLeases.cleanupStatus,
|
||||
metadata: environmentLeases.metadata,
|
||||
})
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(finalRow?.status).toBe("expired");
|
||||
expect(finalRow?.cleanupStatus).toBe("success");
|
||||
expect((finalRow?.metadata as Record<string, unknown> | null)?.[ATTEMPTS_KEY]).toBe(1);
|
||||
});
|
||||
|
||||
it("test_pending_cleanup_sweep_tears_down_reusable_lease_after_environment_delete", async () => {
|
||||
const { companyId } = await seedCompanyAndEnvironment();
|
||||
// A reuse_by_environment lease whose environment a delete removed. The
|
||||
// schema sets the environment reference to null and preserves the
|
||||
// pending_cleanup row, so the lease still points at a live provider sandbox.
|
||||
const leaseId = await insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId: null,
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
// The environment row is gone, so the sweep must tear the sandbox down from
|
||||
// the recorded lease data through retryPendingSandboxTeardown. It must not
|
||||
// call destroyRunLease, which needs the environment. The teardown succeeds
|
||||
// and does not release the lease; the sweep releases it.
|
||||
const retryPendingSandboxTeardown = vi.fn(async (input: { environment: unknown }) => {
|
||||
expect(input.environment).toBeNull();
|
||||
});
|
||||
const destroyRunLease = vi.fn(async () => null);
|
||||
const runtime = { retryPendingSandboxTeardown, destroyRunLease } as unknown as HeartbeatEnvironmentRuntime;
|
||||
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
|
||||
|
||||
const result = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
|
||||
expect(result).toEqual({ swept: 1, destroyed: 1, capped: 0 });
|
||||
expect(retryPendingSandboxTeardown).toHaveBeenCalledTimes(1);
|
||||
expect(retryPendingSandboxTeardown.mock.calls[0]?.[0]).toMatchObject({
|
||||
lease: expect.objectContaining({ id: leaseId }),
|
||||
});
|
||||
expect(destroyRunLease).not.toHaveBeenCalled();
|
||||
|
||||
const finalRow = await db
|
||||
.select({ status: environmentLeases.status, cleanupStatus: environmentLeases.cleanupStatus })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(finalRow?.status).toBe("expired");
|
||||
expect(finalRow?.cleanupStatus).toBe("success");
|
||||
});
|
||||
|
||||
it("test_pending_cleanup_sweep_keeps_reusable_lease_when_recorded_teardown_fails", async () => {
|
||||
const { companyId } = await seedCompanyAndEnvironment();
|
||||
const leaseId = await insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId: null,
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
// The recorded-data teardown throws, so the sweep reverts the lease to
|
||||
// pending_cleanup for a later retry. The claimed attempt still counts
|
||||
// against the cap, so the retries stay bounded.
|
||||
const retryPendingSandboxTeardown = vi.fn(async () => {
|
||||
throw new Error("provider teardown failed");
|
||||
});
|
||||
const destroyRunLease = vi.fn(async () => null);
|
||||
const runtime = { retryPendingSandboxTeardown, destroyRunLease } as unknown as HeartbeatEnvironmentRuntime;
|
||||
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
|
||||
|
||||
const result = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
|
||||
expect(result).toEqual({ swept: 1, destroyed: 0, capped: 0 });
|
||||
expect(retryPendingSandboxTeardown).toHaveBeenCalledTimes(1);
|
||||
expect(destroyRunLease).not.toHaveBeenCalled();
|
||||
|
||||
const finalRow = await db
|
||||
.select({
|
||||
status: environmentLeases.status,
|
||||
cleanupStatus: environmentLeases.cleanupStatus,
|
||||
metadata: environmentLeases.metadata,
|
||||
})
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(finalRow?.status).toBe("pending_cleanup");
|
||||
expect(finalRow?.cleanupStatus).toBe("failed");
|
||||
expect((finalRow?.metadata as Record<string, unknown> | null)?.[ATTEMPTS_KEY]).toBe(1);
|
||||
});
|
||||
|
||||
it("test_pending_cleanup_sweep_skips_lease_while_plugin_worker_down_then_retries", async () => {
|
||||
const { companyId } = await seedCompanyAndEnvironment();
|
||||
// An orphan ephemeral lease that a failed plugin acquire recorded. The sweep
|
||||
// tears it down through retryPendingSandboxTeardown.
|
||||
const leaseId = await insertOrphanEphemeralLease({
|
||||
companyId,
|
||||
environmentId: null,
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
// The plugin worker is down at first, then recovers on the second sweep.
|
||||
let workerReady = false;
|
||||
const isPendingCleanupWorkerReady = vi.fn(async () => workerReady);
|
||||
const retryPendingSandboxTeardown = vi.fn(async () => {});
|
||||
const destroyRunLease = vi.fn(async () => null);
|
||||
const runtime = {
|
||||
isPendingCleanupWorkerReady,
|
||||
retryPendingSandboxTeardown,
|
||||
destroyRunLease,
|
||||
} as unknown as HeartbeatEnvironmentRuntime;
|
||||
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
|
||||
|
||||
// First sweep: the worker is down, so the sweep skips the lease. It never
|
||||
// claims an attempt and never runs the teardown.
|
||||
const first = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
expect(first).toEqual({ swept: 1, destroyed: 0, capped: 0 });
|
||||
expect(isPendingCleanupWorkerReady).toHaveBeenCalledTimes(1);
|
||||
expect(retryPendingSandboxTeardown).not.toHaveBeenCalled();
|
||||
// The skipped lease consumed no finite attempt.
|
||||
expect(await readAttempts(leaseId)).toBe(0);
|
||||
const afterSkip = await db
|
||||
.select({ status: environmentLeases.status })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]?.status);
|
||||
expect(afterSkip).toBe("pending_cleanup");
|
||||
|
||||
// Second sweep: the worker recovered, so the sweep claims an attempt and
|
||||
// tears the sandbox down.
|
||||
workerReady = true;
|
||||
const second = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
expect(second).toEqual({ swept: 1, destroyed: 1, capped: 0 });
|
||||
expect(retryPendingSandboxTeardown).toHaveBeenCalledTimes(1);
|
||||
|
||||
const finalRow = await db
|
||||
.select({ status: environmentLeases.status, cleanupStatus: environmentLeases.cleanupStatus })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(finalRow?.status).toBe("expired");
|
||||
expect(finalRow?.cleanupStatus).toBe("success");
|
||||
});
|
||||
|
||||
it("test_pending_cleanup_sweep_never_caps_while_provider_unavailable_then_cleans_up", async () => {
|
||||
const { companyId } = await seedCompanyAndEnvironment();
|
||||
// An orphan ephemeral lease that a failed plugin acquire recorded. The sweep
|
||||
// tears it down through retryPendingSandboxTeardown.
|
||||
const leaseId = await insertOrphanEphemeralLease({
|
||||
companyId,
|
||||
environmentId: null,
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
});
|
||||
|
||||
// The provider stays unavailable for more sweeps than the finite attempt
|
||||
// cap. A long plugin reload or a long worker restart looks like this. The
|
||||
// probe reports not ready every time, so no sweep claims an attempt.
|
||||
let providerReady = false;
|
||||
const isPendingCleanupWorkerReady = vi.fn(async () => providerReady);
|
||||
const retryPendingSandboxTeardown = vi.fn(async () => {});
|
||||
const runtime = {
|
||||
isPendingCleanupWorkerReady,
|
||||
retryPendingSandboxTeardown,
|
||||
} as unknown as HeartbeatEnvironmentRuntime;
|
||||
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
|
||||
|
||||
// Run one more sweep than the cap. A per-sweep claim would exhaust the cap
|
||||
// and strand the sandbox, so this proves the unavailable provider never
|
||||
// burns an attempt.
|
||||
for (let sweep = 0; sweep < ATTEMPT_CAP + 1; sweep += 1) {
|
||||
const result = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
// The lease is swept and skipped every time; it never caps and never
|
||||
// tears down while the provider is unavailable.
|
||||
expect(result).toEqual({ swept: 1, destroyed: 0, capped: 0 });
|
||||
}
|
||||
expect(retryPendingSandboxTeardown).not.toHaveBeenCalled();
|
||||
// The unavailable provider consumed no finite attempt across every sweep.
|
||||
expect(await readAttempts(leaseId)).toBe(0);
|
||||
const afterUnavailable = await db
|
||||
.select({ status: environmentLeases.status })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]?.status);
|
||||
expect(afterUnavailable).toBe("pending_cleanup");
|
||||
|
||||
// The provider recovers, so the next sweep claims the first attempt and
|
||||
// tears the sandbox down.
|
||||
providerReady = true;
|
||||
const recovered = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
expect(recovered).toEqual({ swept: 1, destroyed: 1, capped: 0 });
|
||||
expect(retryPendingSandboxTeardown).toHaveBeenCalledTimes(1);
|
||||
|
||||
const finalRow = await db
|
||||
.select({ status: environmentLeases.status, cleanupStatus: environmentLeases.cleanupStatus })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, leaseId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(finalRow?.status).toBe("expired");
|
||||
expect(finalRow?.cleanupStatus).toBe("success");
|
||||
});
|
||||
|
||||
it("test_pending_cleanup_sweep_does_not_let_unavailable_leases_starve_ready_leases", async () => {
|
||||
const { companyId, environmentId } = await seedCompanyAndEnvironment();
|
||||
|
||||
// Fill one whole page with the oldest leases, and make every one of them
|
||||
// unavailable. Their providers are not ready, so the sweep must not tear
|
||||
// them down.
|
||||
const unavailableIds = new Set<string>();
|
||||
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
for (let index = 0; index < SWEEP_PAGE_SIZE; index += 1) {
|
||||
const id = await insertOrphanEphemeralLease({
|
||||
companyId,
|
||||
environmentId: null,
|
||||
updatedAt: twoHoursAgo,
|
||||
});
|
||||
unavailableIds.add(id);
|
||||
}
|
||||
|
||||
// Add one newer lease with a ready provider. It sorts after the page of
|
||||
// unavailable leases, so the first sweep never reaches it.
|
||||
const readyLeaseId = await insertPendingCleanupLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
updatedAt: new Date(Date.now() - 30 * 60 * 1000),
|
||||
});
|
||||
|
||||
// The probe reports the ready lease's provider ready and every unavailable
|
||||
// lease's provider not ready.
|
||||
const isPendingCleanupWorkerReady = vi.fn(
|
||||
async ({ lease }: { lease: { id: string } }) => !unavailableIds.has(lease.id),
|
||||
);
|
||||
const retryPendingSandboxTeardown = vi.fn(async () => {});
|
||||
const destroyRunLease = vi.fn(async ({ lease }: { lease: { id: string } }) => {
|
||||
const now = new Date();
|
||||
const row = await db
|
||||
.update(environmentLeases)
|
||||
.set({ status: "expired", cleanupStatus: "success", updatedAt: now })
|
||||
.where(eq(environmentLeases.id, lease.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? { ...row, status: "expired" as const } : null;
|
||||
});
|
||||
const runtime = {
|
||||
isPendingCleanupWorkerReady,
|
||||
retryPendingSandboxTeardown,
|
||||
destroyRunLease,
|
||||
} as unknown as HeartbeatEnvironmentRuntime;
|
||||
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
|
||||
|
||||
// A five-minute backoff keeps every seeded lease eligible, because each one
|
||||
// is older than the backoff. A deferred lease bumps its updatedAt to now,
|
||||
// so the backoff then excludes it from the next page.
|
||||
const backoffMs = 5 * 60 * 1000;
|
||||
|
||||
// First sweep: the page holds only the unavailable leases. The sweep defers
|
||||
// all of them and tears none down. The ready lease is not in this page.
|
||||
const first = await heartbeat.sweepPendingCleanupLeases({ backoffMs });
|
||||
expect(first).toEqual({ swept: SWEEP_PAGE_SIZE, destroyed: 0, capped: 0 });
|
||||
expect(retryPendingSandboxTeardown).not.toHaveBeenCalled();
|
||||
expect(destroyRunLease).not.toHaveBeenCalled();
|
||||
// The deferred leases consumed no finite attempt.
|
||||
for (const id of unavailableIds) {
|
||||
expect(await readAttempts(id)).toBe(0);
|
||||
}
|
||||
|
||||
// Second sweep: the deferred unavailable leases now sit inside the backoff
|
||||
// window, so the sweep skips them. The ready lease takes the page slot and
|
||||
// tears down. This proves the unavailable leases never starve the ready
|
||||
// lease.
|
||||
const second = await heartbeat.sweepPendingCleanupLeases({ backoffMs });
|
||||
expect(second.destroyed).toBe(1);
|
||||
expect(destroyRunLease).toHaveBeenCalledTimes(1);
|
||||
expect(destroyRunLease.mock.calls[0]?.[0]).toMatchObject({
|
||||
lease: expect.objectContaining({ id: readyLeaseId }),
|
||||
});
|
||||
|
||||
const readyStatus = await db
|
||||
.select({ status: environmentLeases.status })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.id, readyLeaseId))
|
||||
.then((rows) => rows[0]?.status);
|
||||
expect(readyStatus).toBe("expired");
|
||||
|
||||
// The unavailable leases stay in pending_cleanup for a later retry.
|
||||
const stillPending = await db
|
||||
.select({ id: environmentLeases.id })
|
||||
.from(environmentLeases)
|
||||
.where(eq(environmentLeases.status, "pending_cleanup"))
|
||||
.then((rows) => rows.map((row) => row.id));
|
||||
expect(new Set(stillPending)).toEqual(unavailableIds);
|
||||
});
|
||||
|
||||
// Read the current retry attempt count from a lease's metadata.
|
||||
async function readAttempts(leaseId: string): Promise<number> {
|
||||
const metadata = await db
|
||||
|
|
@ -938,4 +1307,40 @@ describeEmbeddedPostgres("heartbeat sweepPendingCleanupLeases", () => {
|
|||
const arrayMetadata = await readMetadata(arrayLeaseId);
|
||||
expect(arrayMetadata?.[ATTEMPTS_KEY]).toBe(1);
|
||||
});
|
||||
|
||||
it("flushes the in-process orphan buffer before the read, so a freshly landed row is swept the same tick", async () => {
|
||||
const { companyId, environmentId } = await seedCompanyAndEnvironment();
|
||||
|
||||
// The flush lands one durable orphan row, exactly as the runtime buffer does
|
||||
// after the database recovers. The sweep must run this flush before it reads
|
||||
// the rows, so the same tick tears the freshly-landed orphan down.
|
||||
const flushDeferredOrphanCleanups = vi.fn(async () => {
|
||||
await insertOrphanEphemeralLease({
|
||||
companyId,
|
||||
environmentId,
|
||||
updatedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
});
|
||||
return { recovered: 1, pending: 0 };
|
||||
});
|
||||
// The recorded-data teardown succeeds, so the sweep releases the lease.
|
||||
const retryPendingSandboxTeardown = vi.fn(async () => {});
|
||||
const runtime = {
|
||||
flushDeferredOrphanCleanups,
|
||||
retryPendingSandboxTeardown,
|
||||
} as unknown as HeartbeatEnvironmentRuntime;
|
||||
const heartbeat = heartbeatService(db, { environmentRuntime: runtime });
|
||||
|
||||
const result = await heartbeat.sweepPendingCleanupLeases({ backoffMs: 0 });
|
||||
|
||||
// The flush ran once before the read, so the row it landed is visible to the
|
||||
// same sweep and tears down through the recorded-data teardown path.
|
||||
expect(flushDeferredOrphanCleanups).toHaveBeenCalledTimes(1);
|
||||
expect(retryPendingSandboxTeardown).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ swept: 1, destroyed: 1, capped: 0 });
|
||||
|
||||
const rows = await db.select().from(environmentLeases);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.status).toBe("expired");
|
||||
expect(rows[0]?.cleanupStatus).toBe("success");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,6 +74,12 @@ const explicitOpenApiCoverageExclusions = new Set([
|
|||
"smoke-lab.ts",
|
||||
]);
|
||||
|
||||
// The set of contract-first routes whose OpenAPI document leads the mounted
|
||||
// request handler. The company-and-environment Claude setup-token login routes
|
||||
// now have request handlers, so the set is empty. A new contract-first route
|
||||
// belongs here only until its handler lands.
|
||||
const specOnlyContractFirstRoutes = new Set<string>([]);
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use("/api", openApiRoutes());
|
||||
|
|
@ -253,7 +259,9 @@ describe("openapi routes", () => {
|
|||
const { routes: specRoutes } = loadSpecRoutes();
|
||||
|
||||
const missingInSpec = [...actualRoutes].filter((route) => !specRoutes.has(route)).sort();
|
||||
const extraInSpec = [...specRoutes].filter((route) => !actualRoutes.has(route)).sort();
|
||||
const extraInSpec = [...specRoutes]
|
||||
.filter((route) => !actualRoutes.has(route) && !specOnlyContractFirstRoutes.has(route))
|
||||
.sort();
|
||||
|
||||
expect({ unknownRouteFiles, missingInSpec, extraInSpec }).toEqual({
|
||||
unknownRouteFiles: [],
|
||||
|
|
@ -288,4 +296,74 @@ describe("openapi routes", () => {
|
|||
expect(spec.paths["/api/board-api-keys"].post.responses["201"]).toBeDefined();
|
||||
expect(spec.paths["/api/companies/import"].post.responses["202"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("publishes the Claude browser-code grammar and strict setup-token response shapes", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const base = "/api/companies/{companyId}/setup-token-login-sessions";
|
||||
|
||||
// The submitted browser code carries the bounded printable-ASCII grammar.
|
||||
const codeBody =
|
||||
spec.paths[`${base}/{sessionId}/code`].post.requestBody.content["application/json"].schema;
|
||||
const browserCode = codeBody.properties.browserCode;
|
||||
expect(browserCode.minLength).toBe(1);
|
||||
expect(browserCode.maxLength).toBe(512);
|
||||
expect(typeof browserCode.pattern).toBe("string");
|
||||
expect(browserCode.pattern.length).toBeGreaterThan(0);
|
||||
|
||||
// Every Claude request object forbids an unknown property.
|
||||
const startBody =
|
||||
spec.paths[base].post.requestBody.content["application/json"].schema;
|
||||
expect(startBody.additionalProperties).toBe(false);
|
||||
expect(codeBody.additionalProperties).toBe(false);
|
||||
|
||||
// The four contract-first routes carry typed strict response schemas.
|
||||
const responseSchemas: Record<string, Record<string, unknown>> = {
|
||||
start: spec.paths[base].post.responses["201"].content["application/json"].schema,
|
||||
status: spec.paths[`${base}/{sessionId}`].get.responses["200"].content["application/json"].schema,
|
||||
prompt: spec.paths[`${base}/{sessionId}/prompt`].get.responses["200"].content["application/json"].schema,
|
||||
code: spec.paths[`${base}/{sessionId}/code`].post.responses["200"].content["application/json"].schema,
|
||||
};
|
||||
const forbiddenProperties = ["token", "accountId", "leaseId"];
|
||||
for (const [name, schema] of Object.entries(responseSchemas)) {
|
||||
expect(schema.type, `${name} response is a typed object`).toBe("object");
|
||||
expect(schema.additionalProperties, `${name} response is strict`).toBe(false);
|
||||
const properties = (schema.properties ?? {}) as Record<string, unknown>;
|
||||
expect(Object.keys(properties).length, `${name} response lists properties`).toBeGreaterThan(0);
|
||||
for (const forbidden of forbiddenProperties) {
|
||||
expect(properties[forbidden], `${name} response hides ${forbidden}`).toBeUndefined();
|
||||
}
|
||||
// No property name looks like a raw prompt secret or a token.
|
||||
for (const property of Object.keys(properties)) {
|
||||
expect(/token|secret|accountId|leaseId/i.test(property), `${name}.${property} is not secret-adjacent`).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The status and code routes share the public response; it hides the prompt.
|
||||
expect(responseSchemas.status.properties).toEqual(responseSchemas.code.properties);
|
||||
expect((responseSchemas.status.properties as Record<string, unknown>).prompt).toBeUndefined();
|
||||
// The owner start response adds the panel mode and the one-time prompt.
|
||||
expect((responseSchemas.start.properties as Record<string, unknown>).panelMode).toBeDefined();
|
||||
expect((responseSchemas.start.properties as Record<string, unknown>).prompt).toBeDefined();
|
||||
// The prompt route returns the authorization URL and the optional transport
|
||||
// advisory. The advisory is present on a non-confidential transport, so the
|
||||
// client can show a non-blocking disclaimer.
|
||||
expect(Object.keys(responseSchemas.prompt.properties as Record<string, unknown>)).toEqual([
|
||||
"authorizationUrl",
|
||||
"transportAdvisory",
|
||||
]);
|
||||
});
|
||||
|
||||
it("documents the 404 non-member gate on the Claude setup-token cancel route", () => {
|
||||
const { spec } = loadSpecRoutes();
|
||||
const cancel =
|
||||
spec.paths["/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/cancel"].post;
|
||||
// The 404 is reachable at run time. The company-access gate returns a fixed
|
||||
// 404 for a non-member before the cancel logic runs, so the spec declares
|
||||
// it. The idempotent cancel still returns 200 for an owner-scoped missing,
|
||||
// terminal, or foreign session id.
|
||||
const codes = Object.keys(cancel.responses).sort();
|
||||
expect(codes).toEqual(["200", "401", "403", "404"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
type HostServices,
|
||||
type HostToWorkerMethods,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
|
||||
import {
|
||||
appendStderrExcerpt,
|
||||
createPluginWorkerHandle,
|
||||
|
|
@ -1054,3 +1055,262 @@ describe("plugin worker manager execute.log route", () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host-owned setup-token login pseudo-terminal route gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SETUP_TOKEN_PTY_WORKER_ENTRYPOINT = path.join(
|
||||
FIXTURES_DIR,
|
||||
"plugin-worker-setup-token-pty.cjs",
|
||||
);
|
||||
|
||||
function makeSetupTokenPtyHandle(extra?: Record<string, unknown>) {
|
||||
return createPluginWorkerHandle("test.plugin", {
|
||||
entrypointPath: SETUP_TOKEN_PTY_WORKER_ENTRYPOINT,
|
||||
manifest: TEST_MANIFEST,
|
||||
config: {},
|
||||
instanceInfo: { instanceId: "instance-1", hostVersion: "1.0.0" },
|
||||
apiVersion: 1,
|
||||
hostHandlers: {},
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function ptyOpenInput(directive: unknown) {
|
||||
return {
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
// The test directive rides in `providerLeaseId`, an opaque field the manager
|
||||
// forwards to the worker unchanged. The manager allowlists `command`, so the
|
||||
// command stays the fixed `CLAUDE_SETUP_TOKEN_COMMAND` for every route-gate
|
||||
// case.
|
||||
providerLeaseId: JSON.stringify(directive),
|
||||
command: CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
describe("plugin worker manager setup-token pty route gate", () => {
|
||||
it("rejects a command that is not the allowlisted setup-token command before the worker call", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// A caller passes a command other than the fixed `CLAUDE_SETUP_TOKEN_COMMAND`.
|
||||
// The manager rejects it with one fixed non-secret error before the worker
|
||||
// call, so no arbitrary process spawns in the sandbox pseudo-terminal.
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession({
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: JSON.stringify({ mode: "normal" }),
|
||||
command: "rm -rf /",
|
||||
}),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED");
|
||||
// The rejected open never consumed the single route, so a later open with
|
||||
// the allowlisted command still succeeds.
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("permits one active credential pseudo-terminal per worker", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
// A second open while the first route is not closed rejects with one fixed
|
||||
// non-secret error before it reaches the worker.
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_ROUTE_BUSY");
|
||||
await first.close();
|
||||
// After the first route closes and the worker acknowledges the close, a new
|
||||
// open is admitted.
|
||||
const second = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
await second.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("delivers output only for the exact bound worker session id and drops a mismatch", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [
|
||||
{ chunk: "good-1" },
|
||||
{ chunk: "forged", sid: "ws-EVIL" },
|
||||
{ chunk: "good-2" },
|
||||
],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
// The forged notification carries a wrong worker session id, so the host
|
||||
// drops it. Only the two bound chunks reach the listener, in order.
|
||||
expect(chunks).toEqual(["good-1", "good-2"]);
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes delayed input to the worker and back to the listener", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
session.write("browser-code");
|
||||
// The worker echoes the input as one output notification for the bound
|
||||
// session, so the listener receives it.
|
||||
await vi.waitFor(() => expect(chunks).toContain("echo:browser-code"));
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("terminalizes the route when the cumulative output passes the per-route bound", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { maxTotalChars: 10 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({
|
||||
outputs: [
|
||||
{ chunk: "aaaaa" }, // total 5 → delivered
|
||||
{ chunk: "bbbbb" }, // total 10 → delivered
|
||||
{ chunk: "ccccc" }, // total 15 > 10 → terminalize
|
||||
],
|
||||
}),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
// The per-route bound terminalizes the route, so the login wait resolves
|
||||
// with a null exit code and the third chunk never reaches the listener.
|
||||
await expect(session.wait()).resolves.toEqual({ exitCode: null });
|
||||
expect(chunks).toEqual(["aaaaa", "bbbbb"]);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("terminalizes and fails closed on a malformed open reply, then admits a later open", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "malformed-open" })),
|
||||
).rejects.toThrow("SETUP_TOKEN_PTY_OPEN_FAILED");
|
||||
// The terminalize closed the route by the host route id and the worker
|
||||
// acknowledged the close, so a later open is admitted.
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
expect(session).toBeDefined();
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("terminalizes the route on an open timeout", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { openTimeoutMs: 200 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "no-open-reply" })),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("binds the worker session id one time and ignores a duplicate open reply", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({
|
||||
mode: "duplicate-open-reply",
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [{ chunk: "hello" }],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
// The duplicate open reply never rebinds or reopens the route, so the
|
||||
// session runs normally on the one bind.
|
||||
await expect(session.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
expect(chunks).toEqual(["hello"]);
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("closes the route with a fixed exit when the worker exits", async () => {
|
||||
const handle = makeSetupTokenPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
);
|
||||
const waitResult = session.wait();
|
||||
await handle.stop();
|
||||
// A worker exit closes the one route and resolves the login wait with the
|
||||
// fixed non-secret exit.
|
||||
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("retires the worker on an unconfirmed close acknowledgement", async () => {
|
||||
const handle = makeSetupTokenPtyHandle({
|
||||
setupTokenPtyLimits: { closeTimeoutMs: 200 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
handle.on("exit", () => resolve());
|
||||
});
|
||||
const session = await handle.openSetupTokenPtySession(
|
||||
ptyOpenInput({ mode: "normal", closeMode: "bad-ack" }),
|
||||
);
|
||||
await session.close();
|
||||
// The close acknowledgement carried a mismatched host route id, so the host
|
||||
// fails closed and retires the worker before any reuse.
|
||||
await exited;
|
||||
await expect(
|
||||
handle.openSetupTokenPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,93 @@
|
|||
import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createSandboxOrphanCleanupSpool,
|
||||
type DeferredOrphanCleanupRecord,
|
||||
} from "../services/sandbox-orphan-cleanup-spool.ts";
|
||||
|
||||
function buildRecord(overrides: Partial<DeferredOrphanCleanupRecord> = {}): DeferredOrphanCleanupRecord {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
environmentId: "environment-1",
|
||||
executionWorkspaceId: null,
|
||||
issueId: null,
|
||||
heartbeatRunId: "run-1",
|
||||
provider: "fake",
|
||||
providerLeaseId: "sandbox://fake/lease-1",
|
||||
metadata: { driver: "sandbox", pluginKey: "example" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("createSandboxOrphanCleanupSpool", () => {
|
||||
let dir!: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(os.tmpdir(), "orphan-spool-unit-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
});
|
||||
|
||||
it("persists a record and loads it back after a restart", async () => {
|
||||
const writer = createSandboxOrphanCleanupSpool(dir);
|
||||
const record = buildRecord();
|
||||
expect(await writer.append(record)).toBe(true);
|
||||
|
||||
// A fresh spool over the same directory models a process restart. The record
|
||||
// survives, so the load returns it with every field intact.
|
||||
const reader = createSandboxOrphanCleanupSpool(dir);
|
||||
const loaded = await reader.load();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]).toEqual(record);
|
||||
});
|
||||
|
||||
it("removes a record so a later load returns nothing", async () => {
|
||||
const spool = createSandboxOrphanCleanupSpool(dir);
|
||||
const record = buildRecord();
|
||||
await spool.append(record);
|
||||
await spool.remove(record);
|
||||
expect(await spool.load()).toEqual([]);
|
||||
expect((await readdir(dir)).filter((name) => name.endsWith(".json"))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps one file when the same orphan is appended twice", async () => {
|
||||
const spool = createSandboxOrphanCleanupSpool(dir);
|
||||
const record = buildRecord();
|
||||
await spool.append(record);
|
||||
await spool.append(record);
|
||||
// The file name keys on the provider lease id, so the second append overwrites
|
||||
// the first file instead of persisting a duplicate.
|
||||
const loaded = await spool.load();
|
||||
expect(loaded).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("persists a record with a null provider lease id", async () => {
|
||||
const spool = createSandboxOrphanCleanupSpool(dir);
|
||||
const record = buildRecord({ providerLeaseId: null });
|
||||
expect(await spool.append(record)).toBe(true);
|
||||
const loaded = await spool.load();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]?.providerLeaseId).toBeNull();
|
||||
});
|
||||
|
||||
it("skips a malformed spool file and returns the other records", async () => {
|
||||
const spool = createSandboxOrphanCleanupSpool(dir);
|
||||
await spool.append(buildRecord());
|
||||
// A torn or hand-edited file cannot authorize a teardown, so the load skips it
|
||||
// and keeps the valid record.
|
||||
await writeFile(path.join(dir, "broken.json"), "{ not json", "utf8");
|
||||
await writeFile(path.join(dir, "wrong-shape.json"), JSON.stringify({ companyId: 1 }), "utf8");
|
||||
const loaded = await spool.load();
|
||||
expect(loaded).toHaveLength(1);
|
||||
expect(loaded[0]?.providerLeaseId).toBe("sandbox://fake/lease-1");
|
||||
});
|
||||
|
||||
it("returns an empty list when the spool directory does not exist", async () => {
|
||||
const spool = createSandboxOrphanCleanupSpool(path.join(dir, "missing"));
|
||||
expect(await spool.load()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,610 @@
|
|||
// The narrow Claude Code OAuth secret helper and the owner-bound compare-and-set
|
||||
// live in the secret service, not in a route. These tests exercise the real
|
||||
// service against an embedded Postgres database, so the partial unique index,
|
||||
// the expected-version predicate, and the session-id idempotency all run against
|
||||
// real constraints. The route-level tests stay in secrets-routes.test.ts, which
|
||||
// mocks the service and cannot exercise the database behavior.
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySecretBindings,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { HttpError } from "../errors.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping Claude OAuth secret service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The fixed key the narrow helper owns. A caller never selects it.
|
||||
const CLAUDE_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
const CLAUDE_NAME = "Claude Code OAuth token";
|
||||
|
||||
describeEmbeddedPostgres("secretService Claude Code OAuth helper and compare-and-set", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-claude-oauth-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("claude-oauth-secrets");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (stopDb) await stopDb();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function seedCompanyMember(companyId: string, userId: string) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async function countVersions(secretId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(companySecretVersions)
|
||||
.where(eq(companySecretVersions.secretId, secretId));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// --- the narrow secret-definition helper ------------------------
|
||||
|
||||
it("creates the fixed Claude OAuth definition with the compile-time key and fixed properties", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const definition = await svc.ensureClaudeOAuthUserSecretDefinition(companyId, { userId: "user-1", agentId: null });
|
||||
|
||||
expect(definition.key).toBe(CLAUDE_KEY);
|
||||
expect(definition.name).toBe(CLAUDE_NAME);
|
||||
expect(definition.provider).toBe("local_encrypted");
|
||||
expect(definition.managedMode).toBe("paperclip_managed");
|
||||
expect(definition.status).toBe("active");
|
||||
});
|
||||
|
||||
it("reuses an exact compatible definition without a second row", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.ensureClaudeOAuthUserSecretDefinition(companyId);
|
||||
const second = await svc.ensureClaudeOAuthUserSecretDefinition(companyId);
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(userSecretDefinitions)
|
||||
.where(eq(userSecretDefinitions.companyId, companyId));
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects a conflicting definition with 409 and does not mutate it", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
// A pre-existing definition holds the fixed key but a different name. It is
|
||||
// not the exact fixed shape, so the helper must reject it and leave it alone.
|
||||
const conflicting = await svc.createUserSecretDefinition(companyId, {
|
||||
key: CLAUDE_KEY,
|
||||
name: "Some other Claude token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
|
||||
await expect(svc.ensureClaudeOAuthUserSecretDefinition(companyId)).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(userSecretDefinitions)
|
||||
.where(eq(userSecretDefinitions.id, conflicting.id))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.name).toBe("Some other Claude token");
|
||||
});
|
||||
|
||||
// --- the owner-bound compare-and-set ----------------------------
|
||||
|
||||
it("first_write creates the owner value and stores the session id", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const result = await svc.completeClaudeOAuthUserSecret(
|
||||
companyId,
|
||||
"user-1",
|
||||
{ sessionId: "session-a", mode: "first_write", value: "oauth-token-value" },
|
||||
{ userId: "user-1", agentId: null },
|
||||
);
|
||||
|
||||
expect(result.secretId).toBeTruthy();
|
||||
expect(result.latestVersion).toBe(1);
|
||||
expect(await countVersions(result.secretId)).toBe(1);
|
||||
});
|
||||
|
||||
it("first_write conflicts with 409 when a different session already wrote the owner value", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "first_write",
|
||||
value: "another-token-value",
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("first_write is idempotent for the same session id and creates no new version", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
const repeat = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
expect(repeat.secretId).toBe(first.secretId);
|
||||
expect(repeat.latestVersion).toBe(1);
|
||||
expect(await countVersions(first.secretId)).toBe(1);
|
||||
});
|
||||
|
||||
it("confirmed_rotation requires expectedSecretId and expectedLatestVersion", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 422 });
|
||||
});
|
||||
|
||||
it("confirmed_rotation rotates to the next version when the expected version matches", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
const rotated = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: first.latestVersion,
|
||||
});
|
||||
|
||||
expect(rotated.secretId).toBe(first.secretId);
|
||||
expect(rotated.latestVersion).toBe(2);
|
||||
expect(await countVersions(first.secretId)).toBe(2);
|
||||
});
|
||||
|
||||
it("confirmed_rotation returns a fixed stale-confirmation 409 for a wrong expected version", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
// A first confirmed rotation moves the value to version 2.
|
||||
await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
});
|
||||
|
||||
// A later confirmation still carries the stale version 1 and must conflict.
|
||||
await expect(
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-c",
|
||||
mode: "confirmed_rotation",
|
||||
value: "third-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 409 });
|
||||
|
||||
// The stale confirmation created no new version.
|
||||
expect(await countVersions(first.secretId)).toBe(2);
|
||||
});
|
||||
|
||||
it("confirmed_rotation is idempotent for the same session id and creates no new version", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
const rotated = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
});
|
||||
|
||||
const repeat = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
});
|
||||
|
||||
expect(repeat.secretId).toBe(rotated.secretId);
|
||||
expect(repeat.latestVersion).toBe(2);
|
||||
expect(await countVersions(first.secretId)).toBe(2);
|
||||
});
|
||||
|
||||
it("two concurrent confirmed rotations return one fixed stale 409 and add one version", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
expect(first.latestVersion).toBe(1);
|
||||
|
||||
// Both confirmations read the same latest version 1 and both target the next
|
||||
// version 2. The unique index on (secretId, version) lets one insert win. The
|
||||
// loser must return the same fixed stale 409, not a raw database error.
|
||||
const [a, b] = await Promise.allSettled([
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-b",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
}),
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-c",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-c",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const fulfilled = [a, b].filter((r) => r.status === "fulfilled");
|
||||
const rejected = [a, b].filter((r) => r.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
|
||||
// The loser returns the one fixed stale-confirmation 409. The text is the
|
||||
// same for every stale race, so it discloses no owner-value state.
|
||||
const loser = (rejected[0] as PromiseRejectedResult).reason;
|
||||
expect(loser).toBeInstanceOf(HttpError);
|
||||
expect((loser as HttpError).status).toBe(409);
|
||||
expect((loser as HttpError).message).toContain("The Claude login confirmation is stale");
|
||||
|
||||
const winner = fulfilled[0] as PromiseFulfilledResult<{ latestVersion: number }>;
|
||||
expect(winner.value.latestVersion).toBe(2);
|
||||
|
||||
// Only the two winning versions remain. The loser left no extra version row.
|
||||
expect(await countVersions(first.secretId)).toBe(2);
|
||||
});
|
||||
|
||||
it("normalizes a next-version unique collision to the fixed stale 409 and adds no row", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
expect(first.latestVersion).toBe(1);
|
||||
|
||||
// Reproduce the exact race window. A concurrent winner inserted the next
|
||||
// version row but has not yet committed its owner-bound compare-and-set, so
|
||||
// the latest version still reads 1. A second confirmation that carries the
|
||||
// same expected version 1 passes the pre-check, then hits the (secretId,
|
||||
// version) unique index on its own insert. That one collision must return the
|
||||
// fixed stale 409, not a raw database error.
|
||||
await db.insert(companySecretVersions).values({
|
||||
secretId: first.secretId,
|
||||
version: 2,
|
||||
material: {},
|
||||
valueSha256: "placeholder-sha256",
|
||||
fingerprintSha256: "placeholder-sha256",
|
||||
status: "disabled",
|
||||
});
|
||||
expect(await countVersions(first.secretId)).toBe(2);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(HttpError);
|
||||
expect((caught as HttpError).status).toBe(409);
|
||||
expect((caught as HttpError).message).toContain("The Claude login confirmation is stale");
|
||||
|
||||
// The failed insert added no new row and did not advance the latest version.
|
||||
expect(await countVersions(first.secretId)).toBe(2);
|
||||
const row = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(eq(companySecrets.id, first.secretId))
|
||||
.then((rows) => rows[0]);
|
||||
expect(row?.latestVersion).toBe(1);
|
||||
});
|
||||
|
||||
it("two concurrent first writes leave exactly one owner value through the partial unique index", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const [a, b] = await Promise.allSettled([
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "token-a",
|
||||
}),
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "first_write",
|
||||
value: "token-b",
|
||||
}),
|
||||
]);
|
||||
|
||||
const fulfilled = [a, b].filter((r) => r.status === "fulfilled");
|
||||
const rejected = [a, b].filter((r) => r.status === "rejected");
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
|
||||
const values = await db
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.scope, "user")));
|
||||
expect(values).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("confirmed_rotation on a value from another owner returns not-found", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
await seedCompanyMember(companyId, "user-2");
|
||||
const svc = secretService(db);
|
||||
|
||||
const owned = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
// user-2 points at user-1's secret id. The owner-scoped lookup fails closed.
|
||||
await expect(
|
||||
svc.completeClaudeOAuthUserSecret(companyId, "user-2", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: owned.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
});
|
||||
|
||||
it("confirmed_rotation on a value from another company returns not-found", async () => {
|
||||
const companyA = await seedCompany("Acme");
|
||||
const companyB = await seedCompany("Globex");
|
||||
await seedCompanyMember(companyA, "user-1");
|
||||
await seedCompanyMember(companyB, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const owned = await svc.completeClaudeOAuthUserSecret(companyA, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
await expect(
|
||||
svc.completeClaudeOAuthUserSecret(companyB, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: owned.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(HttpError);
|
||||
});
|
||||
|
||||
it("readClaudeOAuthUserSecretStatus returns the secret id and the version for the owner value", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
const rotated = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-token-value",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
});
|
||||
|
||||
const status = await svc.readClaudeOAuthUserSecretStatus(companyId, "user-1");
|
||||
expect(status).toEqual({ secretId: first.secretId, latestVersion: rotated.latestVersion });
|
||||
// The status carries no token value.
|
||||
expect(JSON.stringify(status)).not.toContain("oauth-token-value");
|
||||
expect(JSON.stringify(status)).not.toContain("rotated-token-value");
|
||||
});
|
||||
|
||||
it("readClaudeOAuthUserSecretStatus returns null when the owner has no value", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
await seedCompanyMember(companyId, "user-2");
|
||||
const svc = secretService(db);
|
||||
|
||||
// user-1 has a value; user-2 does not. The reader is owner-scoped, so it
|
||||
// returns null for user-2 and the metadata for user-1.
|
||||
await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
expect(await svc.readClaudeOAuthUserSecretStatus(companyId, "user-2")).toBeNull();
|
||||
// No definition-only or empty company also returns null (the reader never
|
||||
// creates the definition).
|
||||
const emptyCompany = await seedCompany("Globex");
|
||||
await seedCompanyMember(emptyCompany, "user-1");
|
||||
expect(await svc.readClaudeOAuthUserSecretStatus(emptyCompany, "user-1")).toBeNull();
|
||||
});
|
||||
|
||||
it("readClaudeOAuthUserSecretStatus is owner-scoped and company-scoped", async () => {
|
||||
const companyA = await seedCompany("Acme");
|
||||
const companyB = await seedCompany("Globex");
|
||||
await seedCompanyMember(companyA, "user-1");
|
||||
await seedCompanyMember(companyB, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
await svc.completeClaudeOAuthUserSecret(companyA, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: "oauth-token-value",
|
||||
});
|
||||
|
||||
// The same user in another company has no value there.
|
||||
expect(await svc.readClaudeOAuthUserSecretStatus(companyB, "user-1")).toBeNull();
|
||||
// Another owner in the same company has no value.
|
||||
await seedCompanyMember(companyA, "user-2");
|
||||
expect(await svc.readClaudeOAuthUserSecretStatus(companyA, "user-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the token out of every activity detail", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
const token = "super-secret-oauth-token";
|
||||
|
||||
const first = await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-a",
|
||||
mode: "first_write",
|
||||
value: token,
|
||||
});
|
||||
await svc.completeClaudeOAuthUserSecret(companyId, "user-1", {
|
||||
sessionId: "session-b",
|
||||
mode: "confirmed_rotation",
|
||||
value: "rotated-secret-oauth-token",
|
||||
expectedSecretId: first.secretId,
|
||||
expectedLatestVersion: 1,
|
||||
});
|
||||
|
||||
const activity = await db.select().from(activityLog);
|
||||
expect(JSON.stringify(activity)).not.toContain(token);
|
||||
expect(JSON.stringify(activity)).not.toContain("rotated-secret-oauth-token");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
companies,
|
||||
companyMemberships,
|
||||
companySecretBindings,
|
||||
companySecretVersions,
|
||||
companySecrets,
|
||||
createDb,
|
||||
secretAccessEvents,
|
||||
userSecretDeclarations,
|
||||
userSecretDefinitions,
|
||||
} from "@paperclipai/db";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping dangling user-secret tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
// A user can delete a user-secret definition while an agent config still
|
||||
// references it. Later, a direct-resolution path (Test or save) resolves that
|
||||
// dangling `user_secret_ref` binding and fails. These tests assert the failure
|
||||
// names the environment variable, the agent id, and the unresolved definition
|
||||
// key, and that it never leaks a secret value.
|
||||
describeEmbeddedPostgres("secretService dangling user_secret_ref resolution", () => {
|
||||
let stopDb: (() => Promise<void>) | null = null;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
const secretsTmpDir = path.join(os.tmpdir(), `paperclip-dangling-secret-${randomUUID()}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
mkdirSync(secretsTmpDir, { recursive: true });
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key");
|
||||
const started = await startEmbeddedPostgresTestDatabase("dangling-user-secret");
|
||||
stopDb = started.cleanup;
|
||||
db = createDb(started.connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(userSecretDeclarations);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(userSecretDefinitions);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (stopDb) await stopDb();
|
||||
if (previousKeyFile === undefined) {
|
||||
delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE;
|
||||
} else {
|
||||
process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile;
|
||||
}
|
||||
rmSync(secretsTmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function seedCompany(name = "Acme") {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name,
|
||||
issuePrefix: `T${companyId.slice(0, 7)}`.toUpperCase(),
|
||||
status: "active",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function seedCompanyMember(companyId: string, userId: string) {
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
it("save path (syncEnvBindingsForTarget) names the env variable, the agent id, and the deleted definition key", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
|
||||
// Delete the definition, then keep an agent config that still references it.
|
||||
await svc.removeUserSecretDefinition(companyId, definition.id);
|
||||
|
||||
const agentId = randomUUID();
|
||||
const envValue = {
|
||||
GH_TOKEN: {
|
||||
type: "user_secret_ref" as const,
|
||||
key: "github_token",
|
||||
version: "latest" as const,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: agentId }, envValue),
|
||||
).rejects.toMatchObject({
|
||||
status: 404,
|
||||
message: expect.stringContaining("User secret definition not found"),
|
||||
});
|
||||
|
||||
// The message must name the env variable, the agent id, and the definition key.
|
||||
const error = await svc
|
||||
.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: agentId }, envValue)
|
||||
.catch((caught: unknown) => caught as Error);
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toContain('environment variable "GH_TOKEN"');
|
||||
expect(error.message).toContain(`agent ${agentId}`);
|
||||
expect(error.message).toContain('definition key "github_token"');
|
||||
});
|
||||
|
||||
it("Test path (resolveUserSecretValue) names the env variable, the agent id, and the deleted definition key without leaking the value", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
const definition = await svc.createUserSecretDefinition(companyId, {
|
||||
key: "github_token",
|
||||
name: "GitHub token",
|
||||
provider: "local_encrypted",
|
||||
});
|
||||
const secretValue = "ghp_super_secret_value";
|
||||
await svc.createCurrentUserSecretValue(companyId, "user-1", {
|
||||
definitionId: definition.id,
|
||||
value: secretValue,
|
||||
});
|
||||
|
||||
// Delete the definition after the value exists. The binding now dangles.
|
||||
await svc.removeUserSecretDefinition(companyId, definition.id);
|
||||
|
||||
const agentId = randomUUID();
|
||||
const error = await svc
|
||||
.resolveUserSecretValue(
|
||||
companyId,
|
||||
{ definitionKey: "github_token", responsibleUserId: "user-1", required: true },
|
||||
{
|
||||
consumerType: "agent",
|
||||
consumerId: agentId,
|
||||
configPath: "env.GH_TOKEN",
|
||||
responsibleUserId: "user-1",
|
||||
},
|
||||
)
|
||||
.catch((caught: unknown) => caught as Error);
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as { status?: number }).status).toBe(404);
|
||||
expect(error.message).toContain("User secret definition not found");
|
||||
expect(error.message).toContain('environment variable "GH_TOKEN"');
|
||||
expect(error.message).toContain(`agent ${agentId}`);
|
||||
expect(error.message).toContain('definition key "github_token"');
|
||||
// The enriched message must never include the secret value.
|
||||
expect(error.message).not.toContain(secretValue);
|
||||
});
|
||||
|
||||
it("optional binding still resolves to null when the definition is missing (enrichment keeps the 404 status)", async () => {
|
||||
const companyId = await seedCompany();
|
||||
await seedCompanyMember(companyId, "user-1");
|
||||
const svc = secretService(db);
|
||||
|
||||
const resolved = await svc.resolveUserSecretValue(
|
||||
companyId,
|
||||
{ definitionKey: "never_created", responsibleUserId: "user-1", required: false },
|
||||
{ consumerType: "agent", consumerId: randomUUID(), configPath: "env.GH_TOKEN" },
|
||||
);
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -68,6 +68,7 @@ const {
|
|||
reconcileTaskWatchdogs: vi.fn(async () => ({ triggered: 0 })),
|
||||
scanSilentActiveRuns: vi.fn(async () => ({ created: 0, escalated: 0 })),
|
||||
sweepStaleIssueLocks: vi.fn(async () => ({ cleared: 0 })),
|
||||
sweepPendingCleanupLeases: vi.fn(async () => ({ swept: 0, destroyed: 0, capped: 0 })),
|
||||
reconcileProductivityReviews: vi.fn(async () => ({ created: 0, updated: 0, failed: 0 })),
|
||||
sweepExpiredRuntimeStatuses: vi.fn(() => 0),
|
||||
tickTimers: vi.fn(async () => ({ checked: 0, enqueued: 0, skipped: 0 })),
|
||||
|
|
@ -522,7 +523,11 @@ describe("startServer feedback export wiring", () => {
|
|||
try {
|
||||
await startServer();
|
||||
|
||||
expect(heartbeatServiceFactoryMock).not.toHaveBeenCalled();
|
||||
// The disabled path still creates one heartbeat runtime. This runtime owns
|
||||
// the orphan-sandbox cleanup sweep, so a leaked provider sandbox is still
|
||||
// reaped at startup and on the interval.
|
||||
expect(heartbeatServiceFactoryMock).toHaveBeenCalledTimes(1);
|
||||
expect(heartbeatServiceMock.sweepPendingCleanupLeases).toHaveBeenCalled();
|
||||
expect(intervalCallback).not.toBeNull();
|
||||
intervalCallback?.();
|
||||
await Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -31,6 +31,15 @@ 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 {
|
||||
buildSetupTokenLoginTransport,
|
||||
createProductionSetupTokenSandboxProvider,
|
||||
createProductionSetupTokenCleanupStore,
|
||||
createSetupTokenSecretWriter,
|
||||
createWorkerBoundSetupTokenPtyOpener,
|
||||
} from "./services/setup-token-transport-binding.js";
|
||||
import { environmentService } from "./services/environments.js";
|
||||
import { environmentRuntimeService } from "./services/environment-runtime.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
import { issueRoutes } from "./routes/issues.js";
|
||||
import { issueTreeControlRoutes } from "./routes/issue-tree-control.js";
|
||||
|
|
@ -426,18 +435,52 @@ export async function createApp(
|
|||
.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`.
|
||||
// Bind the production setup-token login transport. It carries the live lease
|
||||
// manager, the login-process factory over the sandbox pseudo-terminal, and the
|
||||
// durable cleanup store. The factory passes only the fixed command
|
||||
// `CLAUDE_SETUP_TOKEN_COMMAND`; it never reads a command from a
|
||||
// route, a request body, or an adapter configuration. The durable store and the
|
||||
// startup reaper are live now, so a restart reaps a leftover lease.
|
||||
//
|
||||
// The live sandbox pseudo-terminal opener binds inside the sandbox provider
|
||||
// worker, so the server process does not hold the raw sandbox process. The
|
||||
// opener drives the worker through the plugin worker manager route gate.
|
||||
// The manager mints a host-owned route identifier, permits one
|
||||
// active credential pseudo-terminal per worker, binds the worker session
|
||||
// identifier one time for output only, and terminalizes the route on every open
|
||||
// failure path. With the opener supplied, the provider acquires a lease and the
|
||||
// start route drives a live login instead of the fixed 503.
|
||||
const setupTokenLoginTransport = buildSetupTokenLoginTransport({
|
||||
sandbox: createProductionSetupTokenSandboxProvider({
|
||||
environments: environmentService(db),
|
||||
environmentRuntime: environmentRuntimeService(db, { pluginWorkerManager: workerManager }),
|
||||
openLivePtySession: createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager,
|
||||
environments: environmentService(db),
|
||||
log: (line) => logger.info(line),
|
||||
}),
|
||||
log: (line) => logger.info(line),
|
||||
}),
|
||||
store: createProductionSetupTokenCleanupStore(db),
|
||||
// Bind the atomic credential-claim writer, so a completed login transitions
|
||||
// the durable row to `stored` and stores the minted token in one control-plane
|
||||
// transaction. The writer reads the company and the owner only from the
|
||||
// immutable session scope. The confirm-replacement flow owns rotation. Without
|
||||
// this writer the router falls back to the deferred, fail-closed 503 and never
|
||||
// stores the token.
|
||||
completeCredential: createSetupTokenSecretWriter({ db }),
|
||||
// Forward the login runner diagnostic lines to the server logger. The
|
||||
// runner is the sole producer, and every line is a fixed, non-secret
|
||||
// literal. Without this sink the diagnostics fall back to a no-op in
|
||||
// production, so a failed login leaves no log trail.
|
||||
log: (line) => logger.info(line),
|
||||
});
|
||||
api.use(
|
||||
agentRoutes(db, {
|
||||
pluginWorkerManager: workerManager,
|
||||
deploymentMode: opts.deploymentMode,
|
||||
confidentialProxyAllowlist: setupTokenLoginProxyAllowlist,
|
||||
setupTokenLogin: setupTokenLoginTransport,
|
||||
onSetupTokenLoginService: (service) => {
|
||||
setupTokenLoginService = service;
|
||||
// Startup reaper (SR-4): release any lease whose login session is
|
||||
|
|
@ -865,28 +908,40 @@ export async function createApp(
|
|||
logger.error({ err }, "Failed to load ready plugins on startup");
|
||||
});
|
||||
app.locals.bundledPluginsStartup = bundledPluginsStartup;
|
||||
let appServicesShutdown = false;
|
||||
const shutdownAppServices = () => {
|
||||
if (appServicesShutdown) return;
|
||||
appServicesShutdown = true;
|
||||
disableFeedbackExportFlushes();
|
||||
if (importTransferSweepTimer) {
|
||||
clearInterval(importTransferSweepTimer);
|
||||
importTransferSweepTimer = null;
|
||||
}
|
||||
devWatcher?.close();
|
||||
viteHtmlRenderer?.dispose();
|
||||
void viteDevServer?.close().catch(() => undefined);
|
||||
viteHmrServer?.close();
|
||||
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();
|
||||
// The shutdown hook runs at most once. It caches the in-flight promise, so a
|
||||
// second caller (for example the `exit` handler) awaits the same completion
|
||||
// instead of starting a second teardown.
|
||||
let appServicesShutdown: Promise<void> | null = null;
|
||||
const shutdownAppServices = (): Promise<void> => {
|
||||
if (appServicesShutdown) return appServicesShutdown;
|
||||
appServicesShutdown = (async () => {
|
||||
disableFeedbackExportFlushes();
|
||||
if (importTransferSweepTimer) {
|
||||
clearInterval(importTransferSweepTimer);
|
||||
importTransferSweepTimer = null;
|
||||
}
|
||||
devWatcher?.close();
|
||||
viteHtmlRenderer?.dispose();
|
||||
void viteDevServer?.close().catch(() => undefined);
|
||||
viteHmrServer?.close();
|
||||
hostServiceCleanup.disposeAll();
|
||||
hostServiceCleanup.teardown();
|
||||
// Cancel every live setup-token login session and AWAIT the cancellation,
|
||||
// so each direct child stops and the server releases each lease before the
|
||||
// caller stops the database and the provider. A lease release that
|
||||
// fails stays a durable record for the startup reaper.
|
||||
await setupTokenLoginService?.shutdown();
|
||||
})();
|
||||
return appServicesShutdown;
|
||||
};
|
||||
app.locals.paperclipShutdown = shutdownAppServices;
|
||||
|
||||
process.once("exit", shutdownAppServices);
|
||||
// The `exit` event is synchronous. It cannot await the teardown, so it runs
|
||||
// the best-effort cleanup and drops the returned promise. The orderly signal
|
||||
// path awaits `shutdownAppServices` in full before the process exits.
|
||||
process.once("exit", () => {
|
||||
void shutdownAppServices();
|
||||
});
|
||||
process.once("beforeExit", () => {
|
||||
void flushPluginLogBuffer();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import { ensureDecisionSigningSecret } from "./services/decision-signing.js";
|
|||
import { createDecisionRetentionNotifyOriginAgent, createDecisionWakeOriginAgent } from "./services/decision-wakeup.js";
|
||||
import {
|
||||
coordinateHeartbeatSchedulerShutdown,
|
||||
finalizeServerShutdown,
|
||||
loadWithoutCoordinatedShutdownSignalHooks,
|
||||
} from "./shutdown.js";
|
||||
import { systemdNotify } from "./services/systemd-notify.js";
|
||||
|
|
@ -991,6 +992,46 @@ export async function startServer(): Promise<StartedServer> {
|
|||
}));
|
||||
};
|
||||
|
||||
// The retry backstop for orphan sandboxes. An acquire that rejects a
|
||||
// foreign-company insert tears the provisioned sandbox down. If that teardown
|
||||
// also fails, the acquire records a lease-less `pending_cleanup` lease row. No
|
||||
// other path releases that sandbox, so the master pending-cleanup sweep retries
|
||||
// the provider teardown and releases the orphan. The sweep runs on startup and
|
||||
// on the scheduler interval.
|
||||
//
|
||||
// This backstop is independent of the heartbeat scheduler toggle. A leaked
|
||||
// provider sandbox costs money whether or not the instance schedules
|
||||
// heartbeats, so both the enabled and the disabled path run the sweep. A
|
||||
// disabled heartbeat scheduler must not strand a paid sandbox forever.
|
||||
//
|
||||
// The master pending-cleanup sweep is the single owner of these rows. Its
|
||||
// atomic per-attempt claim makes two overlapping sweeps safe, so the enabled
|
||||
// path can also run the sweep from the orphaned-run reaper without a second
|
||||
// teardown. The heartbeat scheduler owns the sweep when it is enabled; the
|
||||
// disabled path creates its own runtime to own the same sweep.
|
||||
// The interval sweep waits this long after a lease's last write before it
|
||||
// retries the teardown. The window matches the orphaned-run reaper staleness,
|
||||
// so a just-failed lease does not draw a retry on every tick. The startup
|
||||
// sweep passes zero, so a restart retries a stranded orphan at once.
|
||||
const ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS = 5 * 60 * 1000;
|
||||
const environmentLeaseCleanupHeartbeat =
|
||||
heartbeat ?? heartbeatService(db as any, { pluginWorkerManager });
|
||||
const runEnvironmentLeaseCleanupSweep = (backoffMs: number) =>
|
||||
environmentLeaseCleanupHeartbeat
|
||||
.sweepPendingCleanupLeases({ backoffMs })
|
||||
.then((result) => {
|
||||
if (result.destroyed > 0 || result.capped > 0) {
|
||||
logger.info(result, "environment lease cleanup sweep retried orphan sandbox teardowns");
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "environment lease cleanup sweep failed");
|
||||
});
|
||||
const scheduleEnvironmentLeaseCleanupSweep = () => {
|
||||
if (heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(runEnvironmentLeaseCleanupSweep(ENVIRONMENT_LEASE_CLEANUP_SWEEP_BACKOFF_MS));
|
||||
};
|
||||
|
||||
if (heartbeat) {
|
||||
const secretProposals = createSecretProposalsService(db as any);
|
||||
const decisionExecutor = decisionService(db as any, decisionServiceOptions);
|
||||
|
|
@ -1086,6 +1127,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
logger.error({ err }, "adapter login reaper sweep failed");
|
||||
}));
|
||||
};
|
||||
|
||||
const tools = toolAccessService(db as any, {
|
||||
deploymentMode: config.deploymentMode,
|
||||
deploymentExposure: config.deploymentExposure,
|
||||
|
|
@ -1223,6 +1265,10 @@ export async function startServer(): Promise<StartedServer> {
|
|||
logger.error({ err }, "startup adapter login reaper sweep failed");
|
||||
});
|
||||
|
||||
// Retry any orphan sandbox teardown left by a failed acquire before a server
|
||||
// restart, so a leaked sandbox does not stay allocated across the restart.
|
||||
await runEnvironmentLeaseCleanupSweep(0);
|
||||
|
||||
const runRetentionSweep = async () => {
|
||||
const activeCompanies = await db.select({ id: companies.id }).from(companies).where(eq(companies.status, "active"));
|
||||
let archived = 0;
|
||||
|
|
@ -1282,6 +1328,7 @@ export async function startServer(): Promise<StartedServer> {
|
|||
scheduleMergedPullRequestConfirmationSweep();
|
||||
scheduleTerminalWorkspaceSweep();
|
||||
scheduleAdapterLoginReaperSweep();
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
|
||||
if (heartbeatSchedulerStopped) return;
|
||||
trackHeartbeatSchedulerWork(routines
|
||||
|
|
@ -1419,8 +1466,14 @@ export async function startServer(): Promise<StartedServer> {
|
|||
}));
|
||||
});
|
||||
} else {
|
||||
// The heartbeat scheduler is disabled, but the orphan-sandbox cleanup sweep
|
||||
// is still required. A failed acquire can leak a paid provider sandbox, so
|
||||
// this path retries the teardown at startup and on the interval, exactly as
|
||||
// the enabled path does.
|
||||
await runEnvironmentLeaseCleanupSweep(0);
|
||||
startHeartbeatSchedulerInterval(() => {
|
||||
scheduleExternalObjectRefreshSweep(new Date());
|
||||
scheduleEnvironmentLeaseCleanupSweep();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1577,21 +1630,22 @@ export async function startServer(): Promise<StartedServer> {
|
|||
logger.error({ err, signal }, "run-log in-flight mirror flush failed");
|
||||
}
|
||||
|
||||
const appShutdown = (app as { locals?: { paperclipShutdown?: () => void } }).locals?.paperclipShutdown;
|
||||
appShutdown?.();
|
||||
const appShutdown = (app as { locals?: { paperclipShutdown?: () => Promise<void> } }).locals
|
||||
?.paperclipShutdown;
|
||||
const embeddedPostgresToStop =
|
||||
embeddedPostgres && embeddedPostgresStartedByThisProcess ? embeddedPostgres : null;
|
||||
|
||||
if (embeddedPostgres && embeddedPostgresStartedByThisProcess) {
|
||||
logger.info({ signal }, "Stopping embedded PostgreSQL");
|
||||
try {
|
||||
await embeddedPostgres?.stop();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
// Flush buffered OTel spans before the process goes away; without this
|
||||
// await the exporter's final batch is dropped on exit.
|
||||
await shutdownInstrumentation();
|
||||
// Await the ordered application teardown before the process exits. A live
|
||||
// setup-token login session must stop and release its sandbox lease before
|
||||
// the database and the provider stop, so an orderly shutdown never leaves a
|
||||
// sandbox lease or confidential login state alive past the process exit.
|
||||
await finalizeServerShutdown({
|
||||
signal,
|
||||
shutdownAppServices: appShutdown,
|
||||
stopEmbeddedPostgres: embeddedPostgresToStop ? () => embeddedPostgresToStop.stop() : null,
|
||||
shutdownInstrumentation,
|
||||
log: logger,
|
||||
});
|
||||
|
||||
process.exit(0);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import {
|
|||
updateAgentSchema,
|
||||
supportedEnvironmentDriversForAdapter,
|
||||
LOW_TRUST_REVIEW_PRESET,
|
||||
startClaudeSetupTokenSessionRequestSchema,
|
||||
submitBrowserCodeRequestSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
isForbiddenConfigEnvKey,
|
||||
|
|
@ -58,7 +60,7 @@ import {
|
|||
} from "../services/index.js";
|
||||
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
||||
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
|
||||
import {
|
||||
assertNoAgentHostWorkspaceCommandMutation,
|
||||
collectAgentAdapterWorkspaceCommandPaths,
|
||||
|
|
@ -67,6 +69,7 @@ import type { PluginWorkerManager } from "../services/plugin-worker-manager.js";
|
|||
import { environmentService } from "../services/environments.js";
|
||||
import { resolveEnvironmentExecutionTarget } from "../services/environment-execution-target.js";
|
||||
import { environmentRuntimeService } from "../services/environment-runtime.js";
|
||||
import { resolvePluginSandboxProviderDriverByKey } from "../services/plugin-environment-driver.js";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type {
|
||||
AdapterEnvironmentCheck,
|
||||
|
|
@ -103,16 +106,33 @@ import {
|
|||
assessConfidentialStartup,
|
||||
evaluateConfidentialTransport,
|
||||
SETUP_TOKEN_START_FAILED,
|
||||
SETUP_TOKEN_TRANSPORT_INSECURE,
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
SETUP_TOKEN_PROVIDER_UNSUPPORTED,
|
||||
SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE,
|
||||
type ConfidentialTransportConfig,
|
||||
type SetupTokenCleanupRecord,
|
||||
type SetupTokenCleanupStore,
|
||||
type SetupTokenLease,
|
||||
type SetupTokenLeaseManager,
|
||||
type SetupTokenLoginProcessFactory,
|
||||
type SetupTokenSecretWriter,
|
||||
type SetupTokenSessionScope,
|
||||
type SetupTokenSessionState,
|
||||
type SetupTokenSessionDescriptor,
|
||||
} from "../services/setup-token-session.js";
|
||||
import type { DeploymentMode } from "@paperclipai/shared";
|
||||
import type {
|
||||
DeploymentMode,
|
||||
AdapterAuthSessionStatus,
|
||||
AdapterAuthSessionFailure,
|
||||
ClaudeSetupTokenSessionResponse,
|
||||
ClaudeSetupTokenSessionOwnerResponse,
|
||||
ClaudeSetupTokenSessionPrompt,
|
||||
ClaudeSetupTokenCompletionResponse,
|
||||
ClaudeOAuthTokenStatusResponse,
|
||||
ClaudeSetupTokenOverwrite,
|
||||
SetupTokenTransportAdvisory,
|
||||
} from "@paperclipai/shared";
|
||||
import { SETUP_TOKEN_TRANSPORT_ADVISORY_CODE } from "@paperclipai/shared";
|
||||
import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/adapter-codex-local";
|
||||
import {
|
||||
checkStagedCredentialReadiness,
|
||||
|
|
@ -233,6 +253,11 @@ export function agentRoutes(
|
|||
leases: SetupTokenLeaseManager;
|
||||
/** The durable cleanup store. Defaults to the in-memory record store. */
|
||||
store?: SetupTokenCleanupStore;
|
||||
/**
|
||||
* The owner-bound secret writer. When the caller omits it, the completion
|
||||
* fails closed, because the secret sink is not bound yet.
|
||||
*/
|
||||
completeCredential?: SetupTokenSecretWriter;
|
||||
};
|
||||
} = {},
|
||||
) {
|
||||
|
|
@ -325,20 +350,47 @@ export function agentRoutes(
|
|||
// 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 scopeMatchesRow = (row: SetupTokenCleanupRecord, identity: {
|
||||
companyId: string;
|
||||
ownerUserId: string;
|
||||
adapterType: string;
|
||||
environmentId: string;
|
||||
}): boolean =>
|
||||
row.companyId === identity.companyId &&
|
||||
row.ownerUserId === identity.ownerUserId &&
|
||||
row.adapterType === identity.adapterType &&
|
||||
row.environmentId === identity.environmentId;
|
||||
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 markState(identity, state): Promise<void> {
|
||||
const row = setupTokenCleanupRows.get(identity.sessionId);
|
||||
if (row && scopeMatchesRow(row, identity)) row.state = state;
|
||||
},
|
||||
async remove(sessionId): Promise<void> {
|
||||
setupTokenCleanupRows.delete(sessionId);
|
||||
async remove(identity): Promise<void> {
|
||||
// The delete matches the full owner scope, so it never removes a row by the
|
||||
// session id alone.
|
||||
const row = setupTokenCleanupRows.get(identity.sessionId);
|
||||
if (row && scopeMatchesRow(row, identity)) setupTokenCleanupRows.delete(identity.sessionId);
|
||||
},
|
||||
async listReapable(): Promise<SetupTokenCleanupRecord[]> {
|
||||
return [];
|
||||
},
|
||||
async consumeStoredClaim(identity): Promise<SetupTokenCleanupRecord | null> {
|
||||
const row = setupTokenCleanupRows.get(identity.sessionId);
|
||||
if (
|
||||
!row ||
|
||||
!scopeMatchesRow(row, identity) ||
|
||||
row.state !== "stored" ||
|
||||
row.boundAt !== null ||
|
||||
row.deadline <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
row.boundAt = Date.now();
|
||||
return { ...row };
|
||||
},
|
||||
};
|
||||
|
||||
const deferredSetupTokenLoginFactory: SetupTokenLoginProcessFactory = () => {
|
||||
|
|
@ -347,19 +399,49 @@ export function agentRoutes(
|
|||
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 deferredSetupTokenSecretWriter: SetupTokenSecretWriter = async () => {
|
||||
// The owner-bound secret writer arrives through `options.setupTokenLogin`.
|
||||
// Until then the completion fails closed, so the session never reports a
|
||||
// stored credential without a real secret write.
|
||||
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
||||
};
|
||||
|
||||
// Resolve the transport: use the injected factory, lease manager, store, and
|
||||
// secret writer 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 setupTokenSecretWriter =
|
||||
options.setupTokenLogin?.completeCredential ?? deferredSetupTokenSecretWriter;
|
||||
|
||||
// Re-check the environment company binding at lease acquisition. The start
|
||||
// route runs `assertSandboxLoginEnvironment` before the session begins, but
|
||||
// managed-environment reconciliation can bind the sandbox to another company
|
||||
// between that guard and the lease acquire. This wrapper re-runs the same
|
||||
// guard at acquire time and fails closed with the 403
|
||||
// `environment_company_mismatch` before the transport provisions a sandbox.
|
||||
// The lease insert transaction re-checks the binding once more inside the
|
||||
// insert, so a bind that lands during the provider call still holds no lease.
|
||||
const guardedSetupTokenLeaseManager: SetupTokenLeaseManager = {
|
||||
async acquire(input): Promise<SetupTokenLease> {
|
||||
await assertSandboxLoginEnvironment(input.scope.companyId, input.scope.environmentId, {
|
||||
requireSetupTokenLoginProvider: true,
|
||||
});
|
||||
return setupTokenLeaseManager.acquire(input);
|
||||
},
|
||||
release: (lease) => setupTokenLeaseManager.release(lease),
|
||||
releaseById: (leaseId) => setupTokenLeaseManager.releaseById(leaseId),
|
||||
};
|
||||
|
||||
const setupTokenLoginService = new SetupTokenSessionService({
|
||||
factory: setupTokenLoginFactory,
|
||||
leases: setupTokenLeaseManager,
|
||||
leases: guardedSetupTokenLeaseManager,
|
||||
store: setupTokenCleanupStore,
|
||||
completeCredential: setupTokenSecretWriter,
|
||||
rateLimiter: setupTokenRateLimiter,
|
||||
});
|
||||
|
||||
|
|
@ -1088,13 +1170,69 @@ export function agentRoutes(
|
|||
// only in an active sandbox environment. This reuses the shared environment
|
||||
// selection guard, so it rejects a missing, archived (inactive), local, SSH, or
|
||||
// plugin environment the same way the agent configuration routes do.
|
||||
//
|
||||
// The execution environment catalog is instance-scoped, not company-owned. PR
|
||||
// #8375 moved the catalog to one shared instance catalog, so an environment row
|
||||
// carries no single owning company. The shared selection guard therefore checks
|
||||
// only the driver and the status. The company-binding check below then rejects
|
||||
// an environment that binds to other companies. The route caller is already
|
||||
// bound to the path company by `assertCompanyAccess`, and the acquired lease
|
||||
// records that same company, so the login stays attributed to the caller.
|
||||
async function assertSandboxLoginEnvironment(
|
||||
companyId: string,
|
||||
environmentId: string,
|
||||
options?: { requireSetupTokenLoginProvider?: boolean },
|
||||
): Promise<void> {
|
||||
await assertEnvironmentSelectionForCompany(environmentsSvc, companyId, environmentId, {
|
||||
allowedDrivers: ["sandbox"],
|
||||
});
|
||||
// Reject an environment that another company owns. A managed sandbox
|
||||
// environment binds to the companies that the instance provisions it for.
|
||||
// When the environment binds to companies but not the request company, the
|
||||
// environment belongs to another company. A login there runs the process in
|
||||
// a foreign company sandbox, so the guard fails closed. An environment with
|
||||
// no company binding is instance-global and stays open to every member.
|
||||
const boundCompanyIds = await environmentsSvc.listBoundCompanyIds(environmentId);
|
||||
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(companyId)) {
|
||||
throw forbidden("The selected environment belongs to another company.", {
|
||||
code: "environment_company_mismatch",
|
||||
});
|
||||
}
|
||||
// Gate the Claude setup-token login on the provider capability. Only a
|
||||
// sandbox provider that advertises the setup-token login capability
|
||||
// implements the setup-token pseudo-terminal methods. The setup-token start
|
||||
// routes pass this option, so an unsupported provider fails closed here
|
||||
// before the session starts. The lease guard passes it too, so a
|
||||
// reconciliation that rebinds the environment to an unsupported provider
|
||||
// still fails closed before the lease and the pseudo-terminal.
|
||||
if (options?.requireSetupTokenLoginProvider) {
|
||||
await assertSetupTokenLoginProviderCapability(environmentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails closed when the environment provider does not advertise the Claude
|
||||
* setup-token login capability. It resolves the effective provider from the
|
||||
* environment config, then reads the static capability from the provider
|
||||
* plugin manifest. It never checks the provider by name. A missing plugin, a
|
||||
* non-plugin provider, and a provider without the flag all fail closed with
|
||||
* the fixed, typed error, so no session row, lease, or pseudo-terminal starts.
|
||||
*/
|
||||
async function assertSetupTokenLoginProviderCapability(environmentId: string): Promise<void> {
|
||||
const environment = await environmentsSvc.getById(environmentId);
|
||||
const config =
|
||||
environment?.config && typeof environment.config === "object"
|
||||
? (environment.config as Record<string, unknown>)
|
||||
: {};
|
||||
const provider = typeof config.provider === "string" ? config.provider : "";
|
||||
const resolved = provider
|
||||
? await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider })
|
||||
: null;
|
||||
if (!resolved?.driver.supportsSetupTokenLogin) {
|
||||
throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, {
|
||||
code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read a login session for its owner. The durable row is the authority for the
|
||||
|
|
@ -2976,6 +3114,13 @@ export function agentRoutes(
|
|||
instructionsBundle,
|
||||
sourceIssueId: _sourceIssueId,
|
||||
sourceIssueIds: _sourceIssueIds,
|
||||
// The stored-session claim is not an agent column. The server derives the
|
||||
// owner from the authenticated actor and consumes the claim in the create
|
||||
// transaction, so it never reaches the insert values.
|
||||
storedSessionId: hireStoredSessionId,
|
||||
// The apply-existing flag is not an agent column. The server binds the
|
||||
// fixed reference to the owner stored value with no login round trip.
|
||||
applyStoredClaudeLogin: hireApplyStoredClaudeLogin,
|
||||
...hireInput
|
||||
} = req.body;
|
||||
hireInput.adapterType = assertSelectableAdapterType(hireInput.adapterType);
|
||||
|
|
@ -3032,13 +3177,26 @@ export function agentRoutes(
|
|||
|
||||
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
||||
const status = requiresApproval ? "pending_approval" : "idle";
|
||||
const createdAgent = await svc.create(companyId, {
|
||||
id: hiredAgentId,
|
||||
...normalizedHireInput,
|
||||
status,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const createdAgent = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
id: hiredAgentId,
|
||||
...normalizedHireInput,
|
||||
status,
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
},
|
||||
{
|
||||
claudeLogin: {
|
||||
storedSessionId: hireStoredSessionId ?? null,
|
||||
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
|
||||
// The apply-existing path runs only for a user actor. The owner comes
|
||||
// from the actor, so an agent actor never reaches the no-claim bind.
|
||||
applyExistingWithoutClaim:
|
||||
req.actor.type !== "agent" && hireApplyStoredClaudeLogin === true,
|
||||
},
|
||||
},
|
||||
);
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
|
||||
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
||||
|
|
@ -3172,6 +3330,13 @@ export function agentRoutes(
|
|||
const {
|
||||
desiredSkills: requestedDesiredSkills,
|
||||
instructionsBundle,
|
||||
// The stored-session claim is not an agent column. The server derives the
|
||||
// owner from the authenticated actor and consumes the claim in the create
|
||||
// transaction, so it never reaches the insert values.
|
||||
storedSessionId: createStoredSessionId,
|
||||
// The apply-existing flag is not an agent column. The server binds the
|
||||
// fixed reference to the owner stored value with no login round trip.
|
||||
applyStoredClaudeLogin: createApplyStoredClaudeLogin,
|
||||
...createInput
|
||||
} = req.body;
|
||||
createInput.adapterType = assertSelectableAdapterType(createInput.adapterType);
|
||||
|
|
@ -3216,15 +3381,28 @@ export function agentRoutes(
|
|||
allowedSandboxProviders: allowedSandboxProvidersForAgent(createInput.adapterType),
|
||||
});
|
||||
|
||||
const createdAgent = await svc.create(companyId, {
|
||||
id: agentId,
|
||||
...createInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizedRuntimeConfig,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
});
|
||||
const createdAgent = await svc.create(
|
||||
companyId,
|
||||
{
|
||||
id: agentId,
|
||||
...createInput,
|
||||
adapterConfig: normalizedAdapterConfig,
|
||||
runtimeConfig: normalizedRuntimeConfig,
|
||||
status: "idle",
|
||||
spentMonthlyCents: 0,
|
||||
lastHeartbeatAt: null,
|
||||
},
|
||||
{
|
||||
claudeLogin: {
|
||||
storedSessionId: createStoredSessionId ?? null,
|
||||
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
|
||||
// The apply-existing path runs only for a user actor. The owner comes
|
||||
// from the actor, so an agent actor never reaches the no-claim bind.
|
||||
applyExistingWithoutClaim:
|
||||
req.actor.type !== "agent" && createApplyStoredClaudeLogin === true,
|
||||
},
|
||||
},
|
||||
);
|
||||
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
|
|
@ -3572,6 +3750,11 @@ export function agentRoutes(
|
|||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const replaceAdapterConfig = patchData.replaceAdapterConfig === true;
|
||||
delete patchData.replaceAdapterConfig;
|
||||
// The apply-existing flag is not an agent column. The server binds the fixed
|
||||
// reference to the owner stored value with no login round trip. Remove it
|
||||
// from the patch so it never reaches the update values.
|
||||
const applyStoredClaudeLogin = patchData.applyStoredClaudeLogin === true;
|
||||
delete patchData.applyStoredClaudeLogin;
|
||||
if (hasOwn(patchData, "adapterConfig")) {
|
||||
const adapterConfig = asRecord(patchData.adapterConfig);
|
||||
if (!adapterConfig) {
|
||||
|
|
@ -3698,6 +3881,13 @@ export function agentRoutes(
|
|||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
source: "patch",
|
||||
},
|
||||
claudeLogin: {
|
||||
ownerUserId: req.actor.type === "agent" ? null : (req.actor.userId ?? null),
|
||||
// The apply-existing path runs only for a user actor. The owner comes
|
||||
// from the actor, so an agent actor never reaches the no-claim bind.
|
||||
applyExistingWithoutClaim:
|
||||
req.actor.type !== "agent" && applyStoredClaudeLogin,
|
||||
},
|
||||
});
|
||||
if (!agent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
|
|
@ -4229,11 +4419,10 @@ export function agentRoutes(
|
|||
|
||||
// --- 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
|
||||
// The routes give the UI operations against one live login session. Every
|
||||
// operation verifies the company and owner user through the session scope. A
|
||||
// missing session and a cross-scope session both return the same 404. The
|
||||
// confidential responses pass through the transport assessment 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.
|
||||
|
|
@ -4251,28 +4440,63 @@ export function agentRoutes(
|
|||
// 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.
|
||||
* Derives the immutable owner of a setup-token login session from the actor.
|
||||
* Only a board user owns a login session. It returns the owner id, or it
|
||||
* throws a forbidden error. The owner is never a client field; it comes only
|
||||
* from the authenticated actor.
|
||||
*/
|
||||
const buildSetupTokenScope = (
|
||||
req: Request,
|
||||
agent: { id: string; companyId: string },
|
||||
): SetupTokenSessionScope => {
|
||||
const deriveSetupTokenOwnerUserId = (req: Request): string => {
|
||||
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 };
|
||||
return actor.actorId;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Read-access gate for the company-scoped setup-token session routes. It runs
|
||||
* before a route resolves a session. The session id is an opaque secret-bearing
|
||||
* reference, so a cross-company reference must fail closed like a missing
|
||||
* session. This gate returns the same fixed not-found error for a cross-company
|
||||
* reference by an authenticated non-member as for a missing session, so the
|
||||
* route is not a company-membership oracle. It keeps the not-found equivalence
|
||||
* the session lookups use.
|
||||
*
|
||||
* The gate keeps the actor rules unchanged. It throws 401 for an unauthenticated
|
||||
* caller and 403 for a non-user actor through the owner derivation. For an
|
||||
* authorized member it runs the full `assertCompanyAccess` write-path checks and
|
||||
* returns the owner user id. For a non-member it sends the fixed 404 and returns
|
||||
* null; the route must stop.
|
||||
*/
|
||||
const enforceSetupTokenTransport = (req: Request, res: Response): boolean => {
|
||||
const resolveCompanySessionOwner = (
|
||||
req: Request,
|
||||
companyId: string,
|
||||
res: Response,
|
||||
): string | null => {
|
||||
assertAuthenticated(req);
|
||||
const ownerUserId = deriveSetupTokenOwnerUserId(req);
|
||||
if (!hasCompanyAccess(req, companyId)) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND });
|
||||
return null;
|
||||
}
|
||||
assertCompanyAccess(req, companyId);
|
||||
return ownerUserId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assesses the setup-token confidential transport. The product
|
||||
* owner set a non-negotiable requirement: do not force TLS. Many users run
|
||||
* Paperclip over plain HTTP on a home server or a Tailscale tailnet. So the
|
||||
* route does not block a non-confidential transport. It returns a non-blocking
|
||||
* advisory instead, and the route attaches it to the confidential response.
|
||||
* The client shows a visible disclaimer and lets the login proceed. The
|
||||
* function reads the raw socket TLS bit and the immediate peer address, so the
|
||||
* global `trust proxy` setting cannot change the result. It returns null when
|
||||
* the transport is confidential (direct TLS, a local-trusted loopback, or an
|
||||
* allowlisted TLS proxy), so a confidential response shows no disclaimer.
|
||||
*/
|
||||
const assessSetupTokenTransport = (req: Request): SetupTokenTransportAdvisory | null => {
|
||||
const socket = req.socket as { encrypted?: boolean; remoteAddress?: string };
|
||||
const forwardedProto = req.headers["x-forwarded-proto"];
|
||||
const decision = evaluateConfidentialTransport(setupTokenConfidentialConfig, {
|
||||
|
|
@ -4280,12 +4504,7 @@ export function agentRoutes(
|
|||
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;
|
||||
return decision.allowed ? null : { code: SETUP_TOKEN_TRANSPORT_ADVISORY_CODE };
|
||||
};
|
||||
|
||||
const sendSetupTokenError = (res: Response, err: unknown): void => {
|
||||
|
|
@ -4296,123 +4515,324 @@ export function agentRoutes(
|
|||
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;
|
||||
// --- Company-and-environment setup-token login routes ----------------------
|
||||
//
|
||||
// These routes serve the agentless Claude login. The scope binds one login to
|
||||
// one company, one owner user, one adapter, and one environment. The scope
|
||||
// carries no agent id, so a hire flow starts one login before an agent exists.
|
||||
//
|
||||
// Object-level authorization: every action derives the owner from
|
||||
// the authenticated actor, fixes the adapter to `claude_local`, and resolves
|
||||
// the environment server-side. The lookup scopes by the immutable tuple
|
||||
// company, owner, adapter, environment, and session. A foreign session returns
|
||||
// the same not-found error as a missing session, so a caller cannot enumerate
|
||||
// a session across a company, an owner, an adapter, or an environment.
|
||||
//
|
||||
// Each route writes its full path as a plain string literal, so the static
|
||||
// OpenAPI coverage test can read the path from the source text.
|
||||
|
||||
// The company-and-environment login serves only the Claude adapter.
|
||||
const CLAUDE_SETUP_TOKEN_ADAPTER_TYPE = "claude_local";
|
||||
|
||||
// Maps the internal session state to the public login status. The public union
|
||||
// carries no server-only state, so the route never returns the internal
|
||||
// `submitting` or `stored` state to a client.
|
||||
const toClaudeLoginStatus = (state: SetupTokenSessionState): AdapterAuthSessionStatus => {
|
||||
switch (state) {
|
||||
case "starting":
|
||||
return "starting";
|
||||
case "awaiting_code":
|
||||
case "submitting":
|
||||
case "stored":
|
||||
return "waiting_for_user";
|
||||
case "completed":
|
||||
return "authenticated";
|
||||
case "failed":
|
||||
return "failed";
|
||||
case "timed_out":
|
||||
return "timed_out";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
}
|
||||
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);
|
||||
// Builds the fixed, non-secret failure for a terminal failure state. A live or
|
||||
// a completed session has no failure. The failure carries a stable reason and
|
||||
// no secret detail.
|
||||
const toClaudeLoginFailure = (state: SetupTokenSessionState): AdapterAuthSessionFailure | null => {
|
||||
switch (state) {
|
||||
case "failed":
|
||||
return { reason: "failed", message: null };
|
||||
case "timed_out":
|
||||
return { reason: "timed_out", message: null };
|
||||
case "cancelled":
|
||||
return { reason: "cancelled", message: null };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// The public login-session response. It carries no prompt and no secret.
|
||||
const toClaudePublicResponse = (
|
||||
descriptor: SetupTokenSessionDescriptor,
|
||||
): ClaudeSetupTokenSessionResponse => ({
|
||||
sessionId: descriptor.sessionId,
|
||||
environmentId: descriptor.environmentId,
|
||||
status: toClaudeLoginStatus(descriptor.state),
|
||||
expiresAt: new Date(descriptor.deadline).toISOString(),
|
||||
failure: toClaudeLoginFailure(descriptor.state),
|
||||
});
|
||||
|
||||
// The company-and-environment login key the non-start routes derive. The route
|
||||
// path gives the company, the actor gives the owner, and the route fixes the
|
||||
// adapter. The service matches this key and the agentless marker.
|
||||
const companySetupTokenKey = (companyId: string, ownerUserId: string) => ({
|
||||
companyId,
|
||||
ownerUserId,
|
||||
adapterType: CLAUDE_SETUP_TOKEN_ADAPTER_TYPE,
|
||||
});
|
||||
|
||||
// The stored Claude OAuth token status read. It returns
|
||||
// only the secret id and the latest version of the owner value; it returns no
|
||||
// token. The client reads the version, applies the stored token first, and
|
||||
// captures the version for a later confirmed overwrite. The route derives the
|
||||
// owner only from the authenticated actor and reads the fixed Claude
|
||||
// definition; it accepts no owner, no definition, and no secret id as input.
|
||||
//
|
||||
// The route returns the same fixed 404 for a missing owner value as the
|
||||
// company gate returns for a non-member, so it discloses no existence
|
||||
// distinction across owners or companies. It sets `Cache-Control: no-store`,
|
||||
// so no cache holds the metadata.
|
||||
router.get("/companies/:companyId/claude-oauth-token-status", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const status = await secretsSvc.readClaudeOAuthUserSecretStatus(companyId, ownerUserId);
|
||||
if (!status) {
|
||||
// A missing owner value returns the same fixed not-found as the non-member
|
||||
// gate, so a member without a value and a non-member look the same.
|
||||
res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND });
|
||||
return;
|
||||
}
|
||||
const body: ClaudeOAuthTokenStatusResponse = status;
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/setup-token-login-sessions", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const ownerUserId = deriveSetupTokenOwnerUserId(req);
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
// Parse the request with the shared strict validator before any session,
|
||||
// lease, or pseudo-terminal side effect. `.strict()` rejects an unknown field,
|
||||
// including a legacy `ttlSeconds`, with a fixed 400. The route echoes no input.
|
||||
const parsed = startClaudeSetupTokenSessionRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "The Claude login start request is invalid." });
|
||||
return;
|
||||
}
|
||||
const { environmentId, adapterType } = parsed.data;
|
||||
const confirmedOverwrite: ClaudeSetupTokenOverwrite | null = parsed.data.overwrite ?? null;
|
||||
|
||||
// The company-and-environment login serves only the Claude adapter. A
|
||||
// different adapter type gets a fixed 400.
|
||||
if (adapterType !== CLAUDE_SETUP_TOKEN_ADAPTER_TYPE) {
|
||||
res.status(400).json({ error: "Only the claude_local adapter supports a setup-token login." });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Resolve the environment server-side to a live instance sandbox. It fails
|
||||
// closed on a missing, archived, non-sandbox, or fake-provider environment, so
|
||||
// the route never persists an empty or invalid environment scope. It runs
|
||||
// before the session starts, so no store holds a rejected environment. It
|
||||
// also rejects an environment that another company owns (see
|
||||
// `assertSandboxLoginEnvironment`), so a login never runs in a foreign
|
||||
// company sandbox. It also fails closed when the provider does not advertise
|
||||
// the setup-token login capability, so an unsupported provider never reaches
|
||||
// a session row, a lease, or a pseudo-terminal.
|
||||
await assertSandboxLoginEnvironment(companyId, environmentId, {
|
||||
requireSetupTokenLoginProvider: true,
|
||||
});
|
||||
|
||||
const scope: SetupTokenSessionScope = {
|
||||
companyId,
|
||||
ownerUserId,
|
||||
targetAgentId: null,
|
||||
adapterType,
|
||||
environmentId,
|
||||
confirmedOverwrite,
|
||||
};
|
||||
try {
|
||||
const started = await setupTokenLoginService.start(scope);
|
||||
res.status(201).json({ sessionId: started.sessionId, state: started.state });
|
||||
const descriptor = setupTokenLoginService.describeOwned(started.sessionId, scope);
|
||||
// The start response carries the panel mode, so the client renders the
|
||||
// browser-code panel. The full login URL rides only through the
|
||||
// guarded prompt read, not the start response, so the prompt is null here.
|
||||
// The client reads the prompt route for the login URL.
|
||||
const body: ClaudeSetupTokenSessionOwnerResponse = {
|
||||
...toClaudePublicResponse(descriptor),
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: null,
|
||||
};
|
||||
res.status(201).json(body);
|
||||
} 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);
|
||||
router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
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 });
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const scope = setupTokenLoginService.resolveCompanyScope(
|
||||
sessionId,
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
const descriptor = setupTokenLoginService.describeOwned(sessionId, scope);
|
||||
// The status response is public. It carries no prompt and no secret.
|
||||
res.json(toClaudePublicResponse(descriptor));
|
||||
} 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;
|
||||
router.get("/companies/:companyId/setup-token-login-sessions/:sessionId/prompt", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
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." });
|
||||
// The full login URL is a confidential response. The route
|
||||
// does not force TLS. It attaches a non-blocking advisory instead.
|
||||
const transportAdvisory = assessSetupTokenTransport(req);
|
||||
try {
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const scope = setupTokenLoginService.resolveCompanyScope(
|
||||
sessionId,
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
const descriptor = setupTokenLoginService.describeOwned(sessionId, scope);
|
||||
if (!descriptor.loginUrl) {
|
||||
// The prompt has not surfaced yet. Return the same not-found error as a
|
||||
// missing or a foreign session, so the route never confirms the session
|
||||
// exists before the URL is ready.
|
||||
res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND });
|
||||
return;
|
||||
}
|
||||
// The full login URL rides only in this authorized owner response.
|
||||
const body: ClaudeSetupTokenSessionPrompt = {
|
||||
authorizationUrl: descriptor.loginUrl,
|
||||
transportAdvisory,
|
||||
};
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
sendSetupTokenError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/code", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
// The browser code is the confidential OAuth authorization
|
||||
// secret. The route does not force TLS. It attaches a non-blocking advisory
|
||||
// to the response instead, so the client can show a disclaimer.
|
||||
const transportAdvisory = assessSetupTokenTransport(req);
|
||||
// Parse the request with the shared strict validator before the route forwards
|
||||
// the code to the live process. `.strict()` rejects an unknown field, and the
|
||||
// grammar rejects an empty, an oversized, or a control-byte code. The route
|
||||
// echoes no input; it returns fixed error text only.
|
||||
const parsed = submitBrowserCodeRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: "A valid browser code is required." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = setupTokenLoginService.submitCode(req.params.sessionId as string, scope, browserCode);
|
||||
res.json({ state: result.state });
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const scope = setupTokenLoginService.resolveCompanyScope(
|
||||
sessionId,
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
setupTokenLoginService.submitCode(sessionId, scope, parsed.data.browserCode);
|
||||
const descriptor = setupTokenLoginService.describeOwned(sessionId, scope);
|
||||
const body: ClaudeSetupTokenSessionResponse = {
|
||||
...toClaudePublicResponse(descriptor),
|
||||
transportAdvisory,
|
||||
};
|
||||
res.json(body);
|
||||
} 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);
|
||||
router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/completion", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
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 });
|
||||
const sessionId = req.params.sessionId as string;
|
||||
const scope = setupTokenLoginService.resolveCompanyScope(
|
||||
sessionId,
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
// The service returns the non-secret `storedSessionId` claim from a
|
||||
// completed session whose owner-bound secret write succeeded. The response
|
||||
// carries no token.
|
||||
const result = setupTokenLoginService.completeSession(sessionId, scope);
|
||||
const body: ClaudeSetupTokenCompletionResponse = { storedSessionId: result.storedSessionId };
|
||||
res.json(body);
|
||||
} catch (err) {
|
||||
sendSetupTokenError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/setup-token-login-sessions/:sessionId/cancel", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
const sessionId = req.params.sessionId as string;
|
||||
try {
|
||||
const scope = setupTokenLoginService.resolveCompanyScope(
|
||||
sessionId,
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
await setupTokenLoginService.cancel(sessionId, scope);
|
||||
res.status(200).json({});
|
||||
} catch (err) {
|
||||
// Cancel is idempotent. The service removes a session when it reaches a
|
||||
// terminal state, so a repeat cancel, a cancel after a timeout, or a
|
||||
// cancel of an unknown session finds no record and throws the fixed
|
||||
// not-found error. Return the same success as an active cancel, so the
|
||||
// client stops the poll and returns to its start state.
|
||||
//
|
||||
// This keeps the not-found uniform. The 200 response is identical
|
||||
// for a missing session, an already-terminal session, and a foreign
|
||||
// session, so the route never confirms a session exists and cancels
|
||||
// nothing for a foreign id. A non-member still fails closed with a 404 at
|
||||
// the company-access gate above, before this handler runs. A non-404
|
||||
// error still surfaces.
|
||||
if (err instanceof SetupTokenSessionError && err.status === 404) {
|
||||
res.status(200).json({});
|
||||
return;
|
||||
}
|
||||
sendSetupTokenError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/heartbeat-runs", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
|
|
|||
|
|
@ -583,6 +583,12 @@ export function environmentRoutes(
|
|||
if (impact.staticReferences.isInstanceDefault) {
|
||||
return "Cannot delete the current instance default environment. Set a new default environment before deleting this one.";
|
||||
}
|
||||
if (impact.pendingCleanupLeaseCount > 0) {
|
||||
return "Cannot delete this environment while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.";
|
||||
}
|
||||
if (impact.reusableSandboxLeaseCount > 0) {
|
||||
return "Cannot delete this environment while it has a reusable sandbox lease. Remove the associated execution workspace or issue so Paperclip can destroy the sandbox, then retry.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -735,6 +741,7 @@ export function environmentRoutes(
|
|||
templateRefKind: driver.templateRefKind,
|
||||
templateConfigBinding: driver.templateConfigBinding,
|
||||
supportsTemplateDelete: driver.supportsTemplateDelete,
|
||||
supportsSetupTokenLogin: driver.supportsSetupTokenLogin ?? false,
|
||||
displayName: driver.displayName,
|
||||
description: driver.description,
|
||||
source: "plugin" as const,
|
||||
|
|
@ -1089,6 +1096,27 @@ export function environmentRoutes(
|
|||
}),
|
||||
});
|
||||
assertNoClientPlatformProvisionedMarkers(req.body.metadata);
|
||||
// The durable `pending_cleanup` lease row stores the provider, the provider
|
||||
// lease id, and the immutable config metadata for an orphan sandbox. The
|
||||
// teardown retry reads that row alone and never reads the current environment
|
||||
// provider. So a provider change or an environment delete after the record
|
||||
// lands cannot strand the teardown. That immutable record is the correctness
|
||||
// invariant.
|
||||
//
|
||||
// This pre-transaction check is a best-effort fast-fail only. It rejects a
|
||||
// provider change while a known `pending_cleanup` lease exists, so the
|
||||
// operator resolves the cleanup first. An orphan record that lands after this
|
||||
// check still carries its own immutable teardown context, so the
|
||||
// time-of-check-to-time-of-use window here cannot strand a sandbox.
|
||||
const changesProviderTarget =
|
||||
(req.body.driver !== undefined && req.body.driver !== existing.driver) ||
|
||||
req.body.config !== undefined;
|
||||
if (changesProviderTarget && (await svc.hasUnresolvedPendingCleanupLeases(existing.id))) {
|
||||
throw conflict(
|
||||
"Cannot change the driver or provider config while a sandbox cleanup is pending. Wait for the cleanup sweep to destroy the orphan sandbox, then retry.",
|
||||
{ code: "environment_pending_sandbox_cleanup" },
|
||||
);
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
const nextDriver = req.body.driver ?? existing.driver;
|
||||
const nextName = req.body.name ?? existing.name;
|
||||
|
|
|
|||
|
|
@ -224,6 +224,13 @@ import {
|
|||
importMcpJsonSchema,
|
||||
toolPolicyTestRequestSchema,
|
||||
createToolMcpGatewaySchema,
|
||||
startClaudeSetupTokenSessionRequestSchema,
|
||||
submitBrowserCodeRequestSchema,
|
||||
claudeSetupTokenSessionResponseSchema,
|
||||
claudeSetupTokenSessionPromptSchema,
|
||||
claudeSetupTokenSessionOwnerResponseSchema,
|
||||
claudeSetupTokenCompletionResponseSchema,
|
||||
claudeOAuthTokenStatusResponseSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
COMPANY_IMPORT_TRANSFERS_API_PATH,
|
||||
|
|
@ -385,6 +392,10 @@ function zodToOpenApiSchema(schema: z.ZodTypeAny): JsonSchema {
|
|||
}
|
||||
const jsonSchema: JsonSchema = { type: "object", properties };
|
||||
if (required.length > 0) jsonSchema.required = required;
|
||||
// A `.strict()` Zod object forbids an unknown key. Publish that constraint
|
||||
// as `additionalProperties: false`, so a client, a gateway, or a handler
|
||||
// that treats the contract as authoritative rejects an extra property too.
|
||||
if (unwrapped._def.unknownKeys === "strict") jsonSchema.additionalProperties = false;
|
||||
return jsonSchema;
|
||||
}
|
||||
|
||||
|
|
@ -4498,18 +4509,44 @@ 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.
|
||||
// Company-and-environment Claude setup-token login session routes. The owner
|
||||
// user starts one session for one adapter in one environment. The scope carries
|
||||
// no agent id, so a hire flow with no agent still starts one session. The owner
|
||||
// reads the login prompt, submits the browser code from the browser, and reads
|
||||
// the completion claim. The prompt, code, and completion responses require a
|
||||
// confidential transport; the guard returns 403 when the transport is not
|
||||
// confidential. No response carries a token; the completion returns the
|
||||
// non-secret `storedSessionId` claim.
|
||||
//
|
||||
// The stored-token status read returns only the secret id and the latest version
|
||||
// of the owner value; it returns no token. The server derives the owner from the
|
||||
// authenticated actor. A missing or a foreign value returns the same fixed 404,
|
||||
// so the read discloses no existence distinction. The response is `no-store`.
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/claude-oauth-token-status",
|
||||
tags: ["companies"],
|
||||
summary: "Read the stored Claude OAuth token status for the authenticated owner",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(claudeOAuthTokenStatusResponseSchema),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
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() }) },
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions",
|
||||
tags: ["companies"],
|
||||
summary: "Start a company-and-environment Claude setup-token login session",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string() }),
|
||||
body: jsonBody(startClaudeSetupTokenSessionRequestSchema),
|
||||
},
|
||||
responses: {
|
||||
201: r.ok(),
|
||||
201: r.ok(claudeSetupTokenSessionOwnerResponseSchema),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
|
|
@ -4520,12 +4557,26 @@ registry.registerPath({
|
|||
|
||||
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() }) },
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}",
|
||||
tags: ["companies"],
|
||||
summary: "Read the status of a Claude setup-token login session",
|
||||
request: { params: z.object({ companyId: z.string(), sessionId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
200: r.ok(claudeSetupTokenSessionResponseSchema),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/prompt",
|
||||
tags: ["companies"],
|
||||
summary: "Read the login prompt for a Claude setup-token login session",
|
||||
request: { params: z.object({ companyId: z.string(), sessionId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(claudeSetupTokenSessionPromptSchema),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
|
|
@ -4535,12 +4586,15 @@ registry.registerPath({
|
|||
|
||||
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() }) },
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/code",
|
||||
tags: ["companies"],
|
||||
summary: "Submit the browser code for a Claude setup-token login session",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), sessionId: z.string() }),
|
||||
body: jsonBody(submitBrowserCodeRequestSchema),
|
||||
},
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
200: r.ok(claudeSetupTokenSessionResponseSchema),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
|
|
@ -4550,40 +4604,12 @@ registry.registerPath({
|
|||
|
||||
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() }) },
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/completion",
|
||||
tags: ["companies"],
|
||||
summary: "Read the completion claim of a Claude setup-token login session",
|
||||
request: { params: z.object({ companyId: 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(),
|
||||
200: r.ok(claudeSetupTokenCompletionResponseSchema),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
|
|
@ -4591,6 +4617,25 @@ registry.registerPath({
|
|||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/cancel",
|
||||
tags: ["companies"],
|
||||
summary: "Cancel a Claude setup-token login session",
|
||||
// The 404 is reserved for the pre-scope non-member gate. The company-access
|
||||
// gate runs before the cancel logic and returns a fixed 404 for a non-member.
|
||||
// Cancel itself is idempotent and stays uniform: a same-company owner-scoped
|
||||
// missing, terminal, or foreign session id all return 200. So the route never
|
||||
// confirms a session exists for the owner.
|
||||
request: { params: z.object({ companyId: z.string(), sessionId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Issue interactions & tree ───────────────────────────────────────────────
|
||||
|
||||
registry.registerPath({
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -29,7 +29,14 @@ import { conflict, notFound, unprocessable } from "../errors.js";
|
|||
import { syncAgentAdapterEnvBindings } from "./agent-secret-bindings.js";
|
||||
import { normalizeAgentPermissions } from "./agent-permissions.js";
|
||||
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import {
|
||||
assertClaudeOAuthBindingInvariant,
|
||||
claudeOAuthClaimRejectedError,
|
||||
CLAUDE_LOCAL_ADAPTER_TYPE,
|
||||
secretService,
|
||||
type ClaudeOAuthBindingInvariantDecision,
|
||||
} from "./secrets.js";
|
||||
import { createDbSetupTokenCleanupStore } from "./setup-token-session.js";
|
||||
import {
|
||||
builtInAgentMarkersEqual,
|
||||
readBuiltInAgentMarker,
|
||||
|
|
@ -69,14 +76,36 @@ interface RevisionMetadata {
|
|||
rolledBackFromRevisionId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude login context for an agent write. The route derives the owner user
|
||||
* from the authenticated actor, not from the request body, and forwards the
|
||||
* non-secret `storedSessionId` claim from a completed Claude login session. A
|
||||
* controlled internal override permits a migration or an administrator repair to
|
||||
* bind or unbind the fixed OAuth token without a claim.
|
||||
*
|
||||
* The `applyExistingWithoutClaim` field is the user-actor apply-existing path.
|
||||
* The route sets it only for an authenticated user actor and derives the owner
|
||||
* from that actor. The path binds the fixed reference to the owner stored value
|
||||
* with no login round trip. It is distinct from `allowInternalBindingOverride`,
|
||||
* which does no ownership check.
|
||||
*/
|
||||
interface ClaudeLoginContext {
|
||||
storedSessionId?: string | null;
|
||||
ownerUserId?: string | null;
|
||||
allowInternalBindingOverride?: boolean;
|
||||
applyExistingWithoutClaim?: boolean;
|
||||
}
|
||||
|
||||
interface UpdateAgentOptions {
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
allowPendingApprovalConfigUpdate?: boolean;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
}
|
||||
|
||||
interface CreateAgentOptions {
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
}
|
||||
|
||||
interface AgentShortnameRow {
|
||||
|
|
@ -447,6 +476,83 @@ export function agentService(db: Db) {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the Claude OAuth binding claim inside a write transaction. It runs
|
||||
* after {@link assertClaudeOAuthBindingInvariant} decided that the write
|
||||
* introduces or keeps the fixed binding.
|
||||
*
|
||||
* When the write introduces the fixed binding:
|
||||
* * A create or hire path (`consume: true`) consumes a stored-session claim
|
||||
* with one conditional write. It builds the claim scope from the company,
|
||||
* the owner user, the fixed adapter, the environment, and the
|
||||
* `storedSessionId`. It inserts the binding only when the write returns one
|
||||
* row; otherwise it raises the fixed claim error, which rolls back the
|
||||
* whole transaction and inserts no binding.
|
||||
* * An update, approval, or rollback path (`consume: false`) carries no
|
||||
* claim, so it raises the same fixed claim error at once.
|
||||
*
|
||||
* The user-actor apply-existing path (`applyExistingWithoutClaim`) binds the
|
||||
* fixed reference with no login round trip. The route sets the flag only for
|
||||
* an authenticated user actor and derives the owner from that actor. The gate
|
||||
* permits the no-claim bind only when the owner already has a stored value for
|
||||
* the company. It reads the owner value status; it reads no token. A missing
|
||||
* owner or a missing stored value raises the same fixed claim error, so the
|
||||
* caller cannot tell the reasons apart.
|
||||
*
|
||||
* A controlled internal override skips the claim for a migration or an
|
||||
* administrator repair. The function creates the fixed user-secret definition
|
||||
* before the caller runs the declaration synchronization, so the synchronized
|
||||
* declaration always references an existing definition.
|
||||
*/
|
||||
async function enforceClaudeOAuthBindingClaim(
|
||||
txDb: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
decision: ClaudeOAuthBindingInvariantDecision;
|
||||
consume: boolean;
|
||||
environmentId: string | null;
|
||||
claudeLogin?: ClaudeLoginContext;
|
||||
},
|
||||
): Promise<void> {
|
||||
const ownerUserId = input.claudeLogin?.ownerUserId ?? null;
|
||||
if (input.decision.introducesBinding && !input.claudeLogin?.allowInternalBindingOverride) {
|
||||
if (input.claudeLogin?.applyExistingWithoutClaim) {
|
||||
// The user-actor apply-existing path. The route derived the owner from
|
||||
// the authenticated user actor. The gate binds the fixed reference only
|
||||
// when that owner already has a stored value. It reads no token.
|
||||
if (!ownerUserId) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
const stored = await secretService(txDb).readClaudeOAuthUserSecretStatus(
|
||||
input.companyId,
|
||||
ownerUserId,
|
||||
);
|
||||
if (!stored) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
} else if (!input.consume) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
} else {
|
||||
const consumed = await createDbSetupTokenCleanupStore(txDb).consumeStoredClaim({
|
||||
sessionId: input.claudeLogin?.storedSessionId ?? "",
|
||||
companyId: input.companyId,
|
||||
ownerUserId: ownerUserId ?? "",
|
||||
adapterType: CLAUDE_LOCAL_ADAPTER_TYPE,
|
||||
environmentId: input.environmentId ?? "",
|
||||
});
|
||||
if (!consumed) {
|
||||
throw claudeOAuthClaimRejectedError();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (input.decision.introducesBinding || input.decision.keepsBinding) {
|
||||
// Create the fixed definition before declaration synchronization.
|
||||
await secretService(txDb).ensureClaudeOAuthUserSecretDefinition(input.companyId, {
|
||||
userId: ownerUserId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function assertBuiltInAgentMetadataMutationAllowed(
|
||||
beforeMetadata: unknown,
|
||||
afterMetadata: unknown,
|
||||
|
|
@ -526,6 +632,17 @@ export function agentService(db: Db) {
|
|||
{ adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string },
|
||||
);
|
||||
}
|
||||
// Run the server-enforced binding invariant when the patch touches the
|
||||
// adapter config. The update, approval, and rollback paths keep an existing
|
||||
// fixed binding but reject a newly introduced binding, because they carry no
|
||||
// stored-session claim.
|
||||
const bindingDecision = Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")
|
||||
? assertClaudeOAuthBindingInvariant({
|
||||
adapterType: (normalizedPatch.adapterType ?? existing.adapterType) as string,
|
||||
nextConfig: normalizedPatch.adapterConfig,
|
||||
priorConfig: existing.adapterConfig,
|
||||
})
|
||||
: null;
|
||||
|
||||
const shouldRecordRevision = Boolean(options?.recordRevision) && hasConfigPatchFields(normalizedPatch);
|
||||
const beforeConfig = shouldRecordRevision ? buildConfigSnapshot(existing) : null;
|
||||
|
|
@ -541,6 +658,15 @@ export function agentService(db: Db) {
|
|||
if (!updated) return null;
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig")) {
|
||||
if (bindingDecision) {
|
||||
await enforceClaudeOAuthBindingClaim(txDb, {
|
||||
companyId: existing.companyId,
|
||||
decision: bindingDecision,
|
||||
consume: false,
|
||||
environmentId: null,
|
||||
claudeLogin: options?.claudeLogin,
|
||||
});
|
||||
}
|
||||
await syncAgentSecretBindings(updated, txDb);
|
||||
}
|
||||
|
||||
|
|
@ -612,8 +738,25 @@ export function agentService(db: Db) {
|
|||
const adapterConfig = isPlainRecord(data.adapterConfig)
|
||||
? await secretsSvc.normalizeAdapterConfigForPersistence(companyId, data.adapterConfig, { adapterType })
|
||||
: {};
|
||||
// Run the server-enforced binding invariant after generic normalization
|
||||
// and before any database write. A create has no prior config.
|
||||
const bindingDecision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType,
|
||||
nextConfig: adapterConfig,
|
||||
priorConfig: null,
|
||||
});
|
||||
return db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
// Consume the stored-session claim and create the fixed definition inside
|
||||
// the same transaction that inserts the binding. A rejected claim rolls
|
||||
// back the whole transaction and inserts no binding.
|
||||
await enforceClaudeOAuthBindingClaim(txDb, {
|
||||
companyId,
|
||||
decision: bindingDecision,
|
||||
consume: true,
|
||||
environmentId: (data.defaultEnvironmentId as string | null | undefined) ?? null,
|
||||
claudeLogin: options?.claudeLogin,
|
||||
});
|
||||
const created = await tx
|
||||
.insert(agents)
|
||||
.values({
|
||||
|
|
@ -790,6 +933,7 @@ export function agentService(db: Db) {
|
|||
if (!existing || existing.status !== "pending_approval") return null;
|
||||
const approvedPatch = approvedPayload ? configPatchFromApprovalPayload(approvedPayload) : {};
|
||||
let patch = { ...approvedPatch } as Partial<typeof agents.$inferInsert>;
|
||||
let approvalBindingDecision: ClaudeOAuthBindingInvariantDecision | null = null;
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(patch, "adapterConfig") &&
|
||||
isPlainRecord(patch.adapterConfig)
|
||||
|
|
@ -799,6 +943,13 @@ export function agentService(db: Db) {
|
|||
patch.adapterConfig,
|
||||
{ adapterType: (patch.adapterType ?? existing.adapterType) as string },
|
||||
);
|
||||
// The approval activation keeps an existing fixed binding but rejects a
|
||||
// newly introduced binding, because it carries no stored-session claim.
|
||||
approvalBindingDecision = assertClaudeOAuthBindingInvariant({
|
||||
adapterType: (patch.adapterType ?? existing.adapterType) as string,
|
||||
nextConfig: patch.adapterConfig,
|
||||
priorConfig: existing.adapterConfig,
|
||||
});
|
||||
}
|
||||
if (patch.permissions !== undefined) {
|
||||
patch.permissions = normalizeAgentPermissions(
|
||||
|
|
@ -813,6 +964,14 @@ export function agentService(db: Db) {
|
|||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!updated) return null;
|
||||
if (approvalBindingDecision) {
|
||||
await enforceClaudeOAuthBindingClaim(txDb, {
|
||||
companyId: existing.companyId,
|
||||
decision: approvalBindingDecision,
|
||||
consume: false,
|
||||
environmentId: null,
|
||||
});
|
||||
}
|
||||
await syncAgentSecretBindings(updated, txDb);
|
||||
const agent = await agentService(txDb).getById(updated.id);
|
||||
if (!agent) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Bundled plugin auto-provisioning.
|
||||
|
|
@ -190,16 +191,23 @@ interface RegistryPluginRow {
|
|||
id: string;
|
||||
pluginKey: string;
|
||||
status: string;
|
||||
version: string;
|
||||
manifestJson: PaperclipPluginManifestV1;
|
||||
}
|
||||
|
||||
export interface BundledPluginProvisionerDeps {
|
||||
registry: {
|
||||
getByKey(pluginKey: string): Promise<RegistryPluginRow | null>;
|
||||
update(
|
||||
id: string,
|
||||
data: { version?: string; manifest?: PaperclipPluginManifestV1 },
|
||||
): Promise<unknown>;
|
||||
};
|
||||
loader: {
|
||||
installPlugin(options: { localPath: string }): Promise<{
|
||||
manifest: { id: string } | null;
|
||||
}>;
|
||||
loadManifest(packagePath: string): Promise<PaperclipPluginManifestV1 | null>;
|
||||
};
|
||||
lifecycle: {
|
||||
load(pluginId: string): Promise<unknown>;
|
||||
|
|
@ -216,6 +224,49 @@ function defaultBundleManifestExists(localPath: string): boolean {
|
|||
return fs.existsSync(path.join(localPath, "dist", "manifest.js"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a present bundled plugin's persisted manifest with the shipped
|
||||
* bundle. The bundle is part of the release image, so its manifest is the
|
||||
* source of truth. When the bundle declares a version that differs from the
|
||||
* persisted version, update the stored manifest and version. This propagates
|
||||
* a manifest change (for example a new driver capability) to an existing
|
||||
* install that the auto-install path skips.
|
||||
*
|
||||
* The step is fail-safe. A missing bundle, a manifest read error, or a
|
||||
* database error is caught, logged, and swallowed, so boot always completes.
|
||||
* The step updates only the stored manifest row; it never restarts the worker.
|
||||
*/
|
||||
async function reconcileBundledPluginManifest(
|
||||
existing: RegistryPluginRow,
|
||||
install: ResolvedBundledPlugin,
|
||||
deps: BundledPluginProvisionerDeps,
|
||||
bundleManifestExists: (localPath: string) => boolean,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!bundleManifestExists(install.localPath)) return;
|
||||
const bundleManifest = await deps.loader.loadManifest(install.localPath);
|
||||
if (!bundleManifest) return;
|
||||
if (bundleManifest.version === existing.version) return;
|
||||
await deps.registry.update(existing.id, {
|
||||
version: bundleManifest.version,
|
||||
manifest: bundleManifest,
|
||||
});
|
||||
deps.logger.info(
|
||||
{
|
||||
pluginKey: install.pluginKey,
|
||||
fromVersion: existing.version,
|
||||
toVersion: bundleManifest.version,
|
||||
},
|
||||
"reconciled bundled plugin manifest to the shipped bundle version",
|
||||
);
|
||||
} catch (err) {
|
||||
deps.logger.error(
|
||||
{ err, pluginKey: install.pluginKey },
|
||||
"Failed to reconcile bundled plugin manifest; continuing boot with the stored manifest",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure each resolved bundled plugin is installed and loaded.
|
||||
*
|
||||
|
|
@ -226,7 +277,9 @@ function defaultBundleManifestExists(localPath: string): boolean {
|
|||
*
|
||||
* Skip semantics:
|
||||
* - A plugin present in any non-uninstalled state is skipped, so an
|
||||
* operator-disabled plugin is not silently re-enabled on reboot.
|
||||
* operator-disabled plugin is not silently re-enabled on reboot. Before the
|
||||
* skip, the persisted manifest is reconciled to the shipped bundle version
|
||||
* (see `reconcileBundledPluginManifest`).
|
||||
* - A soft-uninstalled plugin is reinstalled only when
|
||||
* `reinstallUninstalled` is set (managed mode, where the control plane
|
||||
* owns provisioning). Self-hosted keeps the pre-refactor behavior of
|
||||
|
|
@ -242,6 +295,14 @@ export async function ensureBundledPlugins(
|
|||
try {
|
||||
const existing = await deps.registry.getByKey(install.pluginKey);
|
||||
if (existing && (existing.status !== "uninstalled" || !opts.reinstallUninstalled)) {
|
||||
// The bundle ships with the release image, so its manifest is the
|
||||
// source of truth for a present plugin. Reconcile the persisted
|
||||
// manifest when the shipped bundle declares a newer version. Without
|
||||
// this step a manifest capability added to a bundle never reaches an
|
||||
// existing install, because the auto-install below skips a present
|
||||
// plugin. The reconcile updates only the stored manifest row; the
|
||||
// running worker already runs the shipped code.
|
||||
await reconcileBundledPluginManifest(existing, install, deps, bundleManifestExists);
|
||||
deps.logger.info(
|
||||
{ pluginKey: install.pluginKey, status: existing.status },
|
||||
"bundled plugin already present; skipping auto-install",
|
||||
|
|
|
|||
|
|
@ -714,6 +714,45 @@ export async function resolveEnvironmentDriverConfigForRuntime(
|
|||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the connection secrets of a recorded sandbox config for a durable
|
||||
* orphan-sandbox teardown. The retry reads the recorded config from the durable
|
||||
* `pending_cleanup` lease row, not from the current environment. So this
|
||||
* resolver must not require the environment binding: a delete removed the
|
||||
* environment, or a provider change replaced the binding. It resolves each
|
||||
* schema-declared secret ref by id at the latest version through
|
||||
* `resolveSecretValueForSandboxCleanup`, which authorizes the read from the
|
||||
* durable orphan record and skips the binding check. The resolved values are
|
||||
* used once for the teardown RPC and never persisted.
|
||||
*/
|
||||
export async function resolveSandboxCleanupConfigSecrets(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
config: SandboxEnvironmentConfig,
|
||||
context?: { issueId?: string | null; heartbeatRunId?: string | null },
|
||||
): Promise<SandboxEnvironmentConfig> {
|
||||
if (config.provider === "fake") return config;
|
||||
const schema = await getSandboxProviderConfigSchema(db, config.provider);
|
||||
const secrets = secretService(db);
|
||||
let nextConfig = { ...config } as Record<string, unknown>;
|
||||
for (const path of collectSecretRefPaths(schema)) {
|
||||
const current = canonicalizeSecretRefValue(readConfigValueAtPath(nextConfig, path), path);
|
||||
if (typeof current !== "string") continue;
|
||||
const trimmed = current.trim();
|
||||
if (!isUuidSecretRef(trimmed)) continue;
|
||||
nextConfig = writeConfigValueAtPath(
|
||||
nextConfig,
|
||||
path,
|
||||
await secrets.resolveSecretValueForSandboxCleanup(companyId, trimmed, "latest", {
|
||||
configPath: path,
|
||||
issueId: context?.issueId ?? null,
|
||||
heartbeatRunId: context?.heartbeatRunId ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return nextConfig as SandboxEnvironmentConfig;
|
||||
}
|
||||
|
||||
export function readSshEnvironmentPrivateKeySecretId(
|
||||
environment: Pick<Environment, "driver" | "config">,
|
||||
): string | null {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -30,7 +30,7 @@ import {
|
|||
type EnvironmentLeaseStatus,
|
||||
type UpdateEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict } from "../errors.js";
|
||||
import { conflict, forbidden } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { isCloudManagedInstance } from "./cloud-instance.js";
|
||||
import {
|
||||
|
|
@ -822,6 +822,26 @@ export function environmentService(db: Db) {
|
|||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* List the companies that own an environment through a built-in managed
|
||||
* resource binding. A managed sandbox row binds to each company that the
|
||||
* instance provisions it for. An operator-created environment has no
|
||||
* binding, so this returns an empty list. The caller treats an empty list
|
||||
* as an instance-global environment with no company owner.
|
||||
*/
|
||||
listBoundCompanyIds: async (environmentId: string): Promise<string[]> => {
|
||||
const rows = await db
|
||||
.select({ companyId: builtInManagedResources.companyId })
|
||||
.from(builtInManagedResources)
|
||||
.where(
|
||||
and(
|
||||
eq(builtInManagedResources.resourceKind, MANAGED_ENVIRONMENT_RESOURCE_KIND),
|
||||
eq(builtInManagedResources.resourceId, environmentId),
|
||||
),
|
||||
);
|
||||
return Array.from(new Set(rows.map((row) => row.companyId)));
|
||||
},
|
||||
|
||||
getLeaseById: async (id: string): Promise<EnvironmentLease | null> => {
|
||||
const row = await db
|
||||
.select()
|
||||
|
|
@ -1089,6 +1109,29 @@ export function environmentService(db: Db) {
|
|||
select 1 from ${instanceSettings}
|
||||
where ${instanceSettings.defaultEnvironmentId} = ${environments.id}
|
||||
)`,
|
||||
// A `pending_cleanup` lease is the durable teardown reference for an
|
||||
// orphan sandbox. The environment foreign key uses
|
||||
// `on delete set null`, so a delete keeps the lease row but drops its
|
||||
// environment reference. This predicate refuses the delete while such
|
||||
// a lease exists, so the operator resolves the cleanup first and the
|
||||
// lease keeps its environment link. It runs in the same statement as
|
||||
// the delete, so it also closes the check-to-delete race.
|
||||
sql`not exists (
|
||||
select 1 from ${environmentLeases}
|
||||
where ${environmentLeases.environmentId} = ${environments.id}
|
||||
and ${environmentLeases.status} = 'pending_cleanup'
|
||||
)`,
|
||||
// A reusable lease keeps a live provider sandbox after a run
|
||||
// releases it. Deleting the environment would set its reference to
|
||||
// null, and both the normal release path and scoped reusable cleanup
|
||||
// require that environment context. Refuse the delete atomically
|
||||
// until the owning issue/workspace destroys the reusable sandbox.
|
||||
sql`not exists (
|
||||
select 1 from ${environmentLeases}
|
||||
where ${environmentLeases.environmentId} = ${environments.id}
|
||||
and ${environmentLeases.leasePolicy} = 'reuse_by_environment'
|
||||
and ${environmentLeases.status} in ('active', 'released', 'retained')
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
|
|
@ -1096,6 +1139,26 @@ export function environmentService(db: Db) {
|
|||
return row ? toEnvironment(row) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Return true when the environment has one or more leases in the terminal
|
||||
* `pending_cleanup` state. Each such lease is the only durable provider
|
||||
* reference for an orphan sandbox that a teardown retry must destroy. The
|
||||
* delete guard and the provider-change guard call this to protect that
|
||||
* reference.
|
||||
*/
|
||||
hasUnresolvedPendingCleanupLeases: async (environmentId: string): Promise<boolean> => {
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(environmentLeases)
|
||||
.where(
|
||||
and(
|
||||
eq(environmentLeases.environmentId, environmentId),
|
||||
eq(environmentLeases.status, "pending_cleanup"),
|
||||
),
|
||||
);
|
||||
return countFromRows(rows) > 0;
|
||||
},
|
||||
|
||||
getDeleteBlastRadius: async (id: string): Promise<EnvironmentDeleteBlastRadius | null> => {
|
||||
const environment = await db
|
||||
.select({
|
||||
|
|
@ -1115,6 +1178,8 @@ export function environmentService(db: Db) {
|
|||
projectRows,
|
||||
secretBindingRows,
|
||||
activeLeaseRows,
|
||||
pendingCleanupLeaseRows,
|
||||
reusableSandboxLeaseRows,
|
||||
activeSetupRows,
|
||||
] = await Promise.all([
|
||||
db
|
||||
|
|
@ -1155,6 +1220,25 @@ export function environmentService(db: Db) {
|
|||
eq(environmentLeases.status, "active"),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(environmentLeases)
|
||||
.where(
|
||||
and(
|
||||
eq(environmentLeases.environmentId, id),
|
||||
eq(environmentLeases.status, "pending_cleanup"),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(environmentLeases)
|
||||
.where(
|
||||
and(
|
||||
eq(environmentLeases.environmentId, id),
|
||||
eq(environmentLeases.leasePolicy, "reuse_by_environment"),
|
||||
inArray(environmentLeases.status, ["active", "released", "retained"]),
|
||||
),
|
||||
),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(environmentCustomImageSetupSessions)
|
||||
|
|
@ -1168,9 +1252,18 @@ export function environmentService(db: Db) {
|
|||
|
||||
const isManagedLocal = environment.driver === "local";
|
||||
const isInstanceDefault = countFromRows(instanceDefaultRows) > 0;
|
||||
const pendingCleanupLeaseCount = countFromRows(pendingCleanupLeaseRows);
|
||||
const reusableSandboxLeaseCount = countFromRows(reusableSandboxLeaseRows);
|
||||
const deleteBlockedReasons: EnvironmentDeleteBlockedReason[] = [];
|
||||
if (isManagedLocal) deleteBlockedReasons.push("managed_local");
|
||||
if (isInstanceDefault) deleteBlockedReasons.push("instance_default");
|
||||
// A `pending_cleanup` lease is the durable teardown reference for an orphan
|
||||
// sandbox. The environment foreign key uses `on delete set null`, so a
|
||||
// delete keeps the lease but drops its environment reference. Block the
|
||||
// delete until the sweep resolves the lease, so the operator resolves the
|
||||
// cleanup first and the lease keeps its environment link.
|
||||
if (pendingCleanupLeaseCount > 0) deleteBlockedReasons.push("pending_sandbox_cleanup");
|
||||
if (reusableSandboxLeaseCount > 0) deleteBlockedReasons.push("reusable_sandbox_lease");
|
||||
const activeLeaseCount = countFromRows(activeLeaseRows);
|
||||
const activeCustomImageSetupSessionCount = countFromRows(activeSetupRows);
|
||||
|
||||
|
|
@ -1178,6 +1271,8 @@ export function environmentService(db: Db) {
|
|||
environmentId: id,
|
||||
canDelete: deleteBlockedReasons.length === 0,
|
||||
deleteBlockedReasons,
|
||||
pendingCleanupLeaseCount,
|
||||
reusableSandboxLeaseCount,
|
||||
staticReferences: {
|
||||
isManagedLocal,
|
||||
isInstanceDefault,
|
||||
|
|
@ -1222,32 +1317,80 @@ export function environmentService(db: Db) {
|
|||
providerLeaseId?: string | null;
|
||||
expiresAt?: Date | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
/**
|
||||
* Re-check the environment company binding inside the lease insert
|
||||
* transaction. The login routes set this to close the check-to-lease
|
||||
* race: managed reconciliation can bind a sandbox to another company
|
||||
* between the route guard and this acquire. When the environment is
|
||||
* bound to a company other than `companyId`, the insert throws the 403
|
||||
* `environment_company_mismatch` and no lease row is created (fail
|
||||
* closed). An unbound (instance-global) environment stays open to every
|
||||
* member. All other callers keep the plain, non-transactional insert.
|
||||
*/
|
||||
assertCompanyBinding?: boolean;
|
||||
}): Promise<EnvironmentLease> => {
|
||||
const now = new Date();
|
||||
const row = await db
|
||||
.insert(environmentLeases)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
status: "active",
|
||||
leasePolicy: input.leasePolicy ?? "ephemeral",
|
||||
provider: input.provider ?? null,
|
||||
providerLeaseId: input.providerLeaseId ?? null,
|
||||
acquiredAt: now,
|
||||
lastUsedAt: now,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
releasedAt: null,
|
||||
failureReason: null,
|
||||
cleanupStatus: null,
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const values = {
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
status: "active" as const,
|
||||
leasePolicy: input.leasePolicy ?? "ephemeral",
|
||||
provider: input.provider ?? null,
|
||||
providerLeaseId: input.providerLeaseId ?? null,
|
||||
acquiredAt: now,
|
||||
lastUsedAt: now,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
releasedAt: null,
|
||||
failureReason: null,
|
||||
cleanupStatus: null,
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const row = input.assertCompanyBinding
|
||||
? await db.transaction(async (tx) => {
|
||||
// Lock the environment row first. Managed reconciliation locks the
|
||||
// same sandbox environment rows with `for update` before it writes a
|
||||
// company binding, so this lock serializes the two transactions on
|
||||
// this row and closes the time-of-check to time-of-use window.
|
||||
await tx
|
||||
.select({ id: environments.id })
|
||||
.from(environments)
|
||||
.where(eq(environments.id, input.environmentId))
|
||||
.for("update");
|
||||
// Re-read the company binding inside the locked transaction. A
|
||||
// binding a reconciliation committed after the route guard now
|
||||
// appears here. Reject a foreign-company environment before the
|
||||
// insert, so the login holds no lease.
|
||||
const boundRows = await tx
|
||||
.select({ companyId: builtInManagedResources.companyId })
|
||||
.from(builtInManagedResources)
|
||||
.where(
|
||||
and(
|
||||
eq(builtInManagedResources.resourceKind, MANAGED_ENVIRONMENT_RESOURCE_KIND),
|
||||
eq(builtInManagedResources.resourceId, input.environmentId),
|
||||
),
|
||||
);
|
||||
const boundCompanyIds = Array.from(new Set(boundRows.map((boundRow) => boundRow.companyId)));
|
||||
if (boundCompanyIds.length > 0 && !boundCompanyIds.includes(input.companyId)) {
|
||||
throw forbidden("The selected environment belongs to another company.", {
|
||||
code: "environment_company_mismatch",
|
||||
});
|
||||
}
|
||||
return tx
|
||||
.insert(environmentLeases)
|
||||
.values(values)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
})
|
||||
: await db
|
||||
.insert(environmentLeases)
|
||||
.values(values)
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) {
|
||||
throw new Error("Failed to acquire environment lease");
|
||||
}
|
||||
|
|
@ -1279,6 +1422,82 @@ export function environmentService(db: Db) {
|
|||
return row ? toEnvironmentLease(row) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Record a lease-less orphan sandbox directly in the terminal
|
||||
* `pending_cleanup` state with one atomic insert. The sweep reads a row with
|
||||
* status `pending_cleanup` and cleanup status `failed`, so this insert makes
|
||||
* the orphan visible to recovery immediately. It never passes through the
|
||||
* `active` state, so a process or database crash cannot strand the row in an
|
||||
* intermediate state that no sweep finds. The insert skips the company
|
||||
* binding assertion, so it records the orphan even for a foreign-bound
|
||||
* environment.
|
||||
*
|
||||
* The insert runs in a transaction that first locks the environment row with
|
||||
* `for update`. The delete path (`removeIfDeletable`) refuses a delete while a
|
||||
* `pending_cleanup` lease exists, so this lock serializes the two writes:
|
||||
*
|
||||
* - The environment still exists: the insert records the orphan with the
|
||||
* environment reference. A concurrent delete blocks on the lock, then its
|
||||
* `not exists (pending_cleanup)` predicate fails, so the delete refuses.
|
||||
* - The environment is already gone (a delete won the race before this
|
||||
* insert): the insert records the orphan with a null environment
|
||||
* reference. The row carries the immutable provider metadata the sweep
|
||||
* needs, so the teardown still runs. This closes the window the finding
|
||||
* describes, where the foreign-key insert fails after a delete.
|
||||
*/
|
||||
insertPendingCleanupLease: async (input: {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
executionWorkspaceId?: string | null;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
provider?: string | null;
|
||||
providerLeaseId?: string | null;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
failureReason: string;
|
||||
}): Promise<EnvironmentLease> => {
|
||||
const now = new Date();
|
||||
const row = await db.transaction(async (tx) => {
|
||||
// Lock the environment row so a concurrent delete cannot commit between
|
||||
// this read and the insert. An absent row means a delete already removed
|
||||
// the environment, so record the orphan with a null reference.
|
||||
const environmentRows = await tx
|
||||
.select({ id: environments.id })
|
||||
.from(environments)
|
||||
.where(eq(environments.id, input.environmentId))
|
||||
.for("update");
|
||||
const environmentIdForRow = environmentRows[0]?.id ?? null;
|
||||
return tx
|
||||
.insert(environmentLeases)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
environmentId: environmentIdForRow,
|
||||
executionWorkspaceId: input.executionWorkspaceId ?? null,
|
||||
issueId: input.issueId ?? null,
|
||||
heartbeatRunId: input.heartbeatRunId ?? null,
|
||||
status: "pending_cleanup" as const,
|
||||
leasePolicy: "ephemeral",
|
||||
provider: input.provider ?? null,
|
||||
providerLeaseId: input.providerLeaseId ?? null,
|
||||
acquiredAt: now,
|
||||
lastUsedAt: now,
|
||||
expiresAt: null,
|
||||
releasedAt: now,
|
||||
failureReason: input.failureReason,
|
||||
cleanupStatus: "failed",
|
||||
metadata: input.metadata ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
if (!row) {
|
||||
throw new Error("Failed to record pending sandbox cleanup lease");
|
||||
}
|
||||
return toEnvironmentLease(row);
|
||||
},
|
||||
|
||||
updateLeaseMetadata: async (
|
||||
id: string,
|
||||
metadata: Record<string, unknown> | null,
|
||||
|
|
|
|||
|
|
@ -13261,6 +13261,28 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
return claimed.length > 0;
|
||||
}
|
||||
|
||||
// Defer a pending_cleanup lease whose provider plugin is not ready this tick.
|
||||
// The sweep reads one page of the oldest rows, ordered by `updatedAt`. A lease
|
||||
// that the sweep only skips keeps its old `updatedAt`, so it stays the oldest
|
||||
// and refills the page on every tick. That starves a newer lease whose
|
||||
// provider is ready. The defer bumps `updatedAt` to now, so the unavailable
|
||||
// lease moves to the back of the queue and a ready lease takes its page slot.
|
||||
// The defer never writes the attempt count, so a long provider outage never
|
||||
// consumes a finite retry. The status guard keeps the write on a lease that is
|
||||
// still pending_cleanup.
|
||||
async function deferPendingCleanupLease(leaseId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
await db
|
||||
.update(environmentLeases)
|
||||
.set({ updatedAt: now })
|
||||
.where(
|
||||
and(
|
||||
eq(environmentLeases.id, leaseId),
|
||||
eq(environmentLeases.status, "pending_cleanup"),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Retry the leases stranded in "pending_cleanup". A failed destroy leaves a
|
||||
// lease in that state forever without this sweep. The reaper tick runs the
|
||||
// sweep. The backoff equals the reaper staleness threshold, so a lease waits
|
||||
|
|
@ -13276,6 +13298,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - backoffMs);
|
||||
|
||||
// Flush the in-process orphan-cleanup buffer first. A failed acquire buffers
|
||||
// an orphan there when every synchronous pending-cleanup write failed after a
|
||||
// failed teardown. The flush re-inserts each buffered record, so a durable
|
||||
// `pending_cleanup` row lands once the database recovers. The flush runs
|
||||
// before the read below, so this same tick tears down a freshly-landed row.
|
||||
try {
|
||||
const flushed = await environmentRuntime.flushDeferredOrphanCleanups?.();
|
||||
if (flushed && (flushed.recovered > 0 || flushed.pending > 0)) {
|
||||
logger.info(
|
||||
{ recovered: flushed.recovered, pending: flushed.pending },
|
||||
"flushed the in-process orphan sandbox cleanup buffer to the database",
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// A flush failure never stops the sweep. The buffer keeps the orphan for a
|
||||
// later tick, and the database rows below still need this sweep. The caught
|
||||
// exception never enters the log, because a write error can carry a
|
||||
// credential in its message, code, cause, or stack.
|
||||
logger.warn("orphan sandbox cleanup buffer flush failed; the sweep continues");
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(environmentLeases)
|
||||
|
|
@ -13310,28 +13353,94 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {})
|
|||
continue;
|
||||
}
|
||||
|
||||
const environment = await environmentsSvc.getById(row.environmentId);
|
||||
const environment = row.environmentId
|
||||
? await environmentsSvc.getById(row.environmentId)
|
||||
: null;
|
||||
const lease = await environmentsSvc.getLeaseById(row.id);
|
||||
if (!environment || !lease) continue;
|
||||
if (!lease) continue;
|
||||
|
||||
// An orphan ephemeral lease keeps its provider, its provider lease id, and
|
||||
// its sandbox config in the lease row. A failed acquire records it, and its
|
||||
// environment row may be gone or foreign-bound. A reuse_by_environment lease
|
||||
// whose environment a delete removed keeps the same recorded data, because
|
||||
// the schema sets the environment reference to null on delete and preserves
|
||||
// the row. Both leases tear down from the recorded lease data through
|
||||
// `retryPendingSandboxTeardown`, which never reads the environment row. So
|
||||
// the sweep uses that path whenever the lease is an orphan ephemeral lease
|
||||
// or its environment row is gone. A reuse_by_environment lease whose
|
||||
// environment still exists tears down through `destroyRunLease`. That
|
||||
// path uses the provider and configuration recorded on the lease first;
|
||||
// the environment is lifecycle context and only a legacy fallback.
|
||||
const isOrphanEphemeralLease = lease.leasePolicy === "ephemeral";
|
||||
const useRecordedTeardown = isOrphanEphemeralLease || !environment;
|
||||
|
||||
// Do not consume a finite cleanup attempt while the provider plugin is
|
||||
// briefly unavailable. A plugin worker restart, a plugin reload, or a
|
||||
// plugin reinstall makes the provider unavailable for a short window. The
|
||||
// plugin can be missing or not ready in that window. A teardown then throws,
|
||||
// and the atomic claim below would count that throw against the cap, so a
|
||||
// long restart or reload could exhaust the retries and strand a live
|
||||
// sandbox. So probe the provider first, and defer the lease this tick when
|
||||
// the provider is not ready. The sweep preserves the pending_cleanup row,
|
||||
// and a later sweep retries after the provider recovers. The probe reports
|
||||
// ready only for a permanent condition (a missing provider string, a
|
||||
// built-in provider, or no worker manager), so a genuine teardown failure
|
||||
// still runs, throws, and counts toward the cap. A runtime with no probe
|
||||
// method treats the lease as ready, so the sweep keeps its earlier
|
||||
// behavior.
|
||||
const workerReady = environmentRuntime.isPendingCleanupWorkerReady
|
||||
? await environmentRuntime.isPendingCleanupWorkerReady({ environment, lease })
|
||||
: true;
|
||||
if (!workerReady) {
|
||||
// Move the unavailable lease to the back of the sweep queue. Otherwise
|
||||
// the oldest unavailable rows refill the page on every tick and starve a
|
||||
// newer lease that has a ready provider. The defer bumps `updatedAt`
|
||||
// only, so it consumes no finite retry attempt.
|
||||
await deferPendingCleanupLease(row.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Atomically claim the attempt before the retry. Only the winning sweep
|
||||
// increments the count and destroys the lease, so an overlapping sweep
|
||||
// never destroys the same lease twice or exceeds the attempt cap. The
|
||||
// increments the count and tears the sandbox down, so an overlapping sweep
|
||||
// never tears the same sandbox down twice or exceeds the attempt cap. The
|
||||
// claim records the attempt before the retry, so a thrown driver error
|
||||
// still counts against the cap.
|
||||
const claimed = await claimPendingCleanupRetryAttempt(row.id, attempts);
|
||||
if (!claimed) continue;
|
||||
|
||||
try {
|
||||
const result = await environmentRuntime.destroyRunLease({
|
||||
environment,
|
||||
lease,
|
||||
failureReason: "pending_cleanup_retry",
|
||||
});
|
||||
if (result && result.status !== "pending_cleanup") {
|
||||
if (useRecordedTeardown) {
|
||||
// Tear the sandbox down from the recorded provider config and the
|
||||
// cleanup-authorized secret versions. The teardown returns no value
|
||||
// and throws on failure, so the sweep releases the lease itself.
|
||||
await environmentRuntime.retryPendingSandboxTeardown({ environment, lease });
|
||||
await environmentsSvc.releaseLease(lease.id, "expired", {
|
||||
cleanupStatus: "success",
|
||||
failureReason: "pending_cleanup_retry",
|
||||
});
|
||||
destroyed += 1;
|
||||
} else if (environment) {
|
||||
const result = await environmentRuntime.destroyRunLease({
|
||||
environment,
|
||||
lease,
|
||||
failureReason: "pending_cleanup_retry",
|
||||
});
|
||||
if (result && result.status !== "pending_cleanup") {
|
||||
destroyed += 1;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// The recorded-data teardown throws on failure, so revert the lease to
|
||||
// pending_cleanup for a later sweep. The claimed attempt still counts
|
||||
// against the cap, so the retries stay bounded. The `destroyRunLease`
|
||||
// path reverts the lease itself, so this revert only runs for the
|
||||
// recorded-data teardown path.
|
||||
if (useRecordedTeardown) {
|
||||
await environmentsSvc.releaseLease(lease.id, "pending_cleanup", {
|
||||
cleanupStatus: "failed",
|
||||
failureReason: "pending_cleanup_retry",
|
||||
});
|
||||
}
|
||||
// Log a constant errorKind only. The exception can carry a credential in
|
||||
// its name, code, message, cause, or stack, so the sweep never reads it.
|
||||
logger.warn(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ export interface ReadyPluginEnvironmentDriver {
|
|||
templateRefKind?: PluginEnvironmentDriverDeclaration["templateRefKind"];
|
||||
templateConfigBinding?: PluginEnvironmentDriverDeclaration["templateConfigBinding"];
|
||||
supportsTemplateDelete?: PluginEnvironmentDriverDeclaration["supportsTemplateDelete"];
|
||||
supportsSetupTokenLogin?: PluginEnvironmentDriverDeclaration["supportsSetupTokenLogin"];
|
||||
}
|
||||
|
||||
export function pluginDriverProviderKey(config: Pick<PluginEnvironmentConfig, "pluginKey" | "driverKey">): string {
|
||||
|
|
@ -256,6 +257,7 @@ export async function listReadyPluginEnvironmentDrivers(input: {
|
|||
templateRefKind: driver.templateRefKind,
|
||||
templateConfigBinding: driver.templateConfigBinding,
|
||||
supportsTemplateDelete: driver.supportsTemplateDelete,
|
||||
supportsSetupTokenLogin: driver.supportsSetupTokenLogin,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ import {
|
|||
isJsonRpcSuccessResponse,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION,
|
||||
SETUP_TOKEN_PTY_EXIT_NOTIFICATION,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import type {
|
||||
JsonRpcId,
|
||||
|
|
@ -53,6 +55,7 @@ import type {
|
|||
InitializeParams,
|
||||
} from "@paperclipai/plugin-sdk";
|
||||
import { getActiveStepContext } from "@paperclipai/adapter-utils/acpx-engine/startup-timing";
|
||||
import { CLAUDE_SETUP_TOKEN_COMMAND } from "@paperclipai/adapter-claude-local/server";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { traceparentFromContextToken } from "../instrumentation.js";
|
||||
|
||||
|
|
@ -133,6 +136,26 @@ const MAX_WORKER_MESSAGE_CHARS = 128 * 1024 * 1024;
|
|||
*/
|
||||
const MAX_EXECUTE_LOG_TOTAL_CHARS = 128 * 1024 * 1024;
|
||||
|
||||
/** Maximum characters for one live login pseudo-terminal output notification. */
|
||||
const MAX_SETUP_TOKEN_PTY_CHUNK_CHARS = 1_000_000;
|
||||
/** Maximum cumulative output characters for one login pseudo-terminal route. */
|
||||
const MAX_SETUP_TOKEN_PTY_TOTAL_CHARS = 8 * 1024 * 1024;
|
||||
/** The default open timeout for one login pseudo-terminal route, in milliseconds. */
|
||||
const SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS = 30_000;
|
||||
/** The default close timeout for one login pseudo-terminal route, in milliseconds. */
|
||||
const SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS = 10_000;
|
||||
/**
|
||||
* The fixed non-secret error a disallowed login command returns. The manager
|
||||
* forwards only the compile-time `CLAUDE_SETUP_TOKEN_COMMAND` to the worker
|
||||
* pseudo-terminal. It rejects any other command before the worker call, so a
|
||||
* future caller cannot spawn an arbitrary process in the sandbox.
|
||||
*/
|
||||
const SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED = "SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED";
|
||||
/** The fixed non-secret error a rejected second credential open returns. */
|
||||
const SETUP_TOKEN_PTY_ROUTE_BUSY = "SETUP_TOKEN_PTY_ROUTE_BUSY";
|
||||
/** The fixed non-secret error a failed open returns. */
|
||||
const SETUP_TOKEN_PTY_OPEN_FAILED = "SETUP_TOKEN_PTY_OPEN_FAILED";
|
||||
|
||||
/** Minimum time between two dropped-`execute.log` debug records. The router
|
||||
* rate-limits the record so a flood of dropped chunks writes at most one line
|
||||
* per window with a running count. */
|
||||
|
|
@ -282,6 +305,23 @@ export interface WorkerStartOptions {
|
|||
/** Max total characters one execute call may stream through `execute.log`. */
|
||||
maxTotalCharsPerExecute?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bounds and timeouts for the login pseudo-terminal route. The
|
||||
* defaults bound one output notification, the cumulative output per route, and
|
||||
* the open and the close timeouts. A test overrides them to exercise the
|
||||
* terminalize paths without huge inputs or long waits.
|
||||
*/
|
||||
setupTokenPtyLimits?: {
|
||||
/** Max characters for one login pseudo-terminal output notification. */
|
||||
maxChunkChars?: number;
|
||||
/** Max cumulative output characters for one login pseudo-terminal route. */
|
||||
maxTotalChars?: number;
|
||||
/** The open timeout for one login pseudo-terminal route, in milliseconds. */
|
||||
openTimeoutMs?: number;
|
||||
/** The close timeout for one login pseudo-terminal route, in milliseconds. */
|
||||
closeTimeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -322,6 +362,37 @@ export type ExecuteLogSink = (
|
|||
chunk: string,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* The input the manager needs to open one live login pseudo-terminal route
|
||||
* The manager mints the host route identifier; the caller supplies
|
||||
* only the sandbox scope, the provider lease id, and the fixed command.
|
||||
*/
|
||||
export interface SetupTokenPtyOpenInput {
|
||||
driverKey: string;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
providerLeaseId: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One live login pseudo-terminal session the manager hands to the login
|
||||
* transport. The shape matches the sandbox provider setup-token
|
||||
* pseudo-terminal session, so the transport consumes it with no adapter.
|
||||
*/
|
||||
export interface SetupTokenPtyHostSession {
|
||||
/** 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 or the route terminalizes. */
|
||||
wait(): Promise<{ exitCode: number | null }>;
|
||||
/** Stops the child process. Safe to call more than one time. */
|
||||
kill(): void;
|
||||
/** Closes the route and releases the terminal. Safe to call more than one time. */
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-owned route for one active execute call. The host mints the invocation
|
||||
* id and stores the exact company id and log sink here. A worker never selects
|
||||
|
|
@ -403,6 +474,16 @@ export interface PluginWorkerHandle {
|
|||
*/
|
||||
notify(method: string, params: unknown): void;
|
||||
|
||||
/**
|
||||
* Open one live login pseudo-terminal route on this worker. The
|
||||
* manager mints the host route identifier, reserves the route, drives the open,
|
||||
* binds the worker session identifier one time, and returns a session the login
|
||||
* transport drives. It permits one active credential pseudo-terminal per worker.
|
||||
*/
|
||||
openSetupTokenPtySession(
|
||||
input: SetupTokenPtyOpenInput,
|
||||
): Promise<SetupTokenPtyHostSession>;
|
||||
|
||||
/**
|
||||
* Authorize the set of companies this worker may act on from proactive
|
||||
* (non-invocation) context. Replaces any previously-authorized set. See the
|
||||
|
|
@ -506,6 +587,17 @@ export interface PluginWorkerManager {
|
|||
timeoutMs?: number,
|
||||
executeLogSink?: ExecuteLogSink,
|
||||
): Promise<HostToWorkerMethods[M][1]>;
|
||||
|
||||
/**
|
||||
* Open one live login pseudo-terminal route on a specific plugin worker
|
||||
* See {@link PluginWorkerHandle.openSetupTokenPtySession}.
|
||||
*
|
||||
* @throws if the worker is not registered.
|
||||
*/
|
||||
openSetupTokenPtySession(
|
||||
pluginId: string,
|
||||
input: SetupTokenPtyOpenInput,
|
||||
): Promise<SetupTokenPtyHostSession>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -564,6 +656,17 @@ export function createPluginWorkerHandle(
|
|||
const maxExecuteLogTotalChars =
|
||||
options.executeLogLimits?.maxTotalCharsPerExecute ?? MAX_EXECUTE_LOG_TOTAL_CHARS;
|
||||
|
||||
// Bounds and timeouts for the login pseudo-terminal route. A caller
|
||||
// (a test) can lower them to exercise the terminalize paths.
|
||||
const maxSetupTokenPtyChunkChars =
|
||||
options.setupTokenPtyLimits?.maxChunkChars ?? MAX_SETUP_TOKEN_PTY_CHUNK_CHARS;
|
||||
const maxSetupTokenPtyTotalChars =
|
||||
options.setupTokenPtyLimits?.maxTotalChars ?? MAX_SETUP_TOKEN_PTY_TOTAL_CHARS;
|
||||
const setupTokenPtyOpenTimeoutMs =
|
||||
options.setupTokenPtyLimits?.openTimeoutMs ?? SETUP_TOKEN_PTY_OPEN_TIMEOUT_MS;
|
||||
const setupTokenPtyCloseTimeoutMs =
|
||||
options.setupTokenPtyLimits?.closeTimeoutMs ?? SETUP_TOKEN_PTY_CLOSE_TIMEOUT_MS;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Proactive company scopes (LOOA-629)
|
||||
// ------------------------------------------------------------------
|
||||
|
|
@ -920,6 +1023,251 @@ export function createPluginWorkerHandle(
|
|||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Host-owned setup-token login pseudo-terminal route gate
|
||||
// -----------------------------------------------------------------------
|
||||
// The manager owns one live login pseudo-terminal route per worker. It mints a
|
||||
// host-owned opaque route identifier, carries it in the open call, and keys the
|
||||
// close on it, so it closes a worker-created terminal even when the open reply
|
||||
// was lost and no worker session identifier arrived. It binds the worker
|
||||
// session identifier one time while the route is `opening`, for output only. It
|
||||
// never trusts a worker-supplied identifier as proof of origin: it delivers
|
||||
// output only while the route is `open` and the notification carries the exact
|
||||
// bound identifier and valid bounded bytes, and it never logs the raw bytes. It
|
||||
// terminalizes the route exactly once on every open failure path, closes the
|
||||
// terminal by the host route identifier, and admits a new open only after it
|
||||
// verifies a close acknowledgement bound to that identifier; it retires the
|
||||
// worker on an unconfirmed close.
|
||||
|
||||
type SetupTokenPtyRouteState = "reserved" | "opening" | "open" | "closed";
|
||||
interface SetupTokenPtyRoute {
|
||||
hostRouteId: string;
|
||||
state: SetupTokenPtyRouteState;
|
||||
workerSessionId: string | null;
|
||||
listener: ((chunk: string) => void) | null;
|
||||
buffered: string[];
|
||||
deliveredChars: number;
|
||||
terminalized: boolean;
|
||||
settleWait: (value: { exitCode: number | null }) => void;
|
||||
}
|
||||
// At most one active credential pseudo-terminal per worker. A non-null route
|
||||
// blocks a second open until the manager confirms the first route's close.
|
||||
let setupTokenPtyRoute: SetupTokenPtyRoute | null = null;
|
||||
|
||||
function settleSetupTokenPtyWait(
|
||||
route: SetupTokenPtyRoute,
|
||||
value: { exitCode: number | null },
|
||||
): void {
|
||||
const settle = route.settleWait;
|
||||
route.settleWait = () => {};
|
||||
settle(value);
|
||||
}
|
||||
|
||||
// Close the worker terminal by the host route identifier and verify the bound
|
||||
// acknowledgement. Return true only when the worker returns an acknowledgement
|
||||
// that carries the exact host route identifier. An absent, malformed,
|
||||
// mismatched, or timed-out acknowledgement returns false, so the caller fails
|
||||
// closed.
|
||||
async function closeSetupTokenPtyTerminal(hostRouteId: string): Promise<boolean> {
|
||||
try {
|
||||
const ack = await callInternal(
|
||||
"setupTokenPtyClose",
|
||||
{ hostRouteId },
|
||||
setupTokenPtyCloseTimeoutMs,
|
||||
);
|
||||
return isRecord(ack) && readNonEmptyString(ack.hostRouteId) === hostRouteId;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Terminalize the route exactly once. Resolve the login wait, close the worker
|
||||
// terminal by the host route identifier, and free the per-worker slot only
|
||||
// after the close resolves. Retire the worker when the close is unconfirmed.
|
||||
async function terminalizeSetupTokenPtyRoute(route: SetupTokenPtyRoute): Promise<void> {
|
||||
if (route.terminalized) return;
|
||||
route.terminalized = true;
|
||||
route.state = "closed";
|
||||
route.listener = null;
|
||||
route.buffered = [];
|
||||
// A terminalized route reports a null exit code, which the runner treats as a
|
||||
// failure.
|
||||
settleSetupTokenPtyWait(route, { exitCode: null });
|
||||
const confirmed = await closeSetupTokenPtyTerminal(route.hostRouteId);
|
||||
if (setupTokenPtyRoute === route) setupTokenPtyRoute = null;
|
||||
if (!confirmed) {
|
||||
// The worker did not acknowledge the close, so the host cannot prove the
|
||||
// terminal is gone. Fail closed: retire the worker before any reuse.
|
||||
log.error(
|
||||
{ pluginId },
|
||||
"setup-token login pseudo-terminal close not acknowledged; retiring worker",
|
||||
);
|
||||
void killProcess();
|
||||
}
|
||||
}
|
||||
|
||||
// Route one login pseudo-terminal output notification to the per-session
|
||||
// listener. Deliver only while the route is `open` and the notification carries
|
||||
// the exact bound worker session identifier and valid bounded bytes. Drop an
|
||||
// unknown, late, malformed, or mismatched notification. Never log the raw bytes.
|
||||
function routeSetupTokenPtyOutput(notification: JsonRpcNotification): void {
|
||||
const route = setupTokenPtyRoute;
|
||||
if (!route || route.state !== "open") return;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
if (!workerSessionId || workerSessionId !== route.workerSessionId) return;
|
||||
const chunk = params.chunk;
|
||||
if (
|
||||
typeof chunk !== "string" ||
|
||||
chunk.length === 0 ||
|
||||
chunk.length > maxSetupTokenPtyChunkChars
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (route.deliveredChars + chunk.length > maxSetupTokenPtyTotalChars) {
|
||||
// The cumulative output passed the per-route bound. Terminalize the route.
|
||||
void terminalizeSetupTokenPtyRoute(route);
|
||||
return;
|
||||
}
|
||||
route.deliveredChars += chunk.length;
|
||||
if (route.listener) route.listener(chunk);
|
||||
else route.buffered.push(chunk);
|
||||
}
|
||||
|
||||
// Route one login pseudo-terminal exit notification to the login wait. Resolve
|
||||
// only while the route is `open` and the notification carries the exact bound
|
||||
// worker session identifier.
|
||||
function routeSetupTokenPtyExit(notification: JsonRpcNotification): void {
|
||||
const route = setupTokenPtyRoute;
|
||||
if (!route || route.state !== "open") return;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
if (!workerSessionId || workerSessionId !== route.workerSessionId) return;
|
||||
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
||||
settleSetupTokenPtyWait(route, { exitCode });
|
||||
}
|
||||
|
||||
// Close the one route on a worker exit. The worker is gone, so the manager
|
||||
// resolves the login wait with the fixed non-secret exit and clears the route
|
||||
// one time. The pending pseudo-terminal calls reject through `rejectAllPending`.
|
||||
function closeSetupTokenPtyRouteOnWorkerExit(): void {
|
||||
const route = setupTokenPtyRoute;
|
||||
if (!route) return;
|
||||
setupTokenPtyRoute = null;
|
||||
route.terminalized = true;
|
||||
route.state = "closed";
|
||||
route.listener = null;
|
||||
route.buffered = [];
|
||||
settleSetupTokenPtyWait(route, { exitCode: null });
|
||||
}
|
||||
|
||||
// Open one live login pseudo-terminal route. Reserve the route
|
||||
// before the open call, bind the worker session identifier one time on the
|
||||
// first successful open reply, and return a session the login transport drives.
|
||||
// Terminalize the route on every open failure path.
|
||||
async function openSetupTokenPtySession(
|
||||
input: SetupTokenPtyOpenInput,
|
||||
): Promise<SetupTokenPtyHostSession> {
|
||||
if (input.command !== CLAUDE_SETUP_TOKEN_COMMAND) {
|
||||
// Allowlist the login command. Only the fixed `CLAUDE_SETUP_TOKEN_COMMAND`
|
||||
// may run in the sandbox pseudo-terminal. Reject any other command with one
|
||||
// fixed non-secret error before the worker call, so a caller cannot spawn
|
||||
// an arbitrary process in the sandbox pseudo-terminal.
|
||||
throw new Error(SETUP_TOKEN_PTY_COMMAND_NOT_ALLOWED);
|
||||
}
|
||||
if (setupTokenPtyRoute) {
|
||||
// A route for this worker is not yet closed and confirmed. Reject the
|
||||
// second open with one fixed non-secret error before it reaches the worker.
|
||||
throw new Error(SETUP_TOKEN_PTY_ROUTE_BUSY);
|
||||
}
|
||||
const hostRouteId = randomUUID();
|
||||
let settleWait: (value: { exitCode: number | null }) => void = () => {};
|
||||
const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => {
|
||||
settleWait = resolve;
|
||||
});
|
||||
const route: SetupTokenPtyRoute = {
|
||||
hostRouteId,
|
||||
state: "reserved",
|
||||
workerSessionId: null,
|
||||
listener: null,
|
||||
buffered: [],
|
||||
deliveredChars: 0,
|
||||
terminalized: false,
|
||||
settleWait,
|
||||
};
|
||||
setupTokenPtyRoute = route;
|
||||
|
||||
route.state = "opening";
|
||||
let openResult: HostToWorkerMethods["setupTokenPtyOpen"][1];
|
||||
try {
|
||||
openResult = await callInternal(
|
||||
"setupTokenPtyOpen",
|
||||
{
|
||||
hostRouteId,
|
||||
driverKey: input.driverKey,
|
||||
companyId: input.companyId,
|
||||
environmentId: input.environmentId,
|
||||
providerLeaseId: input.providerLeaseId,
|
||||
command: input.command,
|
||||
},
|
||||
setupTokenPtyOpenTimeoutMs,
|
||||
);
|
||||
} catch (err) {
|
||||
// A send failure, an RPC rejection, or an open timeout. Terminalize the
|
||||
// route exactly once and fail closed.
|
||||
await terminalizeSetupTokenPtyRoute(route);
|
||||
throw err instanceof Error ? err : new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
|
||||
}
|
||||
|
||||
const workerSessionId = readNonEmptyString(
|
||||
isRecord(openResult) ? openResult.workerSessionId : null,
|
||||
);
|
||||
if (!workerSessionId || route.state !== "opening" || route.terminalized) {
|
||||
// A malformed reply, or a route that already left `opening`. A late or a
|
||||
// duplicate reply never binds, revives, or reopens a route.
|
||||
await terminalizeSetupTokenPtyRoute(route);
|
||||
throw new Error(SETUP_TOKEN_PTY_OPEN_FAILED);
|
||||
}
|
||||
// Bind the worker session identifier one time and move the route to `open`.
|
||||
route.workerSessionId = workerSessionId;
|
||||
route.state = "open";
|
||||
|
||||
return {
|
||||
onData(listener: (chunk: string) => void): void {
|
||||
route.listener = listener;
|
||||
if (route.buffered.length > 0) {
|
||||
const pending = route.buffered;
|
||||
route.buffered = [];
|
||||
for (const chunk of pending) listener(chunk);
|
||||
}
|
||||
},
|
||||
write(data: string): void {
|
||||
const sid = route.workerSessionId;
|
||||
if (route.state !== "open" || !sid) return;
|
||||
void callInternal(
|
||||
"setupTokenPtyInput",
|
||||
{ workerSessionId: sid, data },
|
||||
setupTokenPtyOpenTimeoutMs,
|
||||
).catch(() => {});
|
||||
},
|
||||
wait(): Promise<{ exitCode: number | null }> {
|
||||
return waitPromise;
|
||||
},
|
||||
kill(): void {
|
||||
const sid = route.workerSessionId;
|
||||
if (!sid) return;
|
||||
void callInternal(
|
||||
"setupTokenPtyStop",
|
||||
{ workerSessionId: sid },
|
||||
setupTokenPtyOpenTimeoutMs,
|
||||
).catch(() => {});
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await terminalizeSetupTokenPtyRoute(route);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the single company a worker→host call references, mirroring the SDK
|
||||
* governed-access gate's own derivation (host-client-factory.ts
|
||||
|
|
@ -1080,6 +1428,18 @@ export function createPluginWorkerHandle(
|
|||
return;
|
||||
}
|
||||
|
||||
// Setup-token login pseudo-terminal notifications: deliver output
|
||||
// and the exit to the one host-owned login route, bound by the worker session
|
||||
// identifier while the route is open.
|
||||
if (notification.method === SETUP_TOKEN_PTY_OUTPUT_NOTIFICATION) {
|
||||
routeSetupTokenPtyOutput(notification);
|
||||
return;
|
||||
}
|
||||
if (notification.method === SETUP_TOKEN_PTY_EXIT_NOTIFICATION) {
|
||||
routeSetupTokenPtyExit(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream notifications: forward to the stream bus via callback
|
||||
if (
|
||||
notification.method === "streams.open" ||
|
||||
|
|
@ -1225,6 +1585,11 @@ export function createPluginWorkerHandle(
|
|||
)),
|
||||
);
|
||||
|
||||
// Close the one login pseudo-terminal route with a fixed non-secret exit and
|
||||
// clear the route one time. The pending pseudo-terminal calls
|
||||
// already rejected through `rejectAllPending`.
|
||||
closeSetupTokenPtyRouteOnWorkerExit();
|
||||
|
||||
// Emit synthetic close for any orphaned stream channels so SSE clients
|
||||
// are notified instead of hanging indefinitely.
|
||||
if (openStreamChannels.size > 0 && options.onStreamNotification) {
|
||||
|
|
@ -1687,6 +2052,17 @@ export function createPluginWorkerHandle(
|
|||
return callInternal(method, params, timeoutMs, executeLogSink);
|
||||
},
|
||||
|
||||
openSetupTokenPtySession(input: SetupTokenPtyOpenInput) {
|
||||
if (status !== "running" && status !== "starting") {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`Cannot open a login pseudo-terminal — worker for "${pluginId}" is ${status}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
return openSetupTokenPtySession(input);
|
||||
},
|
||||
|
||||
notify(method: string, params: unknown) {
|
||||
if (status !== "running") return;
|
||||
const invocationScope = deriveInvocationScope(method, params);
|
||||
|
|
@ -1925,5 +2301,15 @@ export function createPluginWorkerManager(
|
|||
}
|
||||
return handle.call(method, params, timeoutMs, executeLogSink);
|
||||
},
|
||||
|
||||
openSetupTokenPtySession(pluginId: string, input: SetupTokenPtyOpenInput) {
|
||||
const handle = workers.get(pluginId);
|
||||
if (!handle) {
|
||||
return Promise.reject(
|
||||
new Error(`No worker registered for plugin "${pluginId}"`),
|
||||
);
|
||||
}
|
||||
return handle.openSetupTokenPtySession(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||
|
||||
/**
|
||||
* One orphan pending-cleanup record. The acquire builds it when the conditional
|
||||
* lease insert rejects a live sandbox. The record carries the same fields a
|
||||
* `pending_cleanup` lease row needs, so a later flush re-inserts it without
|
||||
* loss. The `metadata` field holds the sanitized lease metadata: it carries
|
||||
* secret references, not secret values, so the record is safe to persist next to
|
||||
* the other durable local state. A restart-recovered record still authorizes the
|
||||
* teardown, because it keeps the immutable provider, provider lease id, and
|
||||
* recorded config the teardown needs.
|
||||
*/
|
||||
export interface DeferredOrphanCleanupRecord {
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
executionWorkspaceId: string | null;
|
||||
issueId: string | null;
|
||||
heartbeatRunId: string | null;
|
||||
provider: string;
|
||||
providerLeaseId: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable, restart-safe store for orphan pending-cleanup records. The acquire
|
||||
* appends a record here when the conditional lease insert rejects a live
|
||||
* sandbox, the compensating teardown fails, and every synchronous
|
||||
* `pending_cleanup` database write also fails. The database is the only
|
||||
* automated way to find a leaked sandbox, but the database is down at that
|
||||
* instant, so the record has nowhere durable to land. The in-process buffer
|
||||
* alone loses the record on a restart. This spool keeps the record on the local
|
||||
* disk, next to the other durable server state, so a restart still finds it. A
|
||||
* later cleanup sweep loads the spool, re-inserts each record as a durable
|
||||
* `pending_cleanup` row, and removes the spooled copy.
|
||||
*/
|
||||
export interface SandboxOrphanCleanupSpool {
|
||||
/**
|
||||
* Persist one orphan record durably. Report `true` when the record reached the
|
||||
* disk, or `false` on any failure, so the caller keeps the in-process buffer
|
||||
* and the error log as the fallback handles. This method never throws.
|
||||
*/
|
||||
append(record: DeferredOrphanCleanupRecord): Promise<boolean>;
|
||||
/**
|
||||
* Load every persisted orphan record. The cleanup sweep calls this once after
|
||||
* a restart, so the records a previous process could not land re-enter the
|
||||
* flush path. This method never throws; it returns an empty list on any read
|
||||
* failure and skips a malformed file.
|
||||
*/
|
||||
load(): Promise<DeferredOrphanCleanupRecord[]>;
|
||||
/**
|
||||
* Remove one persisted orphan record after its durable `pending_cleanup` row
|
||||
* lands. The removal runs after the database insert succeeds, so a crash
|
||||
* between the insert and this removal leaves the spooled copy for a later
|
||||
* flush. That later flush re-inserts a duplicate row, which the idempotent
|
||||
* teardown handles. This order never loses the orphan. This method never
|
||||
* throws.
|
||||
*/
|
||||
remove(record: DeferredOrphanCleanupRecord): Promise<void>;
|
||||
}
|
||||
|
||||
// The spool writes one file per orphan record, so a removal never rewrites a
|
||||
// shared file and never races a concurrent append. The file name is a stable
|
||||
// hash of the record identity, so a repeated append for the same orphan
|
||||
// overwrites the same file and never persists a duplicate.
|
||||
const RECORD_FILE_SUFFIX = ".json";
|
||||
|
||||
// A unique temp-file counter, so two concurrent appends for two different
|
||||
// records never share a temp path. The atomic rename then publishes each record
|
||||
// without a torn write.
|
||||
let tempFileCounter = 0;
|
||||
|
||||
function resolveDefaultSpoolDir(): string {
|
||||
return (
|
||||
process.env.SANDBOX_ORPHAN_CLEANUP_SPOOL_DIR ??
|
||||
path.resolve(resolvePaperclipInstanceRoot(), "data", "sandbox-orphan-cleanup")
|
||||
);
|
||||
}
|
||||
|
||||
// Build the stable file name for one record. Key on the provider lease id, the
|
||||
// value the teardown needs, so a repeated append for the same orphan reuses the
|
||||
// same file. A null provider lease id carries no identity, so fall back to a
|
||||
// hash of the whole record. The hash keeps the name on the safe file-name
|
||||
// charset and hides the raw identifiers from a directory listing.
|
||||
function recordFileName(record: DeferredOrphanCleanupRecord): string {
|
||||
const identity =
|
||||
record.providerLeaseId !== null
|
||||
? `lease:${record.providerLeaseId}`
|
||||
: `record:${JSON.stringify(record)}`;
|
||||
const hash = createHash("sha256").update(identity).digest("hex");
|
||||
return `${hash}${RECORD_FILE_SUFFIX}`;
|
||||
}
|
||||
|
||||
function isDeferredOrphanCleanupRecord(value: unknown): value is DeferredOrphanCleanupRecord {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
const stringOrNull = (field: unknown) => field === null || typeof field === "string";
|
||||
return (
|
||||
typeof candidate.companyId === "string" &&
|
||||
typeof candidate.environmentId === "string" &&
|
||||
stringOrNull(candidate.executionWorkspaceId) &&
|
||||
stringOrNull(candidate.issueId) &&
|
||||
stringOrNull(candidate.heartbeatRunId) &&
|
||||
typeof candidate.provider === "string" &&
|
||||
stringOrNull(candidate.providerLeaseId) &&
|
||||
typeof candidate.metadata === "object" &&
|
||||
candidate.metadata !== null
|
||||
);
|
||||
}
|
||||
|
||||
// Write one file durably. Write the bytes to a unique temp file, flush the file
|
||||
// to the disk, rename it onto the final path, then flush the directory. The
|
||||
// rename is atomic on POSIX, so a reader never sees a torn record, and a crash
|
||||
// after the directory flush still leaves the whole record on the disk.
|
||||
async function writeFileDurable(absPath: string, data: string): Promise<void> {
|
||||
tempFileCounter += 1;
|
||||
const tempPath = `${absPath}.${process.pid}.${tempFileCounter}.tmp`;
|
||||
const handle = await fs.open(tempPath, "w");
|
||||
try {
|
||||
await handle.writeFile(data, "utf8");
|
||||
await handle.sync();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
try {
|
||||
await fs.rename(tempPath, absPath);
|
||||
} catch (error) {
|
||||
// The rename failed, so drop the temp file. Do not leave it behind, or a
|
||||
// later load reads a half-published record.
|
||||
await fs.rm(tempPath, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
const dirHandle = await fs.open(path.dirname(absPath), "r");
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a local-disk orphan-cleanup spool. The spool writes one file per record
|
||||
* under the spool directory. The default directory sits next to the other
|
||||
* durable server state under the instance root. A caller can override the
|
||||
* directory for a test.
|
||||
*/
|
||||
export function createSandboxOrphanCleanupSpool(spoolDir?: string): SandboxOrphanCleanupSpool {
|
||||
const dir = spoolDir ?? resolveDefaultSpoolDir();
|
||||
|
||||
return {
|
||||
async append(record) {
|
||||
const absPath = path.join(dir, recordFileName(record));
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
// Serialize the record only. A caught write error never enters a log,
|
||||
// because it can carry a credential in its message, code, cause, or
|
||||
// stack. So this method reports a boolean and never logs.
|
||||
await writeFileDurable(absPath, JSON.stringify(record));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async load() {
|
||||
let names: string[];
|
||||
try {
|
||||
names = await fs.readdir(dir);
|
||||
} catch {
|
||||
// The directory does not exist yet, or the read failed. Either way, no
|
||||
// record waits on the disk.
|
||||
return [];
|
||||
}
|
||||
const records: DeferredOrphanCleanupRecord[] = [];
|
||||
for (const name of names) {
|
||||
if (!name.endsWith(RECORD_FILE_SUFFIX)) continue;
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(dir, name), "utf8");
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (isDeferredOrphanCleanupRecord(parsed)) {
|
||||
records.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Skip a malformed or unreadable file. A partial file cannot authorize
|
||||
// a teardown, so the load ignores it and keeps the other records.
|
||||
}
|
||||
}
|
||||
return records;
|
||||
},
|
||||
|
||||
async remove(record) {
|
||||
const absPath = path.join(dir, recordFileName(record));
|
||||
await fs.rm(absPath, { force: true }).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -20,6 +20,14 @@ export interface AcquireSandboxLeaseInput {
|
|||
issueId: string | null;
|
||||
agentId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
/**
|
||||
* The absolute latest time the acquired lease may stay active, as an ISO 8601
|
||||
* timestamp. A caller with an independent deadline sets it. The provider must
|
||||
* configure a provider-side expiry at or before this time, and return the real
|
||||
* provider expiry in `SandboxLeaseHandle.expiresAt`. When omitted, the provider
|
||||
* keeps its default lifetime.
|
||||
*/
|
||||
requestedExpiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface ResumeSandboxLeaseInput {
|
||||
|
|
@ -63,6 +71,12 @@ export interface SandboxExecuteInput {
|
|||
export interface SandboxLeaseHandle {
|
||||
providerLeaseId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
/**
|
||||
* The real provider-side expiry the provider granted, as an ISO 8601 timestamp.
|
||||
* A provider that honors a requested deadline returns the actual expiry it
|
||||
* configured. Absent when the provider granted no bounded lifetime.
|
||||
*/
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PreparedSandboxWorkspace {
|
||||
|
|
@ -336,6 +350,7 @@ export async function acquireSandboxProviderLease(input: {
|
|||
agentId?: string | null;
|
||||
executionWorkspaceId?: string | null;
|
||||
reusableProviderLeaseId?: string | null;
|
||||
requestedExpiresAt?: string | null;
|
||||
}): Promise<SandboxLeaseHandle> {
|
||||
const provider = requireSandboxProvider(input.config.provider);
|
||||
if (provider.supportsReusableLeases && input.config.reuseLease && input.reusableProviderLeaseId) {
|
||||
|
|
@ -355,6 +370,7 @@ export async function acquireSandboxProviderLease(input: {
|
|||
issueId: input.issueId,
|
||||
agentId: input.agentId,
|
||||
executionWorkspaceId: input.executionWorkspaceId,
|
||||
requestedExpiresAt: input.requestedExpiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,10 @@ import { logActivity } from "./activity-log.js";
|
|||
|
||||
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access.";
|
||||
// System consumer id for a durable orphan-sandbox teardown. The cleanup sweep
|
||||
// resolves the recorded connection secret under this id so the audit trail
|
||||
// marks the read as an orphan-sandbox teardown, not a normal environment read.
|
||||
export const SANDBOX_CLEANUP_CONSUMER_ID = "environment-sandbox-cleanup";
|
||||
const SENSITIVE_ENV_KEY_RE =
|
||||
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
|
||||
const REDACTED_SENTINEL = "***REDACTED***";
|
||||
|
|
@ -80,6 +84,180 @@ const FALLBACK_ADAPTER_SCHEMA_SECRET_FIELDS: Readonly<Record<string, readonly st
|
|||
};
|
||||
const USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT = "user_secret_definitions_company_key_uq";
|
||||
const USER_SECRET_VALUE_UNIQUE_CONSTRAINT = "company_secrets_user_definition_owner_uq";
|
||||
// The unique index on (secretId, version). A concurrent rotation that inserts the
|
||||
// same next version first makes the loser's insert fail this constraint.
|
||||
const COMPANY_SECRET_VERSION_UNIQUE_CONSTRAINT = "company_secret_versions_secret_version_uq";
|
||||
// The one stale-rotation conflict text. The rotate function returns it for every
|
||||
// stale race, so the caller sees one fixed 409 and no owner-value state.
|
||||
const SECRET_VERSION_STALE_CONFLICT = "The secret version is stale. Reload and confirm the rotation again.";
|
||||
|
||||
// The fixed Claude Code OAuth user-secret definition. The Claude login flow owns
|
||||
// only this compile-time key and these fixed properties. A caller never selects
|
||||
// the key, the name, the provider, the mode, or the status.
|
||||
const CLAUDE_CODE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
const CLAUDE_CODE_OAUTH_DEFINITION = {
|
||||
key: CLAUDE_CODE_OAUTH_TOKEN_KEY,
|
||||
name: "Claude Code OAuth token",
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
status: "active",
|
||||
} as const;
|
||||
// The fixed, non-secret conflict text. The helper returns it when a stored
|
||||
// definition for the fixed key does not match the fixed shape. The text echoes
|
||||
// no caller input.
|
||||
const CLAUDE_OAUTH_DEFINITION_CONFLICT =
|
||||
"A conflicting Claude Code OAuth token definition already exists.";
|
||||
// The fixed, non-secret text for a stale confirmed rotation. The text is the
|
||||
// same for every stale reason, so it discloses no owner-value state.
|
||||
const CLAUDE_OAUTH_STALE_CONFIRMATION =
|
||||
"The Claude login confirmation is stale. Reload the page and confirm again.";
|
||||
// The fixed, non-secret text for a first write that finds an existing value. The
|
||||
// caller must confirm a replacement to rotate it.
|
||||
const CLAUDE_OAUTH_VALUE_EXISTS =
|
||||
"A Claude login value already exists. Confirm a replacement to rotate it.";
|
||||
// The metadata field that records the setup-token session id on the owner value.
|
||||
// It is the idempotency key for one completion. It is not a secret.
|
||||
const CLAUDE_OAUTH_SESSION_METADATA_FIELD = "claudeSetupTokenSessionId";
|
||||
|
||||
/** The stored result of one owner-bound Claude OAuth completion. It holds no secret. */
|
||||
export interface ClaudeOAuthUserSecretResult {
|
||||
secretId: string;
|
||||
latestVersion: number;
|
||||
definitionId: string;
|
||||
}
|
||||
|
||||
// --- The server-enforced Claude OAuth binding invariant ------------
|
||||
|
||||
/** The adapter that owns the fixed Claude Code OAuth token binding. */
|
||||
export const CLAUDE_LOCAL_ADAPTER_TYPE = "claude_local";
|
||||
|
||||
// The one fixed, non-secret error for every rejected stored-session claim. The
|
||||
// text is byte-identical for a missing, foreign, cross-company, cross-owner,
|
||||
// cross-adapter, cross-environment, expired, non-stored, or already-consumed
|
||||
// claim, so a caller cannot tell the reasons apart.
|
||||
export const CLAUDE_OAUTH_CLAIM_REJECTED =
|
||||
"The Claude login binding requires a valid stored-session claim.";
|
||||
|
||||
// The generic credential-conflict text. It names no token value and no owner
|
||||
// configuration. It tells the caller only that a higher-priority credential is
|
||||
// configured together with the Claude login token.
|
||||
export const CLAUDE_OAUTH_CREDENTIAL_CONFLICT =
|
||||
"A higher-priority Claude credential is configured. Remove it to use the Claude login token.";
|
||||
|
||||
const ANTHROPIC_API_KEY_ENV = "ANTHROPIC_API_KEY";
|
||||
|
||||
/** Reads the `env` record of an adapter config, or an empty record. */
|
||||
function readAdapterEnvRecord(config: unknown): Record<string, unknown> {
|
||||
if (typeof config !== "object" || config === null || Array.isArray(config)) return {};
|
||||
const env = (config as Record<string, unknown>).env;
|
||||
if (typeof env !== "object" || env === null || Array.isArray(env)) return {};
|
||||
return env as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when a binding is the exact fixed Claude Code OAuth user-secret
|
||||
* reference. The fixed binding is a `user_secret_ref` whose key is the fixed
|
||||
* key. Any other shape (a plain value, a company secret reference, or a
|
||||
* different user-secret key) is a replacement or a weaker binding.
|
||||
*/
|
||||
export function isFixedClaudeOAuthBinding(binding: unknown): boolean {
|
||||
if (typeof binding !== "object" || binding === null) return false;
|
||||
const record = binding as Record<string, unknown>;
|
||||
return record.type === "user_secret_ref" && record.key === CLAUDE_CODE_OAUTH_TOKEN_KEY;
|
||||
}
|
||||
|
||||
/** True when the config carries the exact fixed OAuth binding. */
|
||||
function hasFixedClaudeOAuthBinding(config: unknown): boolean {
|
||||
return isFixedClaudeOAuthBinding(readAdapterEnvRecord(config)[CLAUDE_CODE_OAUTH_TOKEN_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the config delivers a non-empty ANTHROPIC_API_KEY. A plain
|
||||
* binding counts only when it has a non-empty value. A company or user secret
|
||||
* reference always counts, because it resolves to a value at runtime.
|
||||
*/
|
||||
function hasAnthropicApiKeyCredential(config: unknown): boolean {
|
||||
const binding = readAdapterEnvRecord(config)[ANTHROPIC_API_KEY_ENV];
|
||||
if (typeof binding === "string") return binding.trim().length > 0;
|
||||
if (typeof binding !== "object" || binding === null) return false;
|
||||
const record = binding as Record<string, unknown>;
|
||||
if (record.type === "plain") {
|
||||
return typeof record.value === "string" && record.value.trim().length > 0;
|
||||
}
|
||||
return record.type === "secret_ref" || record.type === "user_secret_ref";
|
||||
}
|
||||
|
||||
export interface ClaudeOAuthBindingInvariantInput {
|
||||
/** The effective adapter type of the write. */
|
||||
adapterType: string | null | undefined;
|
||||
/** The normalized adapter config the write persists. */
|
||||
nextConfig: unknown;
|
||||
/** The stored adapter config before the write. Null on a create. */
|
||||
priorConfig?: unknown;
|
||||
}
|
||||
|
||||
export interface ClaudeOAuthBindingInvariantDecision {
|
||||
/** True when the write adds the fixed binding that the prior config lacked. */
|
||||
introducesBinding: boolean;
|
||||
/** True when the write keeps an existing fixed binding unchanged. */
|
||||
keepsBinding: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Claude OAuth binding check. It runs after generic normalization and
|
||||
* before every database write on a `claude_local` create, hire, update,
|
||||
* approval activation, and configuration rollback path.
|
||||
*
|
||||
* The `CLAUDE_CODE_OAUTH_TOKEN` binding behaves like a normal environment
|
||||
* variable. A normal write can remove the fixed binding, or re-point it to a
|
||||
* plain value, a company-secret reference, or a different user-secret key. The
|
||||
* function no longer locks a prior fixed binding against removal or replacement.
|
||||
*
|
||||
* A write to a non-claude_local adapter that has no prior fixed binding stays
|
||||
* outside the Claude login flow. A prior fixed binding keeps the function active
|
||||
* for the write, so the function still reports whether the write keeps that
|
||||
* binding, independently of the destination adapter type.
|
||||
*
|
||||
* The precedence policy runs on every path: the fixed binding together with a
|
||||
* non-empty ANTHROPIC_API_KEY is a conflict. The function rejects that conflict
|
||||
* with a generic message that names no token value and no owner configuration.
|
||||
*
|
||||
* The function returns whether the write introduces the fixed binding or keeps
|
||||
* an existing one. The create and hire paths consume a stored-session claim when
|
||||
* the write introduces the binding. The update, approval, and rollback paths
|
||||
* reject a newly introduced binding, because they carry no claim.
|
||||
*/
|
||||
export function assertClaudeOAuthBindingInvariant(
|
||||
input: ClaudeOAuthBindingInvariantInput,
|
||||
): ClaudeOAuthBindingInvariantDecision {
|
||||
const isClaudeLocal = input.adapterType === CLAUDE_LOCAL_ADAPTER_TYPE;
|
||||
const nextIsFixed = hasFixedClaudeOAuthBinding(input.nextConfig);
|
||||
const priorIsFixed = hasFixedClaudeOAuthBinding(input.priorConfig);
|
||||
|
||||
// A write to a non-claude_local adapter that has no prior fixed binding is a
|
||||
// normal non-Claude configuration. It stays outside the Claude login flow. A
|
||||
// prior fixed binding always keeps the function active, so a write still
|
||||
// reports the binding state after a move to another adapter type.
|
||||
if (!isClaudeLocal && !priorIsFixed) {
|
||||
return { introducesBinding: false, keepsBinding: false };
|
||||
}
|
||||
|
||||
if (nextIsFixed && hasAnthropicApiKeyCredential(input.nextConfig)) {
|
||||
throw new HttpError(409, CLAUDE_OAUTH_CREDENTIAL_CONFLICT, {
|
||||
code: "claude_oauth_credential_conflict",
|
||||
});
|
||||
}
|
||||
return {
|
||||
introducesBinding: nextIsFixed && !priorIsFixed,
|
||||
keepsBinding: nextIsFixed && priorIsFixed,
|
||||
};
|
||||
}
|
||||
|
||||
/** The fixed error the create and hire paths raise for a rejected claim. */
|
||||
export function claudeOAuthClaimRejectedError(): HttpError {
|
||||
return new HttpError(409, CLAUDE_OAUTH_CLAIM_REJECTED, { code: "claude_oauth_claim_rejected" });
|
||||
}
|
||||
|
||||
type DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type SecretBindingDb = Pick<Db | DbTransaction, "select" | "delete" | "insert">;
|
||||
|
||||
|
|
@ -624,7 +802,9 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode {
|
|||
if (error.message === "Responsible user is required for user secret resolution") {
|
||||
return "responsible_user_missing";
|
||||
}
|
||||
if (error.message === "User secret definition not found") return "user_secret_definition_missing";
|
||||
if (error.message.startsWith("User secret definition not found")) {
|
||||
return "user_secret_definition_missing";
|
||||
}
|
||||
if (error.message === "User secret definition is not active") return "user_secret_definition_inactive";
|
||||
if (error.message === "User-scoped secrets must be resolved through user secret declarations") {
|
||||
return "secret_scope_invalid";
|
||||
|
|
@ -682,6 +862,47 @@ function missingUserSecretDefinitionRuntimeBinding(
|
|||
};
|
||||
}
|
||||
|
||||
// A direct-resolution path (Test or save) resolves one user-secret binding at a
|
||||
// time. When the definition is gone, this builder names the environment
|
||||
// variable, the consumer, and the unresolved definition so the actor can find
|
||||
// the dangling binding. The message keeps the exact "User secret definition not
|
||||
// found" prefix so `secretResolutionErrorCode` still maps it to
|
||||
// `user_secret_definition_missing`. It never includes a secret value.
|
||||
type UserSecretDefinitionResolutionContext = {
|
||||
envKey?: string | null;
|
||||
configPath?: string | null;
|
||||
consumerType?: string | null;
|
||||
consumerId?: string | null;
|
||||
};
|
||||
|
||||
function envKeyFromConfigPath(configPath?: string | null): string | null {
|
||||
if (!configPath) return null;
|
||||
const separatorIndex = configPath.lastIndexOf(".");
|
||||
const key = separatorIndex >= 0 ? configPath.slice(separatorIndex + 1) : configPath;
|
||||
return key.length > 0 ? key : null;
|
||||
}
|
||||
|
||||
const BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE = "User secret definition not found";
|
||||
|
||||
function userSecretDefinitionNotFoundMessage(
|
||||
input: { definitionId?: string | null; definitionKey?: string | null },
|
||||
context?: UserSecretDefinitionResolutionContext,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
const envKey = context?.envKey ?? envKeyFromConfigPath(context?.configPath);
|
||||
if (envKey) parts.push(`environment variable "${envKey}"`);
|
||||
if (context?.consumerId) {
|
||||
parts.push(`${context.consumerType ?? "consumer"} ${context.consumerId}`);
|
||||
}
|
||||
if (input.definitionKey) {
|
||||
parts.push(`definition key "${input.definitionKey}"`);
|
||||
} else if (input.definitionId) {
|
||||
parts.push(`definition id "${input.definitionId}"`);
|
||||
}
|
||||
if (parts.length === 0) return BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE;
|
||||
return `${BASE_USER_SECRET_DEFINITION_NOT_FOUND_MESSAGE} for ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
function assertSelectableProviderConfig(config: {
|
||||
provider: string;
|
||||
status: string;
|
||||
|
|
@ -779,6 +1000,7 @@ export function secretService(db: Db) {
|
|||
companyId: string,
|
||||
input: { definitionId?: string | null; definitionKey?: string | null },
|
||||
source: Pick<Db | DbTransaction, "select"> = db,
|
||||
context?: UserSecretDefinitionResolutionContext,
|
||||
) {
|
||||
const definition = input.definitionId
|
||||
? await getUserSecretDefinitionById(companyId, input.definitionId, source)
|
||||
|
|
@ -786,7 +1008,7 @@ export function secretService(db: Db) {
|
|||
? await getUserSecretDefinitionByKey(companyId, input.definitionKey, source)
|
||||
: null;
|
||||
if (!definition || definition.deletedAt || definition.status === "deleted") {
|
||||
throw notFound("User secret definition not found");
|
||||
throw notFound(userSecretDefinitionNotFoundMessage(input, context));
|
||||
}
|
||||
if (definition.companyId !== companyId) {
|
||||
throw unprocessable("User secret definition must belong to same company");
|
||||
|
|
@ -1248,6 +1470,44 @@ export function secretService(db: Db) {
|
|||
})).value;
|
||||
}
|
||||
|
||||
// Resolve a connection secret for a durable orphan-sandbox teardown.
|
||||
//
|
||||
// The retry destroys a remote sandbox that a failed acquire left allocated.
|
||||
// The teardown needs the same connection secret the acquire used. But the
|
||||
// environment binding may be gone: a delete removed the environment, or a
|
||||
// provider change replaced the binding. So this path authorizes the read from
|
||||
// the durable `pending_cleanup` lease row, not from the environment binding,
|
||||
// and it never checks the binding. The caller passes only a secret id that the
|
||||
// durable row recorded, so the scope stays narrow. The resolution records an
|
||||
// access event for audit, and the value never enters lease metadata.
|
||||
async function resolveSecretValueForSandboxCleanup(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
version: number | "latest",
|
||||
context: {
|
||||
configPath: string;
|
||||
issueId?: string | null;
|
||||
heartbeatRunId?: string | null;
|
||||
},
|
||||
): Promise<string> {
|
||||
return (
|
||||
await resolveSecretValueInternal(companyId, secretId, version, {
|
||||
// Audit-only access context. No `bindingContext`, so the resolver never
|
||||
// asserts the environment binding that a delete or a provider change may
|
||||
// have removed.
|
||||
accessContext: {
|
||||
consumerType: "system",
|
||||
consumerId: SANDBOX_CLEANUP_CONSUMER_ID,
|
||||
actorType: "system",
|
||||
actorId: null,
|
||||
configPath: context.configPath,
|
||||
issueId: context.issueId ?? null,
|
||||
heartbeatRunId: context.heartbeatRunId ?? null,
|
||||
},
|
||||
})
|
||||
).value;
|
||||
}
|
||||
|
||||
async function resolveSecretValueForAgentAccess(
|
||||
companyId: string,
|
||||
secretId: string,
|
||||
|
|
@ -2243,6 +2503,218 @@ export function secretService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Claude Code OAuth login: narrow definition and owner-bound write -------
|
||||
|
||||
type UserSecretDefinitionRow = typeof userSecretDefinitions.$inferSelect;
|
||||
type CompanySecretRow = typeof companySecrets.$inferSelect;
|
||||
|
||||
/** True only when a stored definition matches the fixed Claude OAuth shape. */
|
||||
function isCompatibleClaudeOAuthDefinition(definition: UserSecretDefinitionRow) {
|
||||
return (
|
||||
definition.key === CLAUDE_CODE_OAUTH_DEFINITION.key &&
|
||||
definition.name === CLAUDE_CODE_OAUTH_DEFINITION.name &&
|
||||
definition.provider === CLAUDE_CODE_OAUTH_DEFINITION.provider &&
|
||||
definition.managedMode === CLAUDE_CODE_OAUTH_DEFINITION.managedMode &&
|
||||
definition.status === CLAUDE_CODE_OAUTH_DEFINITION.status
|
||||
);
|
||||
}
|
||||
|
||||
/** Reads the recorded setup-token session id from an owner value, or null. */
|
||||
function readClaudeOAuthSessionId(secret: CompanySecretRow): string | null {
|
||||
const metadata = secret.providerMetadata;
|
||||
if (!metadata || typeof metadata !== "object") return null;
|
||||
const value = (metadata as Record<string, unknown>)[CLAUDE_OAUTH_SESSION_METADATA_FIELD];
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
/** Records the setup-token session id on the owner value. Not a secret. */
|
||||
async function stampClaudeOAuthSessionId(
|
||||
secret: CompanySecretRow,
|
||||
sessionId: string,
|
||||
): Promise<CompanySecretRow> {
|
||||
const nextMetadata: Record<string, unknown> = {
|
||||
...(secret.providerMetadata ?? {}),
|
||||
[CLAUDE_OAUTH_SESSION_METADATA_FIELD]: sessionId,
|
||||
};
|
||||
return db
|
||||
.update(companySecrets)
|
||||
.set({ providerMetadata: nextMetadata, updatedAt: new Date() })
|
||||
.where(eq(companySecrets.id, secret.id))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? secret);
|
||||
}
|
||||
|
||||
function toClaudeOAuthResult(secret: CompanySecretRow): ClaudeOAuthUserSecretResult {
|
||||
return {
|
||||
secretId: secret.id,
|
||||
latestVersion: secret.latestVersion,
|
||||
definitionId: secret.userSecretDefinitionId ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the fixed Claude Code OAuth user-secret definition for a company. The
|
||||
* helper accepts no key, name, provider, mode, or status from a caller. It
|
||||
* reads the existing definition by the company and the fixed key. It returns an
|
||||
* exact compatible definition. It rejects a conflicting definition with 409 and
|
||||
* does not mutate it. After a uniqueness conflict it re-reads the row and
|
||||
* compares the fixed fields before it returns.
|
||||
*/
|
||||
async function ensureClaudeOAuthUserSecretDefinitionInternal(
|
||||
companyId: string,
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
): Promise<UserSecretDefinitionRow> {
|
||||
const existing = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key);
|
||||
if (existing) {
|
||||
if (!isCompatibleClaudeOAuthDefinition(existing)) {
|
||||
throw conflict(CLAUDE_OAUTH_DEFINITION_CONFLICT);
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
try {
|
||||
return await db
|
||||
.insert(userSecretDefinitions)
|
||||
.values({
|
||||
companyId,
|
||||
key: CLAUDE_CODE_OAUTH_DEFINITION.key,
|
||||
name: CLAUDE_CODE_OAUTH_DEFINITION.name,
|
||||
description: null,
|
||||
status: CLAUDE_CODE_OAUTH_DEFINITION.status,
|
||||
provider: CLAUDE_CODE_OAUTH_DEFINITION.provider,
|
||||
providerConfigId: null,
|
||||
managedMode: CLAUDE_CODE_OAUTH_DEFINITION.managedMode,
|
||||
providerMetadata: null,
|
||||
usageGuidance: null,
|
||||
createdByAgentId: actor?.agentId ?? null,
|
||||
createdByUserId: actor?.userId ?? null,
|
||||
updatedByAgentId: actor?.agentId ?? null,
|
||||
updatedByUserId: actor?.userId ?? null,
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, USER_SECRET_DEFINITION_KEY_UNIQUE_CONSTRAINT)) {
|
||||
// A concurrent create won the race. Re-read and compare the fixed fields.
|
||||
const raced = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key);
|
||||
if (raced && isCompatibleClaudeOAuthDefinition(raced)) return raced;
|
||||
throw conflict(CLAUDE_OAUTH_DEFINITION_CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The owner-bound compare-and-set for the Claude Code OAuth value. It has two
|
||||
* modes. `first_write` creates a value only when no owner value exists.
|
||||
* `confirmed_rotation` rotates only after confirmation with the expected secret
|
||||
* id and the expected latest version. The session id is the idempotency key: a
|
||||
* repeated successful completion returns the stored result and creates no new
|
||||
* version.
|
||||
*/
|
||||
async function completeClaudeOAuthUserSecretInternal(
|
||||
companyId: string,
|
||||
ownerUserId: string,
|
||||
input: {
|
||||
sessionId: string;
|
||||
mode: "first_write" | "confirmed_rotation";
|
||||
value: string;
|
||||
expectedSecretId?: string | null;
|
||||
expectedLatestVersion?: number | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
): Promise<ClaudeOAuthUserSecretResult> {
|
||||
const sessionId = input.sessionId?.trim();
|
||||
if (!sessionId) throw unprocessable("A Claude login session id is required");
|
||||
const value = input.value?.trim();
|
||||
if (!value) throw unprocessable("A Claude login token value is required");
|
||||
|
||||
const definition = await ensureClaudeOAuthUserSecretDefinitionInternal(companyId, actor);
|
||||
|
||||
// Idempotency: a prior successful completion for this session returns the
|
||||
// stored result and creates no new version.
|
||||
const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id });
|
||||
if (existing && readClaudeOAuthSessionId(existing) === sessionId) {
|
||||
return toClaudeOAuthResult(existing);
|
||||
}
|
||||
|
||||
if (input.mode === "first_write") {
|
||||
if (existing) throw conflict(CLAUDE_OAUTH_VALUE_EXISTS);
|
||||
let created: CompanySecretRow;
|
||||
try {
|
||||
created = await createUserSecretValueInternal(
|
||||
companyId,
|
||||
ownerUserId,
|
||||
{ definitionId: definition.id, value },
|
||||
actor,
|
||||
);
|
||||
} catch (error) {
|
||||
// Two concurrent first writes race the partial unique index. Re-read and
|
||||
// return the stored result only when this session already won.
|
||||
if (error instanceof HttpError && error.status === 409) {
|
||||
const raced = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id });
|
||||
if (raced && readClaudeOAuthSessionId(raced) === sessionId) {
|
||||
return toClaudeOAuthResult(raced);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const stamped = await stampClaudeOAuthSessionId(created, sessionId);
|
||||
return toClaudeOAuthResult(stamped);
|
||||
}
|
||||
|
||||
// confirmed_rotation.
|
||||
if (!input.expectedSecretId || input.expectedLatestVersion == null) {
|
||||
throw unprocessable("A confirmed rotation requires expectedSecretId and expectedLatestVersion");
|
||||
}
|
||||
// The owner-scoped lookup fails closed for a cross-owner or cross-company id.
|
||||
const current = await getUserSecretValueById(companyId, ownerUserId, input.expectedSecretId);
|
||||
if (current.userSecretDefinitionId !== definition.id) {
|
||||
throw notFound("User secret value not found");
|
||||
}
|
||||
if (current.latestVersion !== input.expectedLatestVersion) {
|
||||
throw conflict(CLAUDE_OAUTH_STALE_CONFIRMATION);
|
||||
}
|
||||
let rotated: CompanySecretRow;
|
||||
try {
|
||||
rotated = await secretService(db).rotate(
|
||||
current.id,
|
||||
{ value, expectedLatestVersion: input.expectedLatestVersion },
|
||||
actor,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError && error.status === 409) {
|
||||
// A concurrent rotation bumped the version between the read and the
|
||||
// predicate. Return the same fixed stale-confirmation conflict.
|
||||
throw conflict(CLAUDE_OAUTH_STALE_CONFIRMATION);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const stamped = await stampClaudeOAuthSessionId(rotated, sessionId);
|
||||
return toClaudeOAuthResult(stamped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the stored Claude Code OAuth value metadata for one owner. It returns
|
||||
* only the secret id and the latest version, never the token. The client uses
|
||||
* the version as the expected version of a later confirmed rotation.
|
||||
*
|
||||
* The reader derives no definition, no owner, and no secret id from a caller.
|
||||
* It reads the fixed definition by the company and the fixed key. It reads the
|
||||
* value by the company, the owner, and that definition. It returns null when no
|
||||
* definition or no owner value exists, so a foreign value and a missing value
|
||||
* look the same to the caller. It never creates the definition.
|
||||
*/
|
||||
async function readClaudeOAuthUserSecretStatusInternal(
|
||||
companyId: string,
|
||||
ownerUserId: string,
|
||||
): Promise<{ secretId: string; latestVersion: number } | null> {
|
||||
const definition = await getUserSecretDefinitionByKey(companyId, CLAUDE_CODE_OAUTH_DEFINITION.key);
|
||||
if (!definition || !isCompatibleClaudeOAuthDefinition(definition)) return null;
|
||||
const existing = await getUserSecretValue({ companyId, ownerUserId, definitionId: definition.id });
|
||||
if (!existing) return null;
|
||||
return { secretId: existing.id, latestVersion: existing.latestVersion };
|
||||
}
|
||||
|
||||
async function removeSecretInternal(secretId: string) {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) return null;
|
||||
|
|
@ -2821,6 +3293,25 @@ export function secretService(db: Db) {
|
|||
|
||||
createCurrentUserSecretValue: createUserSecretValueInternal,
|
||||
|
||||
// The narrow Claude Code OAuth definition helper. A caller passes
|
||||
// no key, name, provider, mode, or status. The route calls it only after the
|
||||
// authenticated board-user, company, and sandbox checks pass.
|
||||
ensureClaudeOAuthUserSecretDefinition: (
|
||||
companyId: string,
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => ensureClaudeOAuthUserSecretDefinitionInternal(companyId, actor),
|
||||
|
||||
// The owner-bound Claude Code OAuth compare-and-set. It creates a
|
||||
// first value or rotates after a confirmed expected version. The session id
|
||||
// is the idempotency key for one completion.
|
||||
completeClaudeOAuthUserSecret: completeClaudeOAuthUserSecretInternal,
|
||||
|
||||
// The owner-bound Claude Code OAuth status read. It returns only
|
||||
// the secret id and the latest version for the owner value, or null. It never
|
||||
// returns the token and never creates the definition. The status route reads
|
||||
// the expected version from it before it captures the confirmed rotation.
|
||||
readClaudeOAuthUserSecretStatus: readClaudeOAuthUserSecretStatusInternal,
|
||||
|
||||
rotateCurrentUserSecretValue: async (
|
||||
companyId: string,
|
||||
ownerUserId: string,
|
||||
|
|
@ -2916,6 +3407,12 @@ export function secretService(db: Db) {
|
|||
companyId,
|
||||
{ definitionKey: ref.definitionKey },
|
||||
targetDb,
|
||||
{
|
||||
envKey: ref.envKey,
|
||||
configPath: ref.configPath,
|
||||
consumerType: target.targetType,
|
||||
consumerId: target.targetId,
|
||||
},
|
||||
);
|
||||
normalizedRefs.push({
|
||||
definitionId: definition.id,
|
||||
|
|
@ -2989,7 +3486,11 @@ export function secretService(db: Db) {
|
|||
const optionalBinding = input.allowMissingOverride || input.required === false;
|
||||
let definition: typeof userSecretDefinitions.$inferSelect;
|
||||
try {
|
||||
definition = await resolveUserSecretDefinition(companyId, input);
|
||||
definition = await resolveUserSecretDefinition(companyId, input, db, {
|
||||
configPath: context?.configPath ?? null,
|
||||
consumerType: context?.consumerType ?? null,
|
||||
consumerId: context?.consumerId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (optionalBinding && error instanceof HttpError && error.status === 404) return null;
|
||||
throw error;
|
||||
|
|
@ -3370,6 +3871,7 @@ export function secretService(db: Db) {
|
|||
resolveSecretValueForAgentAccess,
|
||||
listAgentSecretAccess,
|
||||
resolveSecretValueForEphemeralAccess,
|
||||
resolveSecretValueForSandboxCleanup,
|
||||
|
||||
create: async (
|
||||
companyId: string,
|
||||
|
|
@ -3591,12 +4093,19 @@ export function secretService(db: Db) {
|
|||
externalRef?: string | null;
|
||||
providerVersionRef?: string | null;
|
||||
providerConfigId?: string | null;
|
||||
// The optional owner-bound compare-and-set guard. When set, the final
|
||||
// update matches the latest version, so a concurrent rotation between the
|
||||
// read and the write cannot pass. A mismatch throws a 409 conflict.
|
||||
expectedLatestVersion?: number;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
) => {
|
||||
const secret = await getById(secretId);
|
||||
if (!secret) throw notFound("Secret not found");
|
||||
if (secret.status !== "active") throw unprocessable("Cannot rotate a non-active secret");
|
||||
if (input.expectedLatestVersion !== undefined && secret.latestVersion !== input.expectedLatestVersion) {
|
||||
throw conflict(SECRET_VERSION_STALE_CONFLICT);
|
||||
}
|
||||
const providerId = secret.provider as SecretProvider;
|
||||
const provider = getSecretProvider(providerId);
|
||||
const providerConfigId =
|
||||
|
|
@ -3698,6 +4207,17 @@ export function secretService(db: Db) {
|
|||
operation: "rotate.prepare_rollback",
|
||||
});
|
||||
}
|
||||
// A guarded concurrent rotation inserted the same next version first, so
|
||||
// this insert fails the (secretId, version) unique index. The loser never
|
||||
// reaches the compare-and-set guard below. Normalize only that one
|
||||
// collision to the same stale conflict, so a guarded rotation always
|
||||
// returns one fixed 409. Re-throw every other error unchanged.
|
||||
if (
|
||||
input.expectedLatestVersion !== undefined &&
|
||||
isUniqueConstraintViolation(error, COMPANY_SECRET_VERSION_UNIQUE_CONSTRAINT)
|
||||
) {
|
||||
throw conflict(SECRET_VERSION_STALE_CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
|
@ -3727,11 +4247,25 @@ export function secretService(db: Db) {
|
|||
lastRotatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(companySecrets.id, secret.id))
|
||||
.where(and(
|
||||
eq(companySecrets.id, secret.id),
|
||||
// The compare-and-set guard. It matches the latest version, so a
|
||||
// concurrent rotation that already bumped it fails this predicate.
|
||||
input.expectedLatestVersion === undefined
|
||||
? undefined
|
||||
: eq(companySecrets.latestVersion, input.expectedLatestVersion),
|
||||
))
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!updated) throw notFound("Secret not found");
|
||||
if (!updated) {
|
||||
// The predicate matched no row. A supplied expected version means a
|
||||
// concurrent rotation won the race; return the stale conflict. An
|
||||
// unguarded rotation means the secret is gone.
|
||||
throw input.expectedLatestVersion === undefined
|
||||
? notFound("Secret not found")
|
||||
: conflict(SECRET_VERSION_STALE_CONFLICT);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -4209,7 +4743,12 @@ export function secretService(db: Db) {
|
|||
if (!parsed.success) continue;
|
||||
const binding = canonicalizeBinding(parsed.data as EnvBinding);
|
||||
if (binding.type === "user_secret_ref") {
|
||||
await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb);
|
||||
await resolveUserSecretDefinition(companyId, { definitionKey: binding.key }, bindingDb, {
|
||||
envKey: key,
|
||||
configPath: `${pathPrefix}.${key}`,
|
||||
consumerType: target.targetType,
|
||||
consumerId: target.targetId,
|
||||
});
|
||||
userRefs.push({
|
||||
definitionKey: binding.key,
|
||||
configPath: `${pathPrefix}.${key}`,
|
||||
|
|
@ -4279,7 +4818,17 @@ export function secretService(db: Db) {
|
|||
if (userRefs.length === 0) return;
|
||||
const definitions = new Map<string, string>();
|
||||
for (const ref of userRefs) {
|
||||
const definition = await resolveUserSecretDefinition(companyId, { definitionKey: ref.definitionKey }, targetDb);
|
||||
const definition = await resolveUserSecretDefinition(
|
||||
companyId,
|
||||
{ definitionKey: ref.definitionKey },
|
||||
targetDb,
|
||||
{
|
||||
envKey: ref.envKey,
|
||||
configPath: ref.configPath,
|
||||
consumerType: target.targetType,
|
||||
consumerId: target.targetId,
|
||||
},
|
||||
);
|
||||
definitions.set(ref.definitionKey, definition.id);
|
||||
}
|
||||
await targetDb.insert(userSecretDeclarations).values(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,16 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
claudeSetupTokenSessions,
|
||||
companies,
|
||||
createDb,
|
||||
environments,
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
createDbSetupTokenCleanupStore,
|
||||
SetupTokenSessionService,
|
||||
SetupTokenSessionError,
|
||||
assessConfidentialStartup,
|
||||
|
|
@ -11,6 +22,8 @@ import {
|
|||
SETUP_TOKEN_RATE_LIMITED,
|
||||
SETUP_TOKEN_CAP_EXCEEDED,
|
||||
SETUP_TOKEN_TOKEN_UNAVAILABLE,
|
||||
SETUP_TOKEN_STORAGE_FAILED,
|
||||
type SetupTokenCleanupIdentity,
|
||||
type SetupTokenCleanupRecord,
|
||||
type SetupTokenCleanupStore,
|
||||
type SetupTokenCredentialSink,
|
||||
|
|
@ -21,6 +34,7 @@ import {
|
|||
type SetupTokenLoginProcessFactory,
|
||||
type SetupTokenPromptSink,
|
||||
type SetupTokenRateLimiter,
|
||||
type SetupTokenSecretWriter,
|
||||
type SetupTokenSessionScope,
|
||||
} from "./setup-token-session.js";
|
||||
import { redactSensitive } from "../middleware/redact-sensitive.js";
|
||||
|
|
@ -37,6 +51,8 @@ const OWNER_SCOPE: SetupTokenSessionScope = {
|
|||
companyId: "company-1",
|
||||
ownerUserId: "user-1",
|
||||
targetAgentId: "agent-1",
|
||||
adapterType: "claude_local",
|
||||
environmentId: "env-1",
|
||||
};
|
||||
|
||||
/** A controllable fake login process. It records the call order into `events`. */
|
||||
|
|
@ -59,8 +75,10 @@ class FakeProcess implements SetupTokenLoginProcess {
|
|||
surfacePrompt(url: string): void {
|
||||
this.onPrompt({ url });
|
||||
}
|
||||
surfaceCredential(token: string): void {
|
||||
this.onCredential(token);
|
||||
// The credential sink is asynchronous: it awaits the owner-bound secret write.
|
||||
// The test awaits this call so the write settles before it asserts.
|
||||
async surfaceCredential(token: string): Promise<void> {
|
||||
await this.onCredential(token);
|
||||
}
|
||||
finish(outcome: SetupTokenLoginOutcome): void {
|
||||
this.resolveDone(outcome);
|
||||
|
|
@ -103,61 +121,168 @@ class FakeLeaseManager implements SetupTokenLeaseManager {
|
|||
}
|
||||
}
|
||||
|
||||
// A lease manager whose `acquire` stays in flight until the test resolves it.
|
||||
// The test uses it to hold the first start suspended on the lease acquire, then
|
||||
// prove a concurrent start for the same owner fails closed before it reaches the
|
||||
// provider. It can also fail the next acquire, to drive the acquire-failure
|
||||
// rollback path.
|
||||
class DeferredLeaseManager implements SetupTokenLeaseManager {
|
||||
acquireCalls = 0;
|
||||
released: string[] = [];
|
||||
releaseByIdCalls: string[] = [];
|
||||
failNextAcquire = false;
|
||||
private counter = 0;
|
||||
private readonly pending: Array<{ resolve: (lease: SetupTokenLease) => void; id: string }> = [];
|
||||
async acquire(): Promise<SetupTokenLease> {
|
||||
this.acquireCalls += 1;
|
||||
if (this.failNextAcquire) {
|
||||
this.failNextAcquire = false;
|
||||
throw new Error("lease acquire failed");
|
||||
}
|
||||
const id = `lease-${(this.counter += 1)}`;
|
||||
return new Promise<SetupTokenLease>((resolve) => {
|
||||
this.pending.push({ resolve, id });
|
||||
});
|
||||
}
|
||||
/** Resolves the oldest in-flight acquire with its lease. */
|
||||
resolveNextAcquire(): void {
|
||||
const next = this.pending.shift();
|
||||
if (next) next.resolve({ id: next.id });
|
||||
}
|
||||
/** The count of acquires that are in flight. */
|
||||
pendingCount(): number {
|
||||
return this.pending.length;
|
||||
}
|
||||
async release(lease: SetupTokenLease): Promise<void> {
|
||||
this.released.push(lease.id);
|
||||
}
|
||||
async releaseById(leaseId: string): Promise<void> {
|
||||
this.releaseByIdCalls.push(leaseId);
|
||||
this.released.push(leaseId);
|
||||
}
|
||||
}
|
||||
|
||||
// Builds the durable-record identity from a session scope and the session id. The
|
||||
// fake writers use it to simulate the atomic durable `stored` transition that the
|
||||
// production writer performs inside the credential transaction.
|
||||
function identityFromScope(scope: SetupTokenSessionScope, sessionId: string): SetupTokenCleanupIdentity {
|
||||
return {
|
||||
sessionId,
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
adapterType: scope.adapterType,
|
||||
environmentId: scope.environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
function identityMatchesRow(row: SetupTokenCleanupRecord, identity: SetupTokenCleanupIdentity): boolean {
|
||||
return (
|
||||
row.companyId === identity.companyId &&
|
||||
row.ownerUserId === identity.ownerUserId &&
|
||||
row.adapterType === identity.adapterType &&
|
||||
row.environmentId === identity.environmentId
|
||||
);
|
||||
}
|
||||
|
||||
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 markState(identity: SetupTokenCleanupIdentity, state: SetupTokenCleanupRecord["state"]): Promise<void> {
|
||||
const row = this.rows.get(identity.sessionId);
|
||||
// The write matches the full owner scope, so it never updates a row by the
|
||||
// session id alone.
|
||||
if (row && identityMatchesRow(row, identity)) row.state = state;
|
||||
}
|
||||
async remove(sessionId: string): Promise<void> {
|
||||
this.rows.delete(sessionId);
|
||||
async remove(identity: SetupTokenCleanupIdentity): Promise<void> {
|
||||
// The delete matches the full owner scope, so it never removes a row by the
|
||||
// session id alone.
|
||||
const row = this.rows.get(identity.sessionId);
|
||||
if (row && identityMatchesRow(row, identity)) this.rows.delete(identity.sessionId);
|
||||
}
|
||||
async listReapable(now: number): Promise<SetupTokenCleanupRecord[]> {
|
||||
return [...this.rows.values()].filter(
|
||||
(row) => isTerminalSessionState(row.state) || row.deadline <= now,
|
||||
(row) => isTerminalSessionState(row.state) || row.deadline <= now || row.boundAt !== null,
|
||||
);
|
||||
}
|
||||
async consumeStoredClaim(identity: SetupTokenCleanupIdentity): Promise<SetupTokenCleanupRecord | null> {
|
||||
const row = this.rows.get(identity.sessionId);
|
||||
if (
|
||||
!row ||
|
||||
!identityMatchesRow(row, identity) ||
|
||||
row.state !== "stored" ||
|
||||
row.boundAt !== null ||
|
||||
row.deadline <= Date.now()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
row.boundAt = Date.now();
|
||||
return { ...row };
|
||||
}
|
||||
}
|
||||
|
||||
function allowAllRateLimiter(): SetupTokenRateLimiter {
|
||||
return { consume: () => ({ allowed: true, retryAfterSeconds: 0 }) };
|
||||
}
|
||||
|
||||
function buildService(overrides: {
|
||||
// One recorded owner-bound secret write. The fake writer records only the
|
||||
// non-secret ids and the token, so a test can prove the token reached the writer
|
||||
// and never the store or a log.
|
||||
interface RecordedSecretWrite {
|
||||
scope: SetupTokenSessionScope;
|
||||
sessionId: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
function buildService<L extends SetupTokenLeaseManager = FakeLeaseManager>(overrides: {
|
||||
events?: string[];
|
||||
leases?: FakeLeaseManager;
|
||||
leases?: L;
|
||||
store?: FakeStore;
|
||||
rateLimiter?: SetupTokenRateLimiter;
|
||||
caps?: { perOwner: number; perAgent: number; perCompany: number };
|
||||
ttlMs?: number;
|
||||
tokenRetentionMs?: number;
|
||||
now?: () => number;
|
||||
completeCredential?: SetupTokenSecretWriter;
|
||||
factory?: SetupTokenLoginProcessFactory;
|
||||
} = {}) {
|
||||
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 factory: SetupTokenLoginProcessFactory =
|
||||
overrides.factory ??
|
||||
(({ onPrompt, onCredential }) => {
|
||||
processCounter += 1;
|
||||
const process = new FakeProcess(`p${processCounter}`, onPrompt, onCredential, events);
|
||||
processes.push(process);
|
||||
return process;
|
||||
});
|
||||
const leases: L = overrides.leases ?? (new FakeLeaseManager(events) as unknown as L);
|
||||
const store = overrides.store ?? new FakeStore();
|
||||
const secretWrites: RecordedSecretWrite[] = [];
|
||||
const completeCredential: SetupTokenSecretWriter =
|
||||
overrides.completeCredential ??
|
||||
(async (input) => {
|
||||
secretWrites.push({ scope: input.scope, sessionId: input.sessionId, token: input.token });
|
||||
// Simulate the atomic durable `stored` transition the production writer runs
|
||||
// inside the credential transaction, so the store reflects the claim.
|
||||
await store.markState(identityFromScope(input.scope, input.sessionId), "stored");
|
||||
});
|
||||
const logs: string[] = [];
|
||||
const service = new SetupTokenSessionService({
|
||||
factory,
|
||||
leases,
|
||||
store,
|
||||
completeCredential,
|
||||
rateLimiter: overrides.rateLimiter ?? allowAllRateLimiter(),
|
||||
caps: overrides.caps ?? { perOwner: 5, perAgent: 5, perCompany: 5 },
|
||||
ttlMs: overrides.ttlMs ?? 60_000,
|
||||
tokenRetentionMs: overrides.tokenRetentionMs,
|
||||
now: overrides.now,
|
||||
log: (line) => logs.push(line),
|
||||
});
|
||||
return { service, processes, leases, store, events };
|
||||
return { service, processes, leases, store, events, secretWrites, logs };
|
||||
}
|
||||
|
||||
describe("SetupTokenSessionService.start", () => {
|
||||
|
|
@ -206,6 +331,140 @@ describe("SetupTokenSessionService.start", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService.start cap reservation (concurrency)", () => {
|
||||
const capsPerOwnerOne = { perOwner: 1, perAgent: 5, perCompany: 5 };
|
||||
|
||||
it("reserves the owner slot synchronously, so a concurrent start fails closed with 429", async () => {
|
||||
const leases = new DeferredLeaseManager();
|
||||
const { service } = buildService({ leases, caps: capsPerOwnerOne });
|
||||
|
||||
// Start the first session. It reserves the owner slot synchronously, then it
|
||||
// suspends on the deferred lease acquire. The acquire reached the provider and
|
||||
// has not resolved yet.
|
||||
const firstStart = service.start(OWNER_SCOPE);
|
||||
await flush();
|
||||
expect(leases.acquireCalls).toBe(1);
|
||||
expect(leases.pendingCount()).toBe(1);
|
||||
|
||||
// The second start for the same owner runs while the first acquire is in
|
||||
// flight. The synchronous reservation already holds the one owner slot, so the
|
||||
// second start fails closed with the fixed 429 cap error before it reaches the
|
||||
// provider. Only one acquire ever reaches the provider.
|
||||
await expect(service.start(OWNER_SCOPE)).rejects.toMatchObject({
|
||||
status: 429,
|
||||
message: SETUP_TOKEN_CAP_EXCEEDED,
|
||||
});
|
||||
expect(leases.acquireCalls).toBe(1);
|
||||
|
||||
// Let the first start finish. It holds the one live session.
|
||||
leases.resolveNextAcquire();
|
||||
const first = await firstStart;
|
||||
expect(first.sessionId).toBeTruthy();
|
||||
expect(service.activeSessionCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("releases the owner reservation after cleanup, so the owner can start again", async () => {
|
||||
const leases = new DeferredLeaseManager();
|
||||
const { service } = buildService({ leases, caps: capsPerOwnerOne });
|
||||
|
||||
const firstStart = service.start(OWNER_SCOPE);
|
||||
await flush();
|
||||
leases.resolveNextAcquire();
|
||||
const first = await firstStart;
|
||||
|
||||
// Cancel the live session. The cleanup releases the owner reservation.
|
||||
await service.cancel(first.sessionId, OWNER_SCOPE);
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
|
||||
// A new start for the same owner reserves the slot again and starts, so the
|
||||
// cleanup released the reservation exactly once and left no slot held.
|
||||
const secondStart = service.start(OWNER_SCOPE);
|
||||
await flush();
|
||||
leases.resolveNextAcquire();
|
||||
const second = await secondStart;
|
||||
expect(second.sessionId).toBeTruthy();
|
||||
expect(second.sessionId).not.toBe(first.sessionId);
|
||||
expect(service.activeSessionCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("releases the owner reservation when the lease acquire fails, so a retry can start", async () => {
|
||||
const leases = new DeferredLeaseManager();
|
||||
leases.failNextAcquire = true;
|
||||
const { service } = buildService({ leases, caps: capsPerOwnerOne });
|
||||
|
||||
// The first start reserves the owner slot, then the lease acquire rejects. The
|
||||
// failure path rolls back the reservation and holds no durable state.
|
||||
await expect(service.start(OWNER_SCOPE)).rejects.toThrow("lease acquire failed");
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
expect(leases.released).toEqual([]);
|
||||
|
||||
// A retry for the same owner reserves the slot again and starts. The failed
|
||||
// start left no reservation behind.
|
||||
const retry = service.start(OWNER_SCOPE);
|
||||
await flush();
|
||||
expect(leases.acquireCalls).toBe(2);
|
||||
leases.resolveNextAcquire();
|
||||
const result = await retry;
|
||||
expect(result.sessionId).toBeTruthy();
|
||||
expect(service.activeSessionCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("releases the reservation and the lease when the durable record write fails", async () => {
|
||||
const leases = new FakeLeaseManager();
|
||||
const store = new FakeStore();
|
||||
let failRecord = true;
|
||||
const recordOriginal = store.record.bind(store);
|
||||
store.record = async (record) => {
|
||||
if (failRecord) {
|
||||
failRecord = false;
|
||||
throw new Error("durable write failed");
|
||||
}
|
||||
return recordOriginal(record);
|
||||
};
|
||||
const { service } = buildService({ leases, store, caps: capsPerOwnerOne });
|
||||
|
||||
// The durable write fails. The failure path releases the acquired lease and
|
||||
// rolls back the reservation.
|
||||
await expect(service.start(OWNER_SCOPE)).rejects.toThrow("durable write failed");
|
||||
expect(leases.released).toEqual(["lease-1"]);
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
|
||||
// The reservation rolled back, so a retry for the same owner starts.
|
||||
const retry = await service.start(OWNER_SCOPE);
|
||||
expect(retry.sessionId).toBeTruthy();
|
||||
expect(service.activeSessionCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("releases the reservation and the lease when the factory throws, so a retry can start", async () => {
|
||||
const leases = new FakeLeaseManager();
|
||||
let failFactory = true;
|
||||
const factory: SetupTokenLoginProcessFactory = () => {
|
||||
if (failFactory) {
|
||||
failFactory = false;
|
||||
throw new Error("factory failed");
|
||||
}
|
||||
// A minimal live process for the retry. It never ends on its own.
|
||||
return {
|
||||
done: new Promise<SetupTokenLoginOutcome>(() => {}),
|
||||
submitCode: () => {},
|
||||
stop: () => {},
|
||||
};
|
||||
};
|
||||
const { service } = buildService({ leases, factory, caps: capsPerOwnerOne });
|
||||
|
||||
// The factory throws. The start releases the lease, drops the durable record,
|
||||
// rolls back the reservation, and returns the fixed 503 start error.
|
||||
await expect(service.start(OWNER_SCOPE)).rejects.toMatchObject({ status: 503 });
|
||||
expect(leases.released).toEqual(["lease-1"]);
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
|
||||
// The reservation rolled back, so a retry for the same owner starts.
|
||||
const retry = await service.start(OWNER_SCOPE);
|
||||
expect(retry.sessionId).toBeTruthy();
|
||||
expect(service.activeSessionCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService.readPrompt", () => {
|
||||
it("returns the full login URL to the authorized owner once the prompt surfaces", async () => {
|
||||
const { service, processes } = buildService();
|
||||
|
|
@ -248,16 +507,26 @@ 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" };
|
||||
const otherAdapter: SetupTokenSessionScope = { ...OWNER_SCOPE, adapterType: "codex_local" };
|
||||
const otherEnvironment: SetupTokenSessionScope = { ...OWNER_SCOPE, environmentId: "env-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]) {
|
||||
// The full-scope match rejects a mismatch in any of the five scope fields:
|
||||
// the company, the owner, the agent, the adapter, and the environment.
|
||||
for (const scope of [
|
||||
crossCompany,
|
||||
sameCompanyOtherUser,
|
||||
otherAgent,
|
||||
otherAdapter,
|
||||
otherEnvironment,
|
||||
]) {
|
||||
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);
|
||||
expect(() => service.completeSession(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);
|
||||
}
|
||||
|
|
@ -269,6 +538,57 @@ describe("SetupTokenSessionService authorization boundary", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService company-and-environment scope", () => {
|
||||
// The agentless company scope. It carries a null agent id.
|
||||
const COMPANY_SCOPE: SetupTokenSessionScope = { ...OWNER_SCOPE, targetAgentId: null };
|
||||
const companyKey = {
|
||||
companyId: COMPANY_SCOPE.companyId,
|
||||
ownerUserId: COMPANY_SCOPE.ownerUserId,
|
||||
adapterType: COMPANY_SCOPE.adapterType,
|
||||
};
|
||||
|
||||
it("resolves an agentless session by the company key and returns the intrinsic environment", async () => {
|
||||
const { service, processes } = buildService();
|
||||
const { sessionId } = await service.start(COMPANY_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
|
||||
const scope = service.resolveCompanyScope(sessionId, companyKey);
|
||||
expect(scope.targetAgentId).toBeNull();
|
||||
expect(scope.environmentId).toBe(COMPANY_SCOPE.environmentId);
|
||||
|
||||
const descriptor = service.describeOwned(sessionId, scope);
|
||||
expect(descriptor.sessionId).toBe(sessionId);
|
||||
expect(descriptor.environmentId).toBe(COMPANY_SCOPE.environmentId);
|
||||
expect(descriptor.loginUrl).toBe(FULL_LOGIN_URL);
|
||||
});
|
||||
|
||||
it("rejects an agent-scoped session through the company key", async () => {
|
||||
const { service } = buildService();
|
||||
// The session carries a non-null agent id. The company key requires the
|
||||
// agentless marker, so a company route cannot reach an agent session.
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
expect(() => service.resolveCompanyScope(sessionId, companyKey)).toThrow(
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the same not-found for a foreign company key", async () => {
|
||||
const { service } = buildService();
|
||||
const { sessionId } = await service.start(COMPANY_SCOPE);
|
||||
// A cross-company, a cross-owner, and a cross-adapter key each return the
|
||||
// same not-found error as a missing session.
|
||||
for (const key of [
|
||||
{ ...companyKey, companyId: "company-2" },
|
||||
{ ...companyKey, ownerUserId: "user-9" },
|
||||
{ ...companyKey, adapterType: "codex_local" },
|
||||
]) {
|
||||
expect(() => service.resolveCompanyScope(sessionId, key)).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[] = [];
|
||||
|
|
@ -313,10 +633,12 @@ describe("SetupTokenSessionService durable reaper", () => {
|
|||
sessionId: "orphan-1",
|
||||
companyId: "company-1",
|
||||
ownerUserId: "user-1",
|
||||
targetAgentId: "agent-1",
|
||||
adapterType: "claude_local",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-orphan",
|
||||
deadline: 1_000,
|
||||
state: "awaiting_code",
|
||||
boundAt: null,
|
||||
});
|
||||
const { service } = buildService({ store, leases, now: () => 5_000 });
|
||||
const summary = await service.reap(5_000);
|
||||
|
|
@ -331,10 +653,12 @@ describe("SetupTokenSessionService durable reaper", () => {
|
|||
sessionId: "orphan-2",
|
||||
companyId: "company-1",
|
||||
ownerUserId: "user-1",
|
||||
targetAgentId: "agent-1",
|
||||
adapterType: "claude_local",
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-orphan-2",
|
||||
deadline: 1_000,
|
||||
state: "failed",
|
||||
boundAt: null,
|
||||
});
|
||||
const leases = new FakeLeaseManager();
|
||||
leases.releaseById = async () => {
|
||||
|
|
@ -347,43 +671,228 @@ describe("SetupTokenSessionService durable reaper", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService.receiveToken", () => {
|
||||
it("returns the token once to the authorized owner after a successful login", async () => {
|
||||
const { service, processes, leases } = buildService();
|
||||
describe("SetupTokenSessionService.completeSession", () => {
|
||||
it("returns the non-secret storedSessionId after a successful secret write, and no token", async () => {
|
||||
const { service, processes, leases, store, secretWrites, logs } = 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);
|
||||
// The session awaits the owner-bound secret write before it completes.
|
||||
await 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.
|
||||
|
||||
// The token reached the owner-bound writer, once, with the owner scope.
|
||||
expect(secretWrites).toEqual([{ scope: OWNER_SCOPE, sessionId, token: SYNTH_TOKEN }]);
|
||||
// The service releases the sandbox lease at once.
|
||||
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);
|
||||
// The durable row stays as the stored-session claim, not removed.
|
||||
expect(store.rows.get(sessionId)?.state).toBe("stored");
|
||||
|
||||
// The completion returns the non-secret storedSessionId and carries no token.
|
||||
const completion = service.completeSession(sessionId, OWNER_SCOPE);
|
||||
expect(completion.storedSessionId).toBe(sessionId);
|
||||
expect(completion).not.toHaveProperty("token");
|
||||
|
||||
// No response, log, or durable record contains the token after success.
|
||||
const haystack = `${logs.join("\n")}\n${JSON.stringify(completion)}\n${JSON.stringify([
|
||||
...store.rows.values(),
|
||||
])}`;
|
||||
expect(haystack).not.toContain(SYNTH_TOKEN);
|
||||
});
|
||||
|
||||
it("returns the fixed unavailable error before the token is delivered", async () => {
|
||||
it("returns the fixed unavailable error before the secret write completes", 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);
|
||||
// The secret write has not run, so the completion is unavailable.
|
||||
expect(() => service.completeSession(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_TOKEN_UNAVAILABLE);
|
||||
});
|
||||
|
||||
it("purges the retained token when the retention window ends", async () => {
|
||||
it("reaches failed with no binding and no completion when the secret write errors", async () => {
|
||||
const store = new FakeStore();
|
||||
const completeCredential: SetupTokenSecretWriter = async () => {
|
||||
throw new Error("storage down");
|
||||
};
|
||||
const { service, processes, leases, logs } = buildService({ store, completeCredential });
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
service.submitCode(sessionId, OWNER_SCOPE, "code");
|
||||
|
||||
// The sink rejects with the fixed, non-secret storage error. It does not
|
||||
// swallow the failure.
|
||||
await expect(processes[0].surfaceCredential(SYNTH_TOKEN)).rejects.toMatchObject({
|
||||
message: SETUP_TOKEN_STORAGE_FAILED,
|
||||
});
|
||||
// Let the terminal-state handler run.
|
||||
processes[0].finish("failure");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// The session failed: it holds no live session and released the lease.
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
expect(leases.released).toEqual(["lease-1"]);
|
||||
// No stored-session claim exists: the durable row never reached stored.
|
||||
expect(store.rows.has(sessionId)).toBe(false);
|
||||
// The completion is gone, so a read returns the same not-found error.
|
||||
expect(() => service.completeSession(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
// No log line contains the token after the failure.
|
||||
expect(logs.join("\n")).not.toContain(SYNTH_TOKEN);
|
||||
});
|
||||
|
||||
it("purges the retained completion 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);
|
||||
await 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);
|
||||
// The retention timer dropped the in-memory session, so a read is not found.
|
||||
expect(() => service.completeSession(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
// A deferred owner-bound secret writer. The test controls when the write
|
||||
// settles, so it can hold the write in flight and drive a cancel or an expiry
|
||||
// into the exact window that used to race the write. It records only the
|
||||
// non-secret ids and the token, and it counts how many times the writer started.
|
||||
function buildDeferredWriter(store: FakeStore) {
|
||||
const calls: RecordedSecretWrite[] = [];
|
||||
let started = 0;
|
||||
let pendingInput: { scope: SetupTokenSessionScope; sessionId: string } | null = null;
|
||||
let resolveWrite: (() => void) | null = null;
|
||||
let rejectWrite: ((error: Error) => void) | null = null;
|
||||
const writer: SetupTokenSecretWriter = (input) => {
|
||||
started += 1;
|
||||
pendingInput = { scope: input.scope, sessionId: input.sessionId };
|
||||
calls.push({ scope: input.scope, sessionId: input.sessionId, token: input.token });
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
resolveWrite = resolve;
|
||||
rejectWrite = reject;
|
||||
});
|
||||
};
|
||||
return {
|
||||
writer,
|
||||
calls,
|
||||
startedCount: () => started,
|
||||
// The commit resolves the write. It first simulates the atomic durable
|
||||
// `stored` transition the production writer runs inside the same transaction.
|
||||
resolve: async () => {
|
||||
if (pendingInput) {
|
||||
await store.markState(identityFromScope(pendingInput.scope, pendingInput.sessionId), "stored");
|
||||
}
|
||||
resolveWrite?.();
|
||||
},
|
||||
reject: () => rejectWrite?.(new Error("storage down")),
|
||||
};
|
||||
}
|
||||
|
||||
// Yields to the microtask queue, so a scheduled sink or lock step can run.
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe("SetupTokenSessionService terminal-race credential write", () => {
|
||||
for (const variant of [
|
||||
{ label: "cancel", run: (service: SetupTokenSessionService, sessionId: string) => service.cancel(sessionId, OWNER_SCOPE), terminalState: "cancelled" as const },
|
||||
{ label: "expiry", run: (service: SetupTokenSessionService, sessionId: string) => service.expire(sessionId, OWNER_SCOPE), terminalState: "timed_out" as const },
|
||||
]) {
|
||||
it(`writes no secret when a ${variant.label} wins the race, and leaves no stored claim`, async () => {
|
||||
const store = new FakeStore();
|
||||
const deferred = buildDeferredWriter(store);
|
||||
const { service, processes, leases } = buildService({ store, completeCredential: deferred.writer });
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
service.submitCode(sessionId, OWNER_SCOPE, "code");
|
||||
|
||||
// The terminal transition wins: it reaches the terminal state before the
|
||||
// credential arrives. The cleanup removes the durable row.
|
||||
await variant.run(service, sessionId);
|
||||
expect(store.rows.has(sessionId)).toBe(false);
|
||||
|
||||
// The credential arrives after the terminal transition. The sink fails
|
||||
// closed: it never calls the writer and it rejects with the fixed,
|
||||
// non-secret error.
|
||||
await expect(processes[0].surfaceCredential(SYNTH_TOKEN)).rejects.toMatchObject({
|
||||
message: SETUP_TOKEN_STORAGE_FAILED,
|
||||
});
|
||||
|
||||
// The writer never committed, so no unintended secret write happened.
|
||||
expect(deferred.startedCount()).toBe(0);
|
||||
expect(deferred.calls).toHaveLength(0);
|
||||
// The durable row holds no stored claim.
|
||||
expect(store.rows.has(sessionId)).toBe(false);
|
||||
// The completion has no success: a read returns the same not-found error.
|
||||
expect(() => service.completeSession(sessionId, OWNER_SCOPE)).toThrow(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
// The session released its lease and holds no live session.
|
||||
expect(leases.released).toEqual(["lease-1"]);
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it(`does not erase the stored claim when the write wins the race, then a ${variant.label} arrives`, async () => {
|
||||
const store = new FakeStore();
|
||||
const deferred = buildDeferredWriter(store);
|
||||
const { service, processes, leases } = buildService({ store, completeCredential: deferred.writer });
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
service.submitCode(sessionId, OWNER_SCOPE, "code");
|
||||
|
||||
// Start the credential write. It is in flight and holds the session lock, so
|
||||
// a terminal transition that arrives now must wait for the write to settle.
|
||||
const credentialSettled = processes[0].surfaceCredential(SYNTH_TOKEN);
|
||||
await flush();
|
||||
expect(deferred.startedCount()).toBe(1);
|
||||
|
||||
// The terminal transition arrives while the write is in flight. It blocks on
|
||||
// the lock; it does not interleave with the write.
|
||||
const terminalSettled = variant.run(service, sessionId);
|
||||
await flush();
|
||||
|
||||
// The write wins the serialized ordering: it commits, then the service
|
||||
// records the non-secret markers and releases the lock.
|
||||
await deferred.resolve();
|
||||
await credentialSettled;
|
||||
await terminalSettled;
|
||||
|
||||
// The writer committed exactly once. No duplicate write is possible.
|
||||
expect(deferred.startedCount()).toBe(1);
|
||||
expect(deferred.calls).toEqual([{ scope: OWNER_SCOPE, sessionId, token: SYNTH_TOKEN }]);
|
||||
// The terminal transition did not erase the claim: the durable row stays
|
||||
// `stored` and the terminal API reports the completed state.
|
||||
expect(store.rows.get(sessionId)?.state).toBe("stored");
|
||||
const completion = service.completeSession(sessionId, OWNER_SCOPE);
|
||||
expect(completion.storedSessionId).toBe(sessionId);
|
||||
// The service released the sandbox lease at once.
|
||||
expect(leases.released).toEqual(["lease-1"]);
|
||||
});
|
||||
}
|
||||
|
||||
it("does not erase the claim when the process reports success after the write wins a cancel race", async () => {
|
||||
// This proves the natural success path stays intact when a cancel loses the
|
||||
// race: the write commits, the cancel completes the session, and the later
|
||||
// process-done success transition is an idempotent no-op.
|
||||
const store = new FakeStore();
|
||||
const deferred = buildDeferredWriter(store);
|
||||
const { service, processes } = buildService({ store, completeCredential: deferred.writer });
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
service.submitCode(sessionId, OWNER_SCOPE, "code");
|
||||
|
||||
const credentialSettled = processes[0].surfaceCredential(SYNTH_TOKEN);
|
||||
await flush();
|
||||
const cancelSettled = service.cancel(sessionId, OWNER_SCOPE);
|
||||
await flush();
|
||||
await deferred.resolve();
|
||||
await credentialSettled;
|
||||
await cancelSettled;
|
||||
|
||||
// The process reports success after the sink resolved. The transition finds a
|
||||
// completed, cleaned-up session and does nothing.
|
||||
processes[0].finish("success");
|
||||
await flush();
|
||||
|
||||
expect(deferred.startedCount()).toBe(1);
|
||||
expect(store.rows.get(sessionId)?.state).toBe("stored");
|
||||
expect(service.completeSession(sessionId, OWNER_SCOPE).storedSessionId).toBe(sessionId);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -394,7 +903,7 @@ describe("no secret reaches a sink (SR-1, SR-5)", () => {
|
|||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
service.submitCode(sessionId, OWNER_SCOPE, "SECRETCODE123");
|
||||
processes[0].surfaceCredential(SYNTH_TOKEN);
|
||||
await processes[0].surfaceCredential(SYNTH_TOKEN);
|
||||
const serialized = JSON.stringify([...store.rows.values()]);
|
||||
expect(serialized).not.toContain("SECRETCODE123");
|
||||
expect(serialized).not.toContain("STATEVALUE");
|
||||
|
|
@ -545,3 +1054,255 @@ describe("confidential transport guard (SR-6, SR-7)", () => {
|
|||
expect(decision.allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService durable state scope", () => {
|
||||
it("marks the durable record state by the full owner scope", async () => {
|
||||
const store = new FakeStore();
|
||||
const { service, processes } = buildService({ store });
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
processes[0].surfacePrompt(FULL_LOGIN_URL);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(store.rows.get(sessionId)?.state).toBe("awaiting_code");
|
||||
|
||||
// A foreign-scope mark leaves the row unchanged, so a write never updates a
|
||||
// row by the session id alone.
|
||||
await store.markState(
|
||||
{
|
||||
sessionId,
|
||||
companyId: OWNER_SCOPE.companyId,
|
||||
ownerUserId: "intruder",
|
||||
adapterType: OWNER_SCOPE.adapterType,
|
||||
environmentId: OWNER_SCOPE.environmentId,
|
||||
},
|
||||
"submitting",
|
||||
);
|
||||
expect(store.rows.get(sessionId)?.state).toBe("awaiting_code");
|
||||
});
|
||||
});
|
||||
|
||||
// --- The durable, database-backed cleanup store ------------------------------
|
||||
|
||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping durable setup-token cleanup store tests on this host: ${
|
||||
embeddedPostgresSupport.reason ?? "unsupported environment"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)", () => {
|
||||
let stopDb: (() => Promise<void>) | undefined;
|
||||
let connectionString!: string;
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const started = await startEmbeddedPostgresTestDatabase("claude-setup-token-store");
|
||||
stopDb = started.cleanup;
|
||||
connectionString = started.connectionString;
|
||||
db = createDb(connectionString);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(claudeSetupTokenSessions);
|
||||
await db.delete(environments);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await stopDb?.();
|
||||
});
|
||||
|
||||
// Seed a company and an environment, then return one fresh record identity.
|
||||
async function seedScope(): Promise<SetupTokenCleanupIdentity> {
|
||||
const companyId = randomUUID();
|
||||
const environmentId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Acme",
|
||||
status: "active",
|
||||
// A unique issue prefix per company; the column has a unique index.
|
||||
issuePrefix: companyId.slice(0, 8),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
await db.insert(environments).values({
|
||||
id: environmentId,
|
||||
name: `sandbox-${environmentId.slice(0, 8)}`,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: "fake" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
return {
|
||||
sessionId: randomUUID(),
|
||||
companyId,
|
||||
ownerUserId: `user-${randomUUID().slice(0, 8)}`,
|
||||
adapterType: "claude_local",
|
||||
environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
async function insertRecord(
|
||||
identity: SetupTokenCleanupIdentity,
|
||||
state: SetupTokenCleanupRecord["state"],
|
||||
deadlineMs: number,
|
||||
): Promise<void> {
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
await store.record({
|
||||
...identity,
|
||||
leaseId: "lease-1",
|
||||
deadline: deadlineMs,
|
||||
state,
|
||||
boundAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function readRow(sessionId: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(claudeSetupTokenSessions)
|
||||
.where(eq(claudeSetupTokenSessions.sessionId, sessionId));
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
it("records the non-secret cleanup record and reads it back", async () => {
|
||||
const identity = await seedScope();
|
||||
await insertRecord(identity, "starting", Date.now() + 60_000);
|
||||
const row = await readRow(identity.sessionId);
|
||||
expect(row?.companyId).toBe(identity.companyId);
|
||||
expect(row?.ownerUserId).toBe(identity.ownerUserId);
|
||||
expect(row?.adapterType).toBe(identity.adapterType);
|
||||
expect(row?.environmentId).toBe(identity.environmentId);
|
||||
expect(row?.state).toBe("starting");
|
||||
expect(row?.boundAt).toBeNull();
|
||||
});
|
||||
|
||||
it("consumes a stored claim once with one conditional write", async () => {
|
||||
const identity = await seedScope();
|
||||
await insertRecord(identity, "stored", Date.now() + 60_000);
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
|
||||
const first = await store.consumeStoredClaim(identity);
|
||||
expect(first).not.toBeNull();
|
||||
expect(first?.sessionId).toBe(identity.sessionId);
|
||||
expect(first?.boundAt).not.toBeNull();
|
||||
expect((await readRow(identity.sessionId))?.boundAt).not.toBeNull();
|
||||
|
||||
// A second consume finds the claim already consumed and returns no row.
|
||||
const second = await store.consumeStoredClaim(identity);
|
||||
expect(second).toBeNull();
|
||||
});
|
||||
|
||||
it("returns no row for a foreign-scope consume and leaves the claim unconsumed", async () => {
|
||||
const identity = await seedScope();
|
||||
await insertRecord(identity, "stored", Date.now() + 60_000);
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
|
||||
const foreign = await store.consumeStoredClaim({ ...identity, ownerUserId: "intruder" });
|
||||
expect(foreign).toBeNull();
|
||||
expect((await readRow(identity.sessionId))?.boundAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns no row for an expired claim", async () => {
|
||||
const identity = await seedScope();
|
||||
await insertRecord(identity, "stored", Date.now() - 1_000);
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
|
||||
const consumed = await store.consumeStoredClaim(identity);
|
||||
expect(consumed).toBeNull();
|
||||
expect((await readRow(identity.sessionId))?.boundAt).toBeNull();
|
||||
});
|
||||
|
||||
it("returns no row for a non-stored claim", async () => {
|
||||
const identity = await seedScope();
|
||||
await insertRecord(identity, "submitting", Date.now() + 60_000);
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
|
||||
const consumed = await store.consumeStoredClaim(identity);
|
||||
expect(consumed).toBeNull();
|
||||
expect((await readRow(identity.sessionId))?.boundAt).toBeNull();
|
||||
});
|
||||
|
||||
it("lists terminal, expired, and consumed records but not a live stored claim", async () => {
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
const now = Date.now();
|
||||
|
||||
const terminal = await seedScope();
|
||||
await insertRecord(terminal, "failed", now + 60_000);
|
||||
|
||||
const expired = await seedScope();
|
||||
await insertRecord(expired, "awaiting_code", now - 1_000);
|
||||
|
||||
const consumed = await seedScope();
|
||||
await insertRecord(consumed, "stored", now + 60_000);
|
||||
await store.consumeStoredClaim(consumed);
|
||||
|
||||
const liveStored = await seedScope();
|
||||
await insertRecord(liveStored, "stored", now + 60_000);
|
||||
|
||||
const reapable = await store.listReapable(now);
|
||||
const ids = new Set(reapable.map((record) => record.sessionId));
|
||||
expect(ids.has(terminal.sessionId)).toBe(true);
|
||||
expect(ids.has(expired.sessionId)).toBe(true);
|
||||
expect(ids.has(consumed.sessionId)).toBe(true);
|
||||
// A live, unexpired, unconsumed stored claim is not reapable.
|
||||
expect(ids.has(liveStored.sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns no row when the claim row lock holds until after the deadline", async () => {
|
||||
// This test proves the consume uses `clock_timestamp()`, not
|
||||
// `transaction_timestamp()`. The row starts with a far-future deadline, so
|
||||
// the consume treats it as a valid candidate and waits for the row lock. A
|
||||
// second connection holds the lock, sets a near deadline inside the locked
|
||||
// transaction, and commits after that deadline passes. The consume then
|
||||
// re-checks the predicate against the committed row with the current
|
||||
// database time, so the expired claim returns no row. A
|
||||
// `transaction_timestamp()` predicate would use the stale start time and
|
||||
// wrongly consume the claim.
|
||||
const identity = await seedScope();
|
||||
await insertRecord(identity, "stored", Date.now() + 3_600_000);
|
||||
const store = createDbSetupTokenCleanupStore(db);
|
||||
|
||||
const lockDb = createDb(connectionString);
|
||||
let signalLocked!: () => void;
|
||||
const locked = new Promise<void>((resolve) => {
|
||||
signalLocked = resolve;
|
||||
});
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
const lockHeld = lockDb.transaction(async (tx) => {
|
||||
await tx.execute(
|
||||
sql`SELECT bound_at FROM claude_setup_token_sessions WHERE session_id = ${identity.sessionId} FOR UPDATE`,
|
||||
);
|
||||
// Set a near deadline inside the locked transaction. The consume cannot see
|
||||
// this value until the transaction commits.
|
||||
await tx.execute(
|
||||
sql`UPDATE claude_setup_token_sessions SET deadline_at = clock_timestamp() + interval '300 milliseconds' WHERE session_id = ${identity.sessionId}`,
|
||||
);
|
||||
signalLocked();
|
||||
await gate;
|
||||
});
|
||||
|
||||
// Wait until the lock is truly held, then fire the consume. The consume
|
||||
// blocks on the row lock; its initial scan sees the far-future deadline.
|
||||
await locked;
|
||||
const consumePromise = store.consumeStoredClaim(identity);
|
||||
// Hold the lock until the near deadline passes, then commit.
|
||||
await delay(600);
|
||||
releaseGate();
|
||||
await lockHeld;
|
||||
|
||||
const consumed = await consumePromise;
|
||||
expect(consumed).toBeNull();
|
||||
expect((await readRow(identity.sessionId))?.boundAt).toBeNull();
|
||||
await lockDb.$client.end();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,892 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
buildClaudeOAuthWriteInput,
|
||||
buildSetupTokenLoginTransport,
|
||||
createProductionSetupTokenSandboxProvider,
|
||||
createSetupTokenSecretWriter,
|
||||
createWorkerBoundSetupTokenPtyOpener,
|
||||
type SetupTokenSandboxProvider,
|
||||
} from "./setup-token-transport-binding.js";
|
||||
import {
|
||||
SetupTokenSessionService,
|
||||
isTerminalSessionState,
|
||||
type SetupTokenCleanupIdentity,
|
||||
type SetupTokenCleanupRecord,
|
||||
type SetupTokenCleanupStore,
|
||||
type SetupTokenLoginProcess,
|
||||
type SetupTokenSessionScope,
|
||||
type SetupTokenSessionState,
|
||||
} from "./setup-token-session.js";
|
||||
import type { SetupTokenPtySessionOpener } from "@paperclipai/adapter-utils/setup-token-transport";
|
||||
import {
|
||||
CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
SETUP_TOKEN_AFTER_ANCHOR,
|
||||
SETUP_TOKEN_BEFORE_ANCHOR,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
// The owner scope for one login session. The per-owner session cap is one, so one
|
||||
// scope holds one live session.
|
||||
const SCOPE: SetupTokenSessionScope = {
|
||||
companyId: "company-1",
|
||||
ownerUserId: "user-1",
|
||||
targetAgentId: null,
|
||||
adapterType: "claude_local",
|
||||
environmentId: "env-1",
|
||||
};
|
||||
|
||||
/** Waits for the pending microtasks and macrotasks to settle. */
|
||||
function flush(): Promise<void> {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
/**
|
||||
* A controllable fake sandbox. It records the opened command and every released
|
||||
* lease id. It drives the login process to a terminal outcome through the exit
|
||||
* promise, so a test simulates a process failure, a timeout, or a cancel.
|
||||
*/
|
||||
function createFakeSandbox() {
|
||||
const openedCommands: string[] = [];
|
||||
const releasedLeaseIds: string[] = [];
|
||||
let acquireCount = 0;
|
||||
let killed = false;
|
||||
let resolveExit: (value: { exitCode: number | null }) => void = () => {};
|
||||
const exit = new Promise<{ exitCode: number | null }>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
|
||||
const openPtySession: SetupTokenPtySessionOpener = async (command) => {
|
||||
openedCommands.push(command);
|
||||
return {
|
||||
onData(): void {},
|
||||
write(): void {},
|
||||
wait: () => exit,
|
||||
kill(): void {
|
||||
killed = true;
|
||||
resolveExit({ exitCode: 137 });
|
||||
},
|
||||
async close(): Promise<void> {},
|
||||
};
|
||||
};
|
||||
|
||||
const provider: SetupTokenSandboxProvider = {
|
||||
async acquire() {
|
||||
acquireCount += 1;
|
||||
return { leaseId: `lease-${acquireCount}`, openPtySession };
|
||||
},
|
||||
async release(leaseId) {
|
||||
releasedLeaseIds.push(leaseId);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
openedCommands,
|
||||
releasedLeaseIds,
|
||||
get acquireCount() {
|
||||
return acquireCount;
|
||||
},
|
||||
get killed() {
|
||||
return killed;
|
||||
},
|
||||
finishProcess(exitCode: number | null): void {
|
||||
resolveExit({ exitCode });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory cleanup store that records every write. It stands in for the
|
||||
* durable database-backed store, so a test asserts the service runs its cleanup
|
||||
* against the injected store.
|
||||
*/
|
||||
function createRecordingStore() {
|
||||
const rows = new Map<string, SetupTokenCleanupRecord>();
|
||||
const removed: string[] = [];
|
||||
const stateWrites: Array<{ sessionId: string; state: SetupTokenSessionState }> = [];
|
||||
let reapable: SetupTokenCleanupRecord[] = [];
|
||||
|
||||
const store: SetupTokenCleanupStore = {
|
||||
async record(record) {
|
||||
rows.set(record.sessionId, { ...record });
|
||||
},
|
||||
async markState(identity: SetupTokenCleanupIdentity, state) {
|
||||
stateWrites.push({ sessionId: identity.sessionId, state });
|
||||
const row = rows.get(identity.sessionId);
|
||||
if (row) row.state = state;
|
||||
},
|
||||
async remove(identity) {
|
||||
removed.push(identity.sessionId);
|
||||
rows.delete(identity.sessionId);
|
||||
},
|
||||
async listReapable() {
|
||||
return reapable;
|
||||
},
|
||||
async consumeStoredClaim() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
store,
|
||||
rows,
|
||||
removed,
|
||||
stateWrites,
|
||||
setReapable(records: SetupTokenCleanupRecord[]): void {
|
||||
reapable = records;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A permissive rate limiter, so the start path never rate-limits in a test. */
|
||||
const allowAllRateLimiter = { consume: () => ({ allowed: true, retryAfterSeconds: 0 }) };
|
||||
|
||||
function buildService(input: {
|
||||
sandbox: SetupTokenSandboxProvider;
|
||||
store: SetupTokenCleanupStore;
|
||||
ttlMs?: number;
|
||||
}) {
|
||||
const transport = buildSetupTokenLoginTransport({
|
||||
sandbox: input.sandbox,
|
||||
store: input.store,
|
||||
});
|
||||
const service = new SetupTokenSessionService({
|
||||
factory: transport.factory,
|
||||
leases: transport.leases,
|
||||
store: transport.store,
|
||||
// The secret write stays a fail-closed default here. This phase does not bind
|
||||
// the owner-bound secret writer.
|
||||
completeCredential: async () => {
|
||||
throw new Error("The credential write is not bound in this phase.");
|
||||
},
|
||||
rateLimiter: allowAllRateLimiter,
|
||||
ttlMs: input.ttlMs ?? 5 * 60_000,
|
||||
});
|
||||
return { service, transport };
|
||||
}
|
||||
|
||||
describe("setup-token production transport binding", () => {
|
||||
it("creates a live session with the transport bound and opens only the fixed command", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: sandbox.provider, store: store.store });
|
||||
|
||||
const started = await service.start(SCOPE);
|
||||
await flush();
|
||||
|
||||
// The start returned a session id instead of the fixed 503.
|
||||
expect(typeof started.sessionId).toBe("string");
|
||||
expect(started.sessionId.length).toBeGreaterThan(0);
|
||||
// The lease manager acquired one production lease.
|
||||
expect(sandbox.acquireCount).toBe(1);
|
||||
// The factory opened the pseudo-terminal with only the fixed command.
|
||||
expect(sandbox.openedCommands).toEqual([CLAUDE_SETUP_TOKEN_COMMAND]);
|
||||
// The service persisted the non-secret cleanup record at start.
|
||||
expect(store.rows.get(started.sessionId)?.leaseId).toBe("lease-1");
|
||||
|
||||
await service.shutdown();
|
||||
});
|
||||
|
||||
it("releases the lease and cleans the durable store on a process failure", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: sandbox.provider, store: store.store });
|
||||
|
||||
const started = await service.start(SCOPE);
|
||||
await flush();
|
||||
|
||||
// The login process ends with a non-zero exit code.
|
||||
sandbox.finishProcess(1);
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
expect(sandbox.releasedLeaseIds).toEqual(["lease-1"]);
|
||||
expect(store.removed).toContain(started.sessionId);
|
||||
expect(store.rows.has(started.sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the lease and cleans the durable store on a cancel", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: sandbox.provider, store: store.store });
|
||||
|
||||
const started = await service.start(SCOPE);
|
||||
await flush();
|
||||
|
||||
await service.cancel(started.sessionId, SCOPE);
|
||||
await flush();
|
||||
|
||||
expect(sandbox.releasedLeaseIds).toEqual(["lease-1"]);
|
||||
expect(sandbox.killed).toBe(true);
|
||||
expect(store.removed).toContain(started.sessionId);
|
||||
});
|
||||
|
||||
it("releases the lease and cleans the durable store on a timeout expiry", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: sandbox.provider, store: store.store });
|
||||
|
||||
const started = await service.start(SCOPE);
|
||||
await flush();
|
||||
|
||||
await service.expire(started.sessionId, SCOPE);
|
||||
await flush();
|
||||
|
||||
expect(sandbox.releasedLeaseIds).toEqual(["lease-1"]);
|
||||
expect(store.removed).toContain(started.sessionId);
|
||||
});
|
||||
|
||||
it("cancels every live session and releases the lease on shutdown", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: sandbox.provider, store: store.store });
|
||||
|
||||
const started = await service.start(SCOPE);
|
||||
await flush();
|
||||
|
||||
await service.shutdown();
|
||||
await flush();
|
||||
|
||||
expect(sandbox.releasedLeaseIds).toEqual(["lease-1"]);
|
||||
expect(store.removed).toContain(started.sessionId);
|
||||
expect(service.activeSessionCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores a runtime-supplied command and always opens the fixed command", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
// A caller forces an alternate command through the dependency object. The
|
||||
// dependency type carries no command field, so this cast simulates a
|
||||
// runtime-supplied value from a route, a body, or a configuration. The
|
||||
// binding must ignore it and open only the fixed command.
|
||||
const deps = {
|
||||
sandbox: sandbox.provider,
|
||||
store: store.store,
|
||||
command: "echo pwned",
|
||||
} as unknown as Parameters<typeof buildSetupTokenLoginTransport>[0];
|
||||
const transport = buildSetupTokenLoginTransport(deps);
|
||||
const service = new SetupTokenSessionService({
|
||||
factory: transport.factory,
|
||||
leases: transport.leases,
|
||||
store: transport.store,
|
||||
completeCredential: async () => {
|
||||
throw new Error("The credential write is not bound in this phase.");
|
||||
},
|
||||
rateLimiter: allowAllRateLimiter,
|
||||
ttlMs: 5 * 60_000,
|
||||
});
|
||||
|
||||
const started = await service.start(SCOPE);
|
||||
await flush();
|
||||
|
||||
// The factory opened the pseudo-terminal with only the fixed command. The
|
||||
// supplied alternate value never reached the command.
|
||||
expect(sandbox.openedCommands).toEqual([CLAUDE_SETUP_TOKEN_COMMAND]);
|
||||
expect(sandbox.openedCommands).not.toContain("echo pwned");
|
||||
|
||||
void started;
|
||||
await service.shutdown();
|
||||
});
|
||||
|
||||
it("keeps the durable cleanup record reapable when a restart release fails", async () => {
|
||||
// The production provider drives a restart release by id. The provider driver
|
||||
// release rejects, so the reaper must keep the durable cleanup record.
|
||||
const releaseRunLease = vi.fn(async () => {
|
||||
throw new Error("remote release failed");
|
||||
});
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: {
|
||||
getById: async () => ({ id: "env-1", driver: "sandbox", status: "active" }) as never,
|
||||
getLeaseById: async () => ({ id: "lease-orphan", environmentId: "env-1" }) as never,
|
||||
releaseLease: async () => ({}) as never,
|
||||
},
|
||||
environmentRuntime: {
|
||||
acquireRunLease: vi.fn(),
|
||||
getDriver: () => ({ releaseRunLease }) as never,
|
||||
},
|
||||
openLivePtySession: async () => {
|
||||
throw new Error("must not open a pty for a restart release");
|
||||
},
|
||||
});
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: provider, store: store.store });
|
||||
|
||||
// A crash left a durable record whose lease the reaper must free. The
|
||||
// in-memory lease map is empty, as after a restart.
|
||||
const orphan: SetupTokenCleanupRecord = {
|
||||
sessionId: "orphan-session",
|
||||
companyId: SCOPE.companyId,
|
||||
ownerUserId: SCOPE.ownerUserId,
|
||||
adapterType: SCOPE.adapterType,
|
||||
environmentId: SCOPE.environmentId,
|
||||
leaseId: "lease-orphan",
|
||||
deadline: 0,
|
||||
state: "failed",
|
||||
boundAt: null,
|
||||
};
|
||||
store.rows.set(orphan.sessionId, { ...orphan });
|
||||
store.setReapable([orphan]);
|
||||
|
||||
const result = await service.reap(Date.now());
|
||||
|
||||
// The remote release ran and failed, so the reaper counts a failure and keeps
|
||||
// the durable cleanup record for a later retry.
|
||||
expect(releaseRunLease).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual({ released: 0, failed: 1 });
|
||||
expect(store.removed).not.toContain("orphan-session");
|
||||
expect(store.rows.has("orphan-session")).toBe(true);
|
||||
});
|
||||
|
||||
it("releases a leftover lease by id on a restart reap", async () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
const { service } = buildService({ sandbox: sandbox.provider, store: store.store });
|
||||
|
||||
// A crash left a durable record whose lease the reaper must free after a
|
||||
// restart. No live session references it.
|
||||
const orphan: SetupTokenCleanupRecord = {
|
||||
sessionId: "orphan-session",
|
||||
companyId: SCOPE.companyId,
|
||||
ownerUserId: SCOPE.ownerUserId,
|
||||
adapterType: SCOPE.adapterType,
|
||||
environmentId: SCOPE.environmentId,
|
||||
leaseId: "lease-orphan",
|
||||
deadline: 0,
|
||||
state: "failed",
|
||||
boundAt: null,
|
||||
};
|
||||
store.setReapable([orphan]);
|
||||
|
||||
const result = await service.reap(Date.now());
|
||||
|
||||
expect(result).toEqual({ released: 1, failed: 0 });
|
||||
expect(sandbox.releasedLeaseIds).toEqual(["lease-orphan"]);
|
||||
expect(store.removed).toContain("orphan-session");
|
||||
});
|
||||
});
|
||||
|
||||
describe("production sandbox provider", () => {
|
||||
it("fails closed before it acquires a lease when the live opener is not bound", async () => {
|
||||
const acquireRunLease = vi.fn();
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: {
|
||||
getById: async () => ({ id: "env-1", driver: "sandbox", status: "active" }) as never,
|
||||
getLeaseById: async () => null,
|
||||
releaseLease: async () => ({}) as never,
|
||||
},
|
||||
environmentRuntime: {
|
||||
acquireRunLease,
|
||||
getDriver: () => null,
|
||||
},
|
||||
// No `openLivePtySession`: the live path lands with the
|
||||
// characterization test.
|
||||
});
|
||||
|
||||
await expect(provider.acquire({ scope: SCOPE, deadline: Date.now() + 1000 })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
// The provider held no lease, so it never acquired one.
|
||||
expect(acquireRunLease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed for a local environment", async () => {
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: {
|
||||
getById: async () => ({ id: "env-1", driver: "local", status: "active" }) as never,
|
||||
getLeaseById: async () => null,
|
||||
releaseLease: async () => ({}) as never,
|
||||
},
|
||||
environmentRuntime: {
|
||||
acquireRunLease: vi.fn(),
|
||||
getDriver: () => null,
|
||||
},
|
||||
openLivePtySession: async () => {
|
||||
throw new Error("must not reach the opener for a local environment");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(provider.acquire({ scope: SCOPE, deadline: Date.now() + 1000 })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
});
|
||||
|
||||
it("acquires a lease and binds the live opener when it is present", async () => {
|
||||
const openPtySession: SetupTokenPtySessionOpener = async () => ({
|
||||
onData(): void {},
|
||||
write(): void {},
|
||||
wait: async () => ({ exitCode: 0 }),
|
||||
kill(): void {},
|
||||
async close(): Promise<void> {},
|
||||
});
|
||||
const releaseRunLease = vi.fn(async () => null);
|
||||
const deadline = Date.now() + 1000;
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: {
|
||||
getById: async () => ({ id: "env-1", name: "Sandbox", driver: "sandbox", status: "active" }) as never,
|
||||
getLeaseById: async () => null,
|
||||
releaseLease: async () => ({}) as never,
|
||||
},
|
||||
environmentRuntime: {
|
||||
acquireRunLease: async () =>
|
||||
({
|
||||
environment: { id: "env-1", driver: "sandbox" },
|
||||
// The runtime bounds the lease expiry to the session deadline.
|
||||
lease: { id: "lease-live", expiresAt: new Date(deadline - 1) },
|
||||
leaseContext: {},
|
||||
}) as never,
|
||||
getDriver: () => ({ releaseRunLease }) as never,
|
||||
},
|
||||
openLivePtySession: async () => openPtySession,
|
||||
});
|
||||
|
||||
const acquired = await provider.acquire({ scope: SCOPE, deadline });
|
||||
expect(acquired.leaseId).toBe("lease-live");
|
||||
|
||||
await provider.release(acquired.leaseId);
|
||||
expect(releaseRunLease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards the session deadline and records a lease expiry at or before it", async () => {
|
||||
const openPtySession: SetupTokenPtySessionOpener = async () => ({
|
||||
onData(): void {},
|
||||
write(): void {},
|
||||
wait: async () => ({ exitCode: 0 }),
|
||||
kill(): void {},
|
||||
async close(): Promise<void> {},
|
||||
});
|
||||
const releaseRunLease = vi.fn(async () => null);
|
||||
const deadline = Date.now() + 60_000;
|
||||
let forwardedExpiresAt: Date | null | undefined;
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: {
|
||||
getById: async () => ({ id: "env-1", name: "Sandbox", driver: "sandbox", status: "active" }) as never,
|
||||
getLeaseById: async () => null,
|
||||
releaseLease: async () => ({}) as never,
|
||||
},
|
||||
environmentRuntime: {
|
||||
// The runtime records the provider-attested expiry. Here the provider
|
||||
// grants an expiry at or before the requested deadline.
|
||||
acquireRunLease: async (input: { requestedExpiresAt?: Date | null }) => {
|
||||
forwardedExpiresAt = input.requestedExpiresAt;
|
||||
const bounded = new Date(Math.min(deadline, deadline + 5_000));
|
||||
return {
|
||||
environment: { id: "env-1", driver: "sandbox" },
|
||||
lease: { id: "lease-live", expiresAt: bounded },
|
||||
leaseContext: {},
|
||||
} as never;
|
||||
},
|
||||
getDriver: () => ({ releaseRunLease }) as never,
|
||||
},
|
||||
openLivePtySession: async () => openPtySession,
|
||||
});
|
||||
|
||||
const acquired = await provider.acquire({ scope: SCOPE, deadline });
|
||||
|
||||
// The provider forwarded the session deadline to the runtime acquire.
|
||||
expect(forwardedExpiresAt).toBeInstanceOf(Date);
|
||||
expect((forwardedExpiresAt as Date).getTime()).toBe(deadline);
|
||||
expect(acquired.leaseId).toBe("lease-live");
|
||||
// The lease held no expiry after the deadline.
|
||||
expect(releaseRunLease).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("releases the remote lease and fails closed when the acquired expiry is absent, invalid, or later", async () => {
|
||||
const deadline = Date.now() + 60_000;
|
||||
const cases: Array<{ label: string; expiresAt: unknown }> = [
|
||||
{ label: "absent", expiresAt: null },
|
||||
{ label: "invalid", expiresAt: new Date("not-a-date") },
|
||||
{ label: "later", expiresAt: new Date(deadline + 1) },
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const releaseRunLease = vi.fn(async () => null);
|
||||
const openLivePtySession = vi.fn(async () => {
|
||||
throw new Error("must not open a pty for an unbounded lease");
|
||||
});
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: {
|
||||
getById: async () => ({ id: "env-1", name: "Sandbox", driver: "sandbox", status: "active" }) as never,
|
||||
getLeaseById: async () => null,
|
||||
releaseLease: async () => ({}) as never,
|
||||
},
|
||||
environmentRuntime: {
|
||||
acquireRunLease: async () =>
|
||||
({
|
||||
environment: { id: "env-1", driver: "sandbox" },
|
||||
lease: { id: "lease-unbounded", expiresAt: testCase.expiresAt },
|
||||
leaseContext: {},
|
||||
}) as never,
|
||||
getDriver: () => ({ releaseRunLease }) as never,
|
||||
},
|
||||
openLivePtySession,
|
||||
});
|
||||
|
||||
await expect(provider.acquire({ scope: SCOPE, deadline })).rejects.toMatchObject({
|
||||
status: 503,
|
||||
});
|
||||
// The provider released the acquired remote lease through the driver.
|
||||
expect(releaseRunLease, testCase.label).toHaveBeenCalledTimes(1);
|
||||
// The provider failed closed before it opened the login pseudo-terminal.
|
||||
expect(openLivePtySession, testCase.label).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("runs the provider driver release by id for a fresh instance with an empty lease map", async () => {
|
||||
// A fresh provider instance holds no in-memory lease record, as after a
|
||||
// restart. The release must resolve the stored lease and its environment and
|
||||
// run the provider driver release, not only the database row write.
|
||||
const releaseRunLease = vi.fn(async () => null);
|
||||
const releaseLease = vi.fn(async () => ({}) as never);
|
||||
const getLeaseById = vi.fn(async () => ({ id: "lease-restart", environmentId: "env-1" }) as never);
|
||||
const getById = vi.fn(async () => ({ id: "env-1", driver: "sandbox", status: "active" }) as never);
|
||||
const provider = createProductionSetupTokenSandboxProvider({
|
||||
environments: { getById, getLeaseById, releaseLease },
|
||||
environmentRuntime: {
|
||||
acquireRunLease: vi.fn(),
|
||||
getDriver: () => ({ releaseRunLease }) as never,
|
||||
},
|
||||
openLivePtySession: async () => {
|
||||
throw new Error("must not open a pty for a restart release");
|
||||
},
|
||||
});
|
||||
|
||||
await provider.release("lease-restart");
|
||||
|
||||
// The provider resolved the lease by id and released through the driver.
|
||||
expect(getLeaseById).toHaveBeenCalledWith("lease-restart");
|
||||
expect(releaseRunLease).toHaveBeenCalledTimes(1);
|
||||
// The provider tore down the remote sandbox through the driver, not only the
|
||||
// database lease row.
|
||||
expect(releaseLease).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("worker-bound live pseudo-terminal opener", () => {
|
||||
it("resolves the lease to its provider lease id and drives the worker route gate", async () => {
|
||||
const session = {
|
||||
onData: () => {},
|
||||
write: () => {},
|
||||
wait: async () => ({ exitCode: 0 }),
|
||||
kill: () => {},
|
||||
close: async () => {},
|
||||
};
|
||||
const openSetupTokenPtySession = vi.fn(async () => session);
|
||||
const getLeaseById = vi.fn(async () => ({
|
||||
providerLeaseId: "provider-lease-9",
|
||||
metadata: { pluginId: "paperclip.daytona", provider: "daytona" },
|
||||
}));
|
||||
|
||||
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager: { openSetupTokenPtySession },
|
||||
environments: { getLeaseById },
|
||||
});
|
||||
|
||||
const opener = await openLivePtySession({
|
||||
scope: SCOPE,
|
||||
environmentId: "env-1",
|
||||
leaseId: "lease-42",
|
||||
});
|
||||
// The opener resolved the server lease id to the provider lease id.
|
||||
expect(getLeaseById).toHaveBeenCalledWith("lease-42");
|
||||
|
||||
const opened = await opener("claude setup-token");
|
||||
// The opener drove the manager route gate with the resolved worker target and
|
||||
// the fixed command. The manager mints the host route id, so the opener passes
|
||||
// none.
|
||||
expect(openSetupTokenPtySession).toHaveBeenCalledWith("paperclip.daytona", {
|
||||
driverKey: "daytona",
|
||||
companyId: "company-1",
|
||||
environmentId: "env-1",
|
||||
providerLeaseId: "provider-lease-9",
|
||||
command: "claude setup-token",
|
||||
});
|
||||
expect(opened).toBe(session);
|
||||
});
|
||||
|
||||
it("fails closed when the lease carries no sandbox worker binding", async () => {
|
||||
const openSetupTokenPtySession = vi.fn();
|
||||
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager: { openSetupTokenPtySession },
|
||||
// The lease resolves but carries no provider lease id or plugin id.
|
||||
environments: {
|
||||
getLeaseById: async () => ({ providerLeaseId: null, metadata: {} }),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
openLivePtySession({ scope: SCOPE, environmentId: "env-1", leaseId: "lease-42" }),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
// The opener never reached the worker manager.
|
||||
expect(openSetupTokenPtySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when the lease is missing", async () => {
|
||||
const openLivePtySession = createWorkerBoundSetupTokenPtyOpener({
|
||||
workerManager: { openSetupTokenPtySession: vi.fn() },
|
||||
environments: { getLeaseById: async () => null },
|
||||
});
|
||||
|
||||
await expect(
|
||||
openLivePtySession({ scope: SCOPE, environmentId: "env-1", leaseId: "missing" }),
|
||||
).rejects.toMatchObject({ status: 503 });
|
||||
});
|
||||
});
|
||||
|
||||
// The fixed, closed set of diagnostic lines the login runner emits. The runner
|
||||
// is the sole producer. Every line is an approved non-secret literal. The
|
||||
// application binding forwards each line verbatim, so a captured line must be a
|
||||
// member of this set.
|
||||
const DIAGNOSTIC_ALLOWLIST = new Set<string>([
|
||||
"[paperclip] Setup-token login: the process stop step errored.",
|
||||
"[paperclip] Setup-token login: the driver dispose step errored.",
|
||||
"[paperclip] Setup-token login: the code input step errored.",
|
||||
"[paperclip] Setup-token login: sent the browser code to the prompt.",
|
||||
"[paperclip] Setup-token login: delivered the credential to the sink.",
|
||||
"[paperclip] Setup-token login: the credential delivery step errored.",
|
||||
"[paperclip] Setup-token login cancelled before start.",
|
||||
"[paperclip] Setup-token login timed out; stopping the process.",
|
||||
"[paperclip] Setup-token login cancelled; stopping the process.",
|
||||
"[paperclip] Setup-token login command ended with a non-zero exit code.",
|
||||
"[paperclip] Setup-token login: the credential did not land; treating the run as a failure.",
|
||||
"[paperclip] Setup-token login: surfaced the sign-in prompt.",
|
||||
"[paperclip] Setup-token login command ended successfully.",
|
||||
]);
|
||||
|
||||
// Synthetic sentinels. No real secret is present. The tests assert that no
|
||||
// captured diagnostic line carries any of these values, so the binding never
|
||||
// leaks the token, the browser code, or the authorization URL to a log.
|
||||
const TOKEN_SENTINEL = "sk-ant-oat01-SENTINELTOKENAAAAAAAAAAAA";
|
||||
const BROWSER_CODE_SENTINEL = "SENTINEL-BROWSER-CODE";
|
||||
const URL_CLIENT_SENTINEL = "sentinelurlclient";
|
||||
// Each query value satisfies the shape contract the parser enforces: a 43-to-128
|
||||
// character `code_challenge`, a 16-to-256 character `state`, and the one pinned
|
||||
// `redirect_uri`. The `client_id` stays the sentinel, so the leak checks still
|
||||
// prove the client id never reaches a log line.
|
||||
const AUTHORIZATION_URL_SENTINEL =
|
||||
"https://claude.com/cai/oauth/authorize" +
|
||||
`?client_id=${URL_CLIENT_SENTINEL}` +
|
||||
"&code=redacted" +
|
||||
"&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" +
|
||||
"&code_challenge_method=S256" +
|
||||
"&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback" +
|
||||
"&response_type=code" +
|
||||
"&scope=user%3Ainference" +
|
||||
"&state=Xy7Kd2Pq9Rn4Vb8Lf1Mw6Zc3Hj0Tg5Us";
|
||||
|
||||
// A complete sign-in prompt block. The parser binds the authorization URL to
|
||||
// the preamble and the prompt line after it.
|
||||
const PROMPT_OUTPUT = [
|
||||
"Browser didn’t open? Use the url below to sign in (c to copy)",
|
||||
AUTHORIZATION_URL_SENTINEL,
|
||||
"Paste code here if prompted >",
|
||||
].join("\n");
|
||||
|
||||
// A complete success record. The parser binds the token between the two
|
||||
// anchors.
|
||||
const CREDENTIAL_OUTPUT = [
|
||||
"✓ Long-lived authentication token created successfully!",
|
||||
"",
|
||||
SETUP_TOKEN_BEFORE_ANCHOR,
|
||||
"",
|
||||
TOKEN_SENTINEL,
|
||||
"",
|
||||
SETUP_TOKEN_AFTER_ANCHOR,
|
||||
].join("\n");
|
||||
|
||||
/**
|
||||
* A fake sandbox that streams controllable output. The test emits the prompt
|
||||
* block and the credential block on demand, and it ends the login process with
|
||||
* a chosen exit code. The runner drives its full diagnostic path over this
|
||||
* fake, so the test captures every line the binding forwards.
|
||||
*/
|
||||
function createStreamingSandbox() {
|
||||
let listener: ((chunk: string) => void) | null = null;
|
||||
let resolveExit: (value: { exitCode: number | null }) => void = () => {};
|
||||
const exit = new Promise<{ exitCode: number | null }>((resolve) => {
|
||||
resolveExit = resolve;
|
||||
});
|
||||
const writes: string[] = [];
|
||||
|
||||
const openPtySession: SetupTokenPtySessionOpener = async () => ({
|
||||
onData(next): void {
|
||||
listener = next;
|
||||
},
|
||||
write(data): void {
|
||||
writes.push(data);
|
||||
},
|
||||
wait: () => exit,
|
||||
kill(): void {
|
||||
resolveExit({ exitCode: 137 });
|
||||
},
|
||||
async close(): Promise<void> {},
|
||||
});
|
||||
|
||||
const provider: SetupTokenSandboxProvider = {
|
||||
async acquire() {
|
||||
return { leaseId: "lease-1", openPtySession };
|
||||
},
|
||||
async release() {},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
emit(chunk: string): void {
|
||||
listener?.(chunk);
|
||||
},
|
||||
finishProcess(exitCode: number | null): void {
|
||||
resolveExit({ exitCode });
|
||||
},
|
||||
get writes() {
|
||||
return writes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Waits `ms` milliseconds of real time, so the code-submit settle can pass. */
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drives one login run through the binding factory and returns every captured
|
||||
* diagnostic line. The caller controls the run through `drive`, so one call
|
||||
* exercises the success path and another exercises the failure path. The `drive`
|
||||
* callback receives the sandbox and the process handle.
|
||||
*/
|
||||
async function captureDiagnostics(
|
||||
drive: (
|
||||
sandbox: ReturnType<typeof createStreamingSandbox>,
|
||||
process: SetupTokenLoginProcess,
|
||||
) => Promise<void>,
|
||||
): Promise<string[]> {
|
||||
const captured: string[] = [];
|
||||
const sandbox = createStreamingSandbox();
|
||||
const store = createRecordingStore();
|
||||
const transport = buildSetupTokenLoginTransport({
|
||||
sandbox: sandbox.provider,
|
||||
store: store.store,
|
||||
log: (line) => captured.push(line),
|
||||
});
|
||||
|
||||
await transport.leases.acquire({ scope: SCOPE, deadline: Date.now() + 60_000 });
|
||||
const controller = new AbortController();
|
||||
const process = transport.factory({
|
||||
scope: SCOPE,
|
||||
onPrompt: () => {},
|
||||
onCredential: async () => {},
|
||||
timeoutMs: 60_000,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// Let the transport open the session and register the output listener before
|
||||
// the driver emits any output.
|
||||
await flush();
|
||||
|
||||
await drive(sandbox, process);
|
||||
await process.done;
|
||||
await flush();
|
||||
return captured;
|
||||
}
|
||||
|
||||
/** Asserts a captured diagnostic set is allowlisted and carries no secret. */
|
||||
function expectSafeDiagnostics(captured: string[]): void {
|
||||
for (const line of captured) {
|
||||
expect(DIAGNOSTIC_ALLOWLIST.has(line), line).toBe(true);
|
||||
}
|
||||
const joined = captured.join("\n");
|
||||
expect(joined).not.toContain(TOKEN_SENTINEL);
|
||||
expect(joined).not.toContain(BROWSER_CODE_SENTINEL);
|
||||
expect(joined).not.toContain(AUTHORIZATION_URL_SENTINEL);
|
||||
expect(joined).not.toContain(URL_CLIENT_SENTINEL);
|
||||
}
|
||||
|
||||
describe("setup-token production transport binding diagnostics", () => {
|
||||
it("forwards only allowlisted, non-secret diagnostics on a success path", async () => {
|
||||
const captured = await captureDiagnostics(async (sandbox, process) => {
|
||||
// Surface the prompt. The runner logs the prompt line and starts the code
|
||||
// input step, which waits for the browser code.
|
||||
sandbox.emit(PROMPT_OUTPUT);
|
||||
await flush();
|
||||
// Submit the browser code. The runner writes the code, lets the paste
|
||||
// buffer settle, writes the Enter byte, and logs the code-sent line.
|
||||
process.submitCode(BROWSER_CODE_SENTINEL);
|
||||
await delay(250);
|
||||
// Deliver the token, then end the process cleanly. The runner binds the
|
||||
// token, delivers it to the sink, and logs the success line.
|
||||
sandbox.emit(CREDENTIAL_OUTPUT);
|
||||
sandbox.finishProcess(0);
|
||||
});
|
||||
|
||||
// The run reached the success lines, so the capture is not empty.
|
||||
expect(captured).toContain("[paperclip] Setup-token login: surfaced the sign-in prompt.");
|
||||
expect(captured).toContain(
|
||||
"[paperclip] Setup-token login: delivered the credential to the sink.",
|
||||
);
|
||||
expect(captured).toContain("[paperclip] Setup-token login command ended successfully.");
|
||||
// The runner sent the browser code, so the code-input path ran. The code
|
||||
// still never reaches a log line.
|
||||
expect(captured).toContain("[paperclip] Setup-token login: sent the browser code to the prompt.");
|
||||
expectSafeDiagnostics(captured);
|
||||
});
|
||||
|
||||
it("forwards only allowlisted, non-secret diagnostics on a failure path", async () => {
|
||||
const captured = await captureDiagnostics(async (sandbox, process) => {
|
||||
void process;
|
||||
// The login process ends with a non-zero exit code before any prompt.
|
||||
sandbox.finishProcess(1);
|
||||
});
|
||||
|
||||
expect(captured).toContain(
|
||||
"[paperclip] Setup-token login command ended with a non-zero exit code.",
|
||||
);
|
||||
expectSafeDiagnostics(captured);
|
||||
});
|
||||
});
|
||||
|
||||
// A compile-time guard: a terminal state helper stays importable for the tests.
|
||||
void isTerminalSessionState;
|
||||
|
||||
// A synthetic token. No real token is present. The tests assert the token never
|
||||
// reaches a log or an error message.
|
||||
const SYNTH_TOKEN = "sk-ant-oat01-SYNTHETICSYNTHETICSYNTHETIC01";
|
||||
|
||||
describe("buildClaudeOAuthWriteInput", () => {
|
||||
it("maps a scope with no overwrite to a first_write input", () => {
|
||||
const input = buildClaudeOAuthWriteInput(SCOPE, "session-7", SYNTH_TOKEN);
|
||||
expect(input).toEqual({ sessionId: "session-7", mode: "first_write", value: SYNTH_TOKEN });
|
||||
});
|
||||
|
||||
it("maps a scope with the overwrite capture to a confirmed_rotation input", () => {
|
||||
const overwriteScope: SetupTokenSessionScope = {
|
||||
...SCOPE,
|
||||
confirmedOverwrite: { expectedSecretId: "secret-9", expectedLatestVersion: 4 },
|
||||
};
|
||||
const input = buildClaudeOAuthWriteInput(overwriteScope, "session-8", SYNTH_TOKEN);
|
||||
expect(input).toEqual({
|
||||
sessionId: "session-8",
|
||||
mode: "confirmed_rotation",
|
||||
value: SYNTH_TOKEN,
|
||||
expectedSecretId: "secret-9",
|
||||
expectedLatestVersion: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSetupTokenSecretWriter wiring", () => {
|
||||
it("carries the writer through the transport binding to the router", () => {
|
||||
const sandbox = createFakeSandbox();
|
||||
const store = createRecordingStore();
|
||||
// The writer never runs here; a stub db is enough to assert the binding
|
||||
// forwards the writer reference to the router.
|
||||
const writer = createSetupTokenSecretWriter({ db: {} as never });
|
||||
|
||||
const transport = buildSetupTokenLoginTransport({
|
||||
sandbox: sandbox.provider,
|
||||
store: store.store,
|
||||
completeCredential: writer,
|
||||
});
|
||||
|
||||
// The binding forwards the writer, so the router uses the real atomic write
|
||||
// instead of the deferred fail-closed default.
|
||||
expect(transport.completeCredential).toBe(writer);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,611 @@
|
|||
// The production transport binding for the Claude setup-token login session.
|
||||
//
|
||||
// The session service (`setup-token-session.ts`) drives the login flow over
|
||||
// three injected components: a lease manager, a login-process factory, and a
|
||||
// durable cleanup store. The router (`agents.ts`) accepts these components as an
|
||||
// optional `setupTokenLogin` transport and falls back to a deferred, fail-closed
|
||||
// default when a caller omits it. This module builds the production transport, so
|
||||
// `app.ts` binds it one time at startup.
|
||||
//
|
||||
// The binding passes only the fixed command `CLAUDE_SETUP_TOKEN_COMMAND` to the
|
||||
// login runner. It never accepts a command from a route, a request
|
||||
// body, or an adapter configuration.
|
||||
//
|
||||
// The live pseudo-terminal opener is a separate seam. The Daytona sandbox
|
||||
// provider runs as a plugin worker, so the server process does not hold the raw
|
||||
// sandbox process. The real opener binds inside the worker; this module accepts
|
||||
// it through `openLivePtySession`. A test injects a fake sandbox opener to drive
|
||||
// the full session path. The live opener lands with the characterization
|
||||
// test against a real sandbox.
|
||||
|
||||
import {
|
||||
SetupTokenSessionError,
|
||||
SETUP_TOKEN_START_FAILED,
|
||||
SETUP_TOKEN_STORAGE_FAILED,
|
||||
createDbSetupTokenCleanupStore,
|
||||
transitionSetupTokenSessionToStored,
|
||||
type SetupTokenCleanupStore,
|
||||
type SetupTokenLease,
|
||||
type SetupTokenLeaseManager,
|
||||
type SetupTokenLoginOutcome,
|
||||
type SetupTokenLoginProcess,
|
||||
type SetupTokenLoginProcessFactory,
|
||||
type SetupTokenSecretWriter,
|
||||
type SetupTokenSessionScope,
|
||||
} from "./setup-token-session.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import type { environmentService } from "./environments.js";
|
||||
import type { environmentRuntimeService } from "./environment-runtime.js";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createSetupTokenPtyTransport,
|
||||
type SetupTokenPtySession,
|
||||
type SetupTokenPtySessionOpener,
|
||||
} from "@paperclipai/adapter-utils/setup-token-transport";
|
||||
import {
|
||||
runSetupTokenLogin,
|
||||
CLAUDE_SETUP_TOKEN_COMMAND,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
/**
|
||||
* The sandbox provider for one login session. It acquires a fresh sandbox lease
|
||||
* and returns the lease id plus the pseudo-terminal opener bound to that lease's
|
||||
* process. It releases the lease by id. The production provider wraps the
|
||||
* environment runtime. A test injects a fake provider.
|
||||
*/
|
||||
export interface SetupTokenSandboxProvider {
|
||||
/**
|
||||
* Acquires a fresh sandbox lease for a login session. It returns the lease id
|
||||
* and the pseudo-terminal opener bound to the acquired lease's process. It
|
||||
* throws a fail-closed error when it cannot acquire the lease or bind the
|
||||
* opener; the caller then returns the fixed start error.
|
||||
*/
|
||||
acquire(input: {
|
||||
scope: SetupTokenSessionScope;
|
||||
deadline: number;
|
||||
}): Promise<{ leaseId: string; openPtySession: SetupTokenPtySessionOpener }>;
|
||||
/** Releases the lease by id. The service and the startup reaper call it. */
|
||||
release(leaseId: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** The dependencies the production transport binding needs. */
|
||||
export interface SetupTokenLoginTransportDeps {
|
||||
/** The sandbox provider. The production provider wraps the environment runtime. */
|
||||
sandbox: SetupTokenSandboxProvider;
|
||||
/** The durable cleanup store. Defaults to the database-backed store over `db`. */
|
||||
store: SetupTokenCleanupStore;
|
||||
/**
|
||||
* The owner-bound secret writer. The binding forwards it to the router, so a
|
||||
* completed login stores the minted token in the secret store. When a caller
|
||||
* omits it, the router keeps its deferred, fail-closed writer.
|
||||
*/
|
||||
completeCredential?: SetupTokenSecretWriter;
|
||||
/** A non-leaking status sink. It receives only fixed status lines. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The production transport the router binds through `options.setupTokenLogin`. It
|
||||
* carries the live lease manager, the login-process factory, the durable cleanup
|
||||
* store, and the owner-bound secret writer. The router uses the writer for the
|
||||
* final secret write; when the binding omits it, the router keeps its deferred,
|
||||
* fail-closed writer.
|
||||
*/
|
||||
export interface SetupTokenLoginTransportBinding {
|
||||
factory: SetupTokenLoginProcessFactory;
|
||||
leases: SetupTokenLeaseManager;
|
||||
store: SetupTokenCleanupStore;
|
||||
completeCredential?: SetupTokenSecretWriter;
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrow secret-completion surface the production writer needs. The secrets
|
||||
* service exposes `completeClaudeOAuthUserSecret` as the owner-bound
|
||||
* compare-and-set for the Claude Code OAuth value. The writer forwards only the
|
||||
* scope owner, the session id, the mode, and the token to it.
|
||||
*/
|
||||
export interface SetupTokenSecretCompletion {
|
||||
completeClaudeOAuthUserSecret(
|
||||
companyId: string,
|
||||
ownerUserId: string,
|
||||
input: {
|
||||
sessionId: string;
|
||||
mode: "first_write" | "confirmed_rotation";
|
||||
value: string;
|
||||
expectedSecretId?: string | null;
|
||||
expectedLatestVersion?: number | null;
|
||||
},
|
||||
actor?: { userId?: string | null; agentId?: string | null },
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
/** The owner-bound compare-and-set input the secrets service consumes. */
|
||||
export type ClaudeOAuthWriteInput =
|
||||
| { sessionId: string; mode: "first_write"; value: string }
|
||||
| {
|
||||
sessionId: string;
|
||||
mode: "confirmed_rotation";
|
||||
value: string;
|
||||
expectedSecretId: string;
|
||||
expectedLatestVersion: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps the session writer input to the secrets service compare-and-set input. The
|
||||
* session scope selects the mode. A scope with no overwrite capture writes with
|
||||
* `mode: "first_write"`, so the login creates the first owner value only. A scope
|
||||
* that carries the confirmed-overwrite capture writes with
|
||||
* `mode: "confirmed_rotation"`, so the login rotates the stored value under the
|
||||
* captured `expectedSecretId` and `expectedLatestVersion`. The overwrite capture
|
||||
* carries no owner; the owner still comes from the scope.
|
||||
*/
|
||||
export function buildClaudeOAuthWriteInput(
|
||||
scope: SetupTokenSessionScope,
|
||||
sessionId: string,
|
||||
token: string,
|
||||
): ClaudeOAuthWriteInput {
|
||||
const overwrite = scope.confirmedOverwrite ?? null;
|
||||
return overwrite
|
||||
? {
|
||||
sessionId,
|
||||
mode: "confirmed_rotation",
|
||||
value: token,
|
||||
expectedSecretId: overwrite.expectedSecretId,
|
||||
expectedLatestVersion: overwrite.expectedLatestVersion,
|
||||
}
|
||||
: { sessionId, mode: "first_write", value: token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production atomic credential-claim writer for the login session. It
|
||||
* runs one control-plane transaction. Inside the transaction it first transitions
|
||||
* the exact durable session row to `stored` with a full-scope conditional update,
|
||||
* then writes or rotates the owner-bound secret on the same transaction handle.
|
||||
* The commit establishes the encrypted secret and the `stored` claim together, so
|
||||
* a crash between the two steps cannot leave a stored token with no valid claim.
|
||||
*
|
||||
* A zero-row transition means no claimable row exists in this scope. The writer
|
||||
* rolls back and rejects with the fixed, non-secret storage error, so the session
|
||||
* marks the login failed. A storage failure rejects and rolls back the whole
|
||||
* transaction, so neither the secret nor the claim commits. A concurrent change
|
||||
* fails the compare-and-set with the fixed stale conflict, so the write leaves no
|
||||
* partial state.
|
||||
*
|
||||
* It reads the company and the owner only from the immutable session scope, so a
|
||||
* request cannot redirect the write to another owner. It never logs
|
||||
* the token and never puts the token in an error; it lets the secrets service
|
||||
* error propagate unchanged, and the session maps a rejection to the fixed,
|
||||
* non-secret storage error.
|
||||
*/
|
||||
export function createSetupTokenSecretWriter(deps: {
|
||||
db: Db;
|
||||
/**
|
||||
* Builds the transaction-bound owner-bound completion. It defaults to
|
||||
* `secretService(tx)`, so the compare-and-set runs on the passed transaction
|
||||
* handle. A test injects a fake to force a storage failure after the transition.
|
||||
*/
|
||||
completionForTx?: (tx: Db) => SetupTokenSecretCompletion;
|
||||
}): SetupTokenSecretWriter {
|
||||
const completionForTx = deps.completionForTx ?? ((tx) => secretService(tx));
|
||||
return async ({ scope, sessionId, token }) => {
|
||||
const input = buildClaudeOAuthWriteInput(scope, sessionId, token);
|
||||
await deps.db.transaction(async (tx) => {
|
||||
const txDb = tx as unknown as Db;
|
||||
// Step 1: lock and conditionally transition the exact durable row to
|
||||
// `stored`. The predicate matches the full scope, an allowed predecessor
|
||||
// state, an unexpired deadline, and an unconsumed claim. It requires exactly
|
||||
// one returned row.
|
||||
const transitioned = await transitionSetupTokenSessionToStored(txDb, {
|
||||
sessionId,
|
||||
companyId: scope.companyId,
|
||||
ownerUserId: scope.ownerUserId,
|
||||
adapterType: scope.adapterType,
|
||||
environmentId: scope.environmentId,
|
||||
});
|
||||
if (!transitioned) {
|
||||
// Zero-row result: roll back the whole transaction. The session then marks
|
||||
// the login failed and returns the fixed no-secret error.
|
||||
throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED);
|
||||
}
|
||||
// Step 2: write or rotate the secret on the same transaction handle. A
|
||||
// failure here rolls back the transition too, so the commit is all-or-none.
|
||||
await completionForTx(txDb).completeClaudeOAuthUserSecret(
|
||||
scope.companyId,
|
||||
scope.ownerUserId,
|
||||
input,
|
||||
{ userId: scope.ownerUserId },
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** The immutable scope key that correlates one acquire with one factory call. */
|
||||
function scopeKey(scope: SetupTokenSessionScope): string {
|
||||
return [scope.companyId, scope.ownerUserId, scope.adapterType, scope.environmentId].join("\u0000");
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production setup-token login transport. The lease manager acquires a
|
||||
* sandbox lease through the injected provider and hands the pseudo-terminal
|
||||
* opener to the factory. The factory drives the login runner over the transport
|
||||
* and passes only the fixed command. The lease manager releases the lease on
|
||||
* every terminal path, so the service runs its cleanup against the durable store.
|
||||
*/
|
||||
export function buildSetupTokenLoginTransport(
|
||||
deps: SetupTokenLoginTransportDeps,
|
||||
): SetupTokenLoginTransportBinding {
|
||||
// The binding fixes the login command. It never reads a command from a route, a
|
||||
// request body, an adapter configuration, or the dependency object, so a
|
||||
// runtime-supplied value cannot change the pseudo-terminal command.
|
||||
const command = CLAUDE_SETUP_TOKEN_COMMAND;
|
||||
const log = deps.log ?? (() => {});
|
||||
|
||||
// The pseudo-terminal opener that one acquire hands to the next factory call.
|
||||
// The service calls `leases.acquire` and then `factory` in sequence for one
|
||||
// session, so a per-scope handoff correlates them. The per-owner session cap is
|
||||
// one, so one scope holds at most one live acquire.
|
||||
const pendingOpeners = new Map<string, { leaseId: string; openPtySession: SetupTokenPtySessionOpener }>();
|
||||
// The reverse map releases a handoff by lease id when the factory never
|
||||
// consumes it (a factory error path).
|
||||
const leaseScopeKeys = new Map<string, string>();
|
||||
|
||||
const leases: SetupTokenLeaseManager = {
|
||||
async acquire({ scope, deadline }): Promise<SetupTokenLease> {
|
||||
const acquired = await deps.sandbox.acquire({ scope, deadline });
|
||||
const key = scopeKey(scope);
|
||||
pendingOpeners.set(key, acquired);
|
||||
leaseScopeKeys.set(acquired.leaseId, key);
|
||||
return { id: acquired.leaseId };
|
||||
},
|
||||
async release(lease): Promise<void> {
|
||||
dropHandoff(lease.id);
|
||||
await deps.sandbox.release(lease.id);
|
||||
},
|
||||
async releaseById(leaseId): Promise<void> {
|
||||
dropHandoff(leaseId);
|
||||
await deps.sandbox.release(leaseId);
|
||||
},
|
||||
};
|
||||
|
||||
function dropHandoff(leaseId: string): void {
|
||||
const key = leaseScopeKeys.get(leaseId);
|
||||
if (key !== undefined) {
|
||||
const pending = pendingOpeners.get(key);
|
||||
if (pending && pending.leaseId === leaseId) pendingOpeners.delete(key);
|
||||
leaseScopeKeys.delete(leaseId);
|
||||
}
|
||||
}
|
||||
|
||||
const factory: SetupTokenLoginProcessFactory = ({ scope, onPrompt, onCredential, timeoutMs, signal }): SetupTokenLoginProcess => {
|
||||
const key = scopeKey(scope);
|
||||
const pending = pendingOpeners.get(key);
|
||||
if (!pending) {
|
||||
// The lease manager did not hand off an opener for this scope. Fail closed.
|
||||
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
||||
}
|
||||
pendingOpeners.delete(key);
|
||||
leaseScopeKeys.delete(pending.leaseId);
|
||||
|
||||
const transport = createSetupTokenPtyTransport(pending.openPtySession);
|
||||
|
||||
// Bridge the browser code. The service calls `submitCode` one time after the
|
||||
// single `awaiting_code` transition wins. The runner calls `provideCode` one
|
||||
// time after it surfaces the prompt. A resolved promise hands the code from
|
||||
// the service call to the runner call.
|
||||
let deliverCode: ((code: string) => void) | null = null;
|
||||
const codeReady = new Promise<string>((resolve, reject) => {
|
||||
deliverCode = resolve;
|
||||
// A cancel or an expiry aborts the signal. Reject the pending code, so the
|
||||
// runner stops waiting and ends the run.
|
||||
if (signal.aborted) {
|
||||
reject(new SetupTokenSessionError(499, SETUP_TOKEN_START_FAILED));
|
||||
return;
|
||||
}
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(new SetupTokenSessionError(499, SETUP_TOKEN_START_FAILED)),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
// The runner rejects the run when `provideCode` rejects. Consume the pending
|
||||
// rejection here too, so it never becomes an unhandled rejection when the
|
||||
// runner ends on the timeout or the signal before it reads the code.
|
||||
codeReady.catch(() => {});
|
||||
|
||||
const done: Promise<SetupTokenLoginOutcome> = runSetupTokenLogin(transport, {
|
||||
command,
|
||||
timeoutMs,
|
||||
signal,
|
||||
onPrompt: (prompt) => onPrompt({ url: prompt.url }),
|
||||
provideCode: () => codeReady,
|
||||
onCredential: async (authBytes) => {
|
||||
// Forward the minted token to the owner-bound secret write. The service
|
||||
// holds the token only for this call and never stores it.
|
||||
await onCredential(authBytes.toString("utf8"));
|
||||
},
|
||||
log,
|
||||
}).then((result) => result.outcome);
|
||||
|
||||
return {
|
||||
done,
|
||||
submitCode(code): void {
|
||||
deliverCode?.(code);
|
||||
},
|
||||
stop(): void {
|
||||
// Stop the direct child. The service releases the lease after this call.
|
||||
transport.stop();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return { factory, leases, store: deps.store, completeCredential: deps.completeCredential };
|
||||
}
|
||||
|
||||
/** The dependencies the production sandbox provider needs. */
|
||||
export interface ProductionSetupTokenSandboxProviderDeps {
|
||||
environments: Pick<ReturnType<typeof environmentService>, "getById" | "getLeaseById" | "releaseLease">;
|
||||
environmentRuntime: Pick<ReturnType<typeof environmentRuntimeService>, "acquireRunLease" | "getDriver">;
|
||||
/**
|
||||
* Opens the live pseudo-terminal for the acquired lease. The Daytona provider
|
||||
* runs as a plugin worker, so the real opener binds `sandbox.process` inside
|
||||
* the worker. When a caller omits it, the provider fails closed before it
|
||||
* acquires a lease, and the live path lands with the characterization
|
||||
* test.
|
||||
*/
|
||||
openLivePtySession?: (input: {
|
||||
scope: SetupTokenSessionScope;
|
||||
environmentId: string;
|
||||
leaseId: string;
|
||||
}) => Promise<SetupTokenPtySessionOpener>;
|
||||
/** A non-leaking status sink. It receives only fixed status lines. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production sandbox provider over the environment runtime. It
|
||||
* acquires a fresh sandbox lease for one login session, binds the live
|
||||
* pseudo-terminal opener to that lease, and releases the lease by id. It fails
|
||||
* closed for a local environment, an SSH environment, or a missing live opener.
|
||||
*/
|
||||
export function createProductionSetupTokenSandboxProvider(
|
||||
deps: ProductionSetupTokenSandboxProviderDeps,
|
||||
): SetupTokenSandboxProvider {
|
||||
const log = deps.log ?? (() => {});
|
||||
// The acquired lease records, keyed by lease id. The provider releases a lease
|
||||
// through its driver. The startup reaper releases a lease by id after a
|
||||
// restart, when this map is empty; the provider then falls back to a direct
|
||||
// database release.
|
||||
const leaseRecords = new Map<
|
||||
string,
|
||||
Awaited<ReturnType<ReturnType<typeof environmentRuntimeService>["acquireRunLease"]>>
|
||||
>();
|
||||
|
||||
const failClosed = (): never => {
|
||||
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
||||
};
|
||||
|
||||
return {
|
||||
async acquire({ scope, deadline }) {
|
||||
const environment = await deps.environments.getById(scope.environmentId);
|
||||
if (!environment) {
|
||||
log("[paperclip] Setup-token login: the selected environment is not found.");
|
||||
return failClosed();
|
||||
}
|
||||
if (environment.driver === "local" || environment.driver === "ssh") {
|
||||
// The login pseudo-terminal needs a sandbox. A local or an SSH
|
||||
// environment has no sandbox process to run the login command in.
|
||||
log("[paperclip] Setup-token login: the selected environment has no sandbox.");
|
||||
return failClosed();
|
||||
}
|
||||
if (!deps.openLivePtySession) {
|
||||
// The live sandbox pseudo-terminal opener is not bound yet. Fail closed
|
||||
// before the acquire, so the login holds no lease. The live opener lands
|
||||
// with the characterization test against a real sandbox.
|
||||
log(
|
||||
"[paperclip] Setup-token login: the live sandbox pseudo-terminal transport is not bound.",
|
||||
);
|
||||
return failClosed();
|
||||
}
|
||||
|
||||
const leaseRecord = await deps.environmentRuntime.acquireRunLease({
|
||||
companyId: scope.companyId,
|
||||
environment,
|
||||
issueId: null,
|
||||
agentId: scope.targetAgentId ?? null,
|
||||
// A null heartbeat run and a null execution workspace disable lease
|
||||
// reuse, so the login session always runs in a fresh sandbox.
|
||||
heartbeatRunId: null,
|
||||
persistedExecutionWorkspace: null,
|
||||
adapterType: scope.adapterType,
|
||||
// Apply the active custom-image template, so the sandbox binds to the
|
||||
// trusted image and runtime identity.
|
||||
applyCustomImageTemplate: true,
|
||||
// Re-check the environment company binding inside the lease insert
|
||||
// transaction. The route guard ran earlier, so a managed reconciliation
|
||||
// can bind this sandbox to another company between the guard and this
|
||||
// acquire. The lease insert then rejects a foreign-company environment
|
||||
// with the 403 `environment_company_mismatch` and holds no lease.
|
||||
assertCompanyBinding: true,
|
||||
// Bound the lease expiry to the session deadline. The runtime records
|
||||
// the earlier of this deadline and the provider expiry on the lease row.
|
||||
requestedExpiresAt: new Date(deadline),
|
||||
});
|
||||
const leaseId = leaseRecord.lease.id;
|
||||
leaseRecords.set(leaseId, leaseRecord);
|
||||
|
||||
// Enforce an independent, provider-backed expiry at or before the session
|
||||
// deadline. A crash or an outage stops the in-process cleanup, so the
|
||||
// lease row must carry a durable expiry that a lease reaper acts on. When
|
||||
// the acquired lease has no expiry, an invalid expiry, or an expiry after
|
||||
// the deadline, the server cannot guarantee the hard stop. Release the
|
||||
// remote lease and fail closed, so the login holds no lease and leaves no
|
||||
// durable cleanup row.
|
||||
const expiresAt = leaseRecord.lease.expiresAt;
|
||||
const expiresAtMs = expiresAt instanceof Date ? expiresAt.getTime() : Number.NaN;
|
||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs > deadline) {
|
||||
await this.release(leaseId);
|
||||
log("[paperclip] Setup-token login: the acquired lease expiry does not bound the session deadline.");
|
||||
return failClosed();
|
||||
}
|
||||
|
||||
try {
|
||||
const openPtySession = await deps.openLivePtySession({
|
||||
scope,
|
||||
environmentId: scope.environmentId,
|
||||
leaseId,
|
||||
});
|
||||
return { leaseId, openPtySession };
|
||||
} catch (err) {
|
||||
// The opener bind failed. Release the lease and fail closed, so the login
|
||||
// holds no lease.
|
||||
await this.release(leaseId);
|
||||
log("[paperclip] Setup-token login: the live pseudo-terminal bind failed.");
|
||||
void err;
|
||||
return failClosed();
|
||||
}
|
||||
},
|
||||
|
||||
async release(leaseId) {
|
||||
const leaseRecord = leaseRecords.get(leaseId);
|
||||
if (leaseRecord) {
|
||||
leaseRecords.delete(leaseId);
|
||||
const driver = deps.environmentRuntime.getDriver(leaseRecord.environment.driver);
|
||||
if (driver) {
|
||||
await driver.releaseRunLease({
|
||||
environment: leaseRecord.environment,
|
||||
lease: leaseRecord.lease,
|
||||
status: "released",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A restart cleared the in-memory record. Release the lease through the
|
||||
// provider driver, not only the database row, so the release tears down the
|
||||
// remote sandbox that holds the login state.
|
||||
await releaseLeaseById(leaseId);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Releases a lease by id after a restart, when the in-memory record is gone. It
|
||||
* resolves the stored lease and its environment, then releases the lease through
|
||||
* the provider driver, so the release tears down the remote sandbox and not only
|
||||
* the database row. It fails loud when it resolves a live lease that it cannot
|
||||
* release, so the reaper keeps the durable cleanup record and retries it. It
|
||||
* returns without an error only when the lease row is already gone, because then
|
||||
* no sandbox remains to release.
|
||||
*/
|
||||
async function releaseLeaseById(leaseId: string): Promise<void> {
|
||||
const lease = await deps.environments.getLeaseById(leaseId);
|
||||
if (!lease) {
|
||||
// The lease row is already gone. No remote sandbox remains to release, so
|
||||
// the release is idempotent and the reaper may drop the cleanup record.
|
||||
return;
|
||||
}
|
||||
const environment = lease.environmentId
|
||||
? await deps.environments.getById(lease.environmentId)
|
||||
: null;
|
||||
if (!environment) {
|
||||
// The lease resolves but its environment is gone, so no driver can tear down
|
||||
// the remote sandbox. Fail loud, so the reaper keeps the cleanup record.
|
||||
log("[paperclip] Setup-token login: the lease environment is not found for a restart release.");
|
||||
return failClosed();
|
||||
}
|
||||
const driver = deps.environmentRuntime.getDriver(environment.driver);
|
||||
if (!driver) {
|
||||
// No driver resolves the remote sandbox. Fail loud, so the reaper keeps the
|
||||
// cleanup record and retries the release.
|
||||
log("[paperclip] Setup-token login: no driver resolves the lease for a restart release.");
|
||||
return failClosed();
|
||||
}
|
||||
await driver.releaseRunLease({ environment, lease, status: "released" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the durable, database-backed cleanup store for the production binding. */
|
||||
export function createProductionSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore {
|
||||
return createDbSetupTokenCleanupStore(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* The narrow plugin worker manager surface the live opener needs. The manager
|
||||
* owns the host route gate: it mints the host route identifier,
|
||||
* reserves one route per worker, drives the open, binds the worker session
|
||||
* identifier one time, routes output, and terminalizes the route.
|
||||
*/
|
||||
export interface SetupTokenPtyWorkerManagerLike {
|
||||
openSetupTokenPtySession(
|
||||
pluginId: string,
|
||||
input: {
|
||||
driverKey: string;
|
||||
companyId: string;
|
||||
environmentId: string;
|
||||
providerLeaseId: string;
|
||||
command: string;
|
||||
},
|
||||
): Promise<SetupTokenPtySession>;
|
||||
}
|
||||
|
||||
/** The narrow lease-lookup surface the live opener needs. */
|
||||
export interface SetupTokenPtyLeaseLookup {
|
||||
getLeaseById(leaseId: string): Promise<
|
||||
| {
|
||||
providerLeaseId: string | null;
|
||||
metadata: Record<string, unknown> | null | undefined;
|
||||
}
|
||||
| null
|
||||
>;
|
||||
}
|
||||
|
||||
/** The dependencies the worker-bound live pseudo-terminal opener needs. */
|
||||
export interface WorkerBoundSetupTokenPtyOpenerDeps {
|
||||
/** The plugin worker manager that owns the host route gate. */
|
||||
workerManager: SetupTokenPtyWorkerManagerLike;
|
||||
/** The environment lease lookup that resolves the worker target for a lease. */
|
||||
environments: SetupTokenPtyLeaseLookup;
|
||||
/** A non-leaking status sink. It receives only fixed status lines. */
|
||||
log?: (line: string) => void;
|
||||
}
|
||||
|
||||
function readLeaseMetaString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the production `openLivePtySession`. It resolves the server lease to its
|
||||
* `providerLeaseId` and the sandbox worker plugin id, then returns an opener that
|
||||
* drives the worker through the manager route gate. The manager mints the host
|
||||
* route identifier and owns the route lifecycle. The opener fails closed when the
|
||||
* lease carries no sandbox worker binding.
|
||||
*/
|
||||
export function createWorkerBoundSetupTokenPtyOpener(
|
||||
deps: WorkerBoundSetupTokenPtyOpenerDeps,
|
||||
): NonNullable<ProductionSetupTokenSandboxProviderDeps["openLivePtySession"]> {
|
||||
const log = deps.log ?? (() => {});
|
||||
return async ({ scope, environmentId, leaseId }) => {
|
||||
const lease = await deps.environments.getLeaseById(leaseId);
|
||||
const providerLeaseId =
|
||||
typeof lease?.providerLeaseId === "string" && lease.providerLeaseId.length > 0
|
||||
? lease.providerLeaseId
|
||||
: null;
|
||||
const metadata =
|
||||
lease && typeof lease.metadata === "object" && lease.metadata !== null
|
||||
? (lease.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
const pluginId = readLeaseMetaString(metadata.pluginId);
|
||||
const driverKey =
|
||||
readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver);
|
||||
if (!providerLeaseId || !pluginId || !driverKey) {
|
||||
log("[paperclip] Setup-token login: the lease carries no sandbox worker binding.");
|
||||
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
||||
}
|
||||
return (command: string) =>
|
||||
deps.workerManager.openSetupTokenPtySession(pluginId, {
|
||||
driverKey,
|
||||
companyId: scope.companyId,
|
||||
environmentId,
|
||||
providerLeaseId,
|
||||
command,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
|
@ -2,9 +2,140 @@ import { EventEmitter } from "node:events";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
coordinateHeartbeatSchedulerShutdown,
|
||||
finalizeServerShutdown,
|
||||
loadWithoutCoordinatedShutdownSignalHooks,
|
||||
} from "./shutdown.js";
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function stubLogger() {
|
||||
return { info: vi.fn(), error: vi.fn() };
|
||||
}
|
||||
|
||||
describe("finalizeServerShutdown", () => {
|
||||
it("awaits the setup-token cleanup before the database stop and the process exit", async () => {
|
||||
const order: string[] = [];
|
||||
// The held promise models the setup-token session cancellation and its
|
||||
// sandbox lease release. The teardown must not continue while it is pending.
|
||||
const release = deferred();
|
||||
const shutdownAppServices = vi.fn(async () => {
|
||||
order.push("appServices:start");
|
||||
await release.promise;
|
||||
order.push("appServices:settled");
|
||||
});
|
||||
const stopEmbeddedPostgres = vi.fn(async () => {
|
||||
order.push("postgres:stop");
|
||||
});
|
||||
const shutdownInstrumentation = vi.fn(async () => {
|
||||
order.push("instrumentation:flush");
|
||||
});
|
||||
|
||||
let exited = false;
|
||||
const finalize = finalizeServerShutdown({
|
||||
signal: "SIGTERM",
|
||||
shutdownAppServices,
|
||||
stopEmbeddedPostgres,
|
||||
shutdownInstrumentation,
|
||||
log: stubLogger(),
|
||||
}).then(() => {
|
||||
// This models the caller's `process.exit(0)` continuation.
|
||||
exited = true;
|
||||
order.push("exit");
|
||||
});
|
||||
|
||||
// The cleanup is in flight. The database stop, the instrumentation flush,
|
||||
// and the process exit continuation must all wait for it to settle.
|
||||
await vi.waitFor(() => expect(shutdownAppServices).toHaveBeenCalledOnce());
|
||||
expect(stopEmbeddedPostgres).not.toHaveBeenCalled();
|
||||
expect(shutdownInstrumentation).not.toHaveBeenCalled();
|
||||
expect(exited).toBe(false);
|
||||
|
||||
release.resolve();
|
||||
await finalize;
|
||||
|
||||
expect(exited).toBe(true);
|
||||
expect(order).toEqual([
|
||||
"appServices:start",
|
||||
"appServices:settled",
|
||||
"postgres:stop",
|
||||
"instrumentation:flush",
|
||||
"exit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the teardown durable and still exits when the setup-token release fails", async () => {
|
||||
const order: string[] = [];
|
||||
// The held promise rejects, which models a lease release that failed. The
|
||||
// reaper owns the durable retry, so the teardown must log the failure and
|
||||
// continue rather than swallow it or block the exit.
|
||||
const release = deferred();
|
||||
const releaseError = new Error("lease release failed");
|
||||
const shutdownAppServices = vi.fn(async () => {
|
||||
await release.promise;
|
||||
});
|
||||
const stopEmbeddedPostgres = vi.fn(async () => {
|
||||
order.push("postgres:stop");
|
||||
});
|
||||
const shutdownInstrumentation = vi.fn(async () => {
|
||||
order.push("instrumentation:flush");
|
||||
});
|
||||
const log = stubLogger();
|
||||
|
||||
let exited = false;
|
||||
const finalize = finalizeServerShutdown({
|
||||
signal: "SIGTERM",
|
||||
shutdownAppServices,
|
||||
stopEmbeddedPostgres,
|
||||
shutdownInstrumentation,
|
||||
log,
|
||||
}).then(() => {
|
||||
exited = true;
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(shutdownAppServices).toHaveBeenCalledOnce());
|
||||
expect(stopEmbeddedPostgres).not.toHaveBeenCalled();
|
||||
expect(exited).toBe(false);
|
||||
|
||||
release.reject(releaseError);
|
||||
await finalize;
|
||||
|
||||
// The teardown surfaced the failure in the log, then finished the ordered
|
||||
// teardown and reached the exit continuation.
|
||||
expect(log.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ err: releaseError, signal: "SIGTERM" }),
|
||||
expect.any(String),
|
||||
);
|
||||
expect(order).toEqual(["postgres:stop", "instrumentation:flush"]);
|
||||
expect(exited).toBe(true);
|
||||
});
|
||||
|
||||
it("skips the database stop when no embedded PostgreSQL runs in this process", async () => {
|
||||
const shutdownAppServices = vi.fn(async () => undefined);
|
||||
const shutdownInstrumentation = vi.fn(async () => undefined);
|
||||
const log = stubLogger();
|
||||
|
||||
await finalizeServerShutdown({
|
||||
signal: "SIGINT",
|
||||
shutdownAppServices,
|
||||
stopEmbeddedPostgres: null,
|
||||
shutdownInstrumentation,
|
||||
log,
|
||||
});
|
||||
|
||||
expect(shutdownAppServices).toHaveBeenCalledOnce();
|
||||
expect(shutdownInstrumentation).toHaveBeenCalledOnce();
|
||||
expect(log.info).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWithoutCoordinatedShutdownSignalHooks", () => {
|
||||
it("removes the eager signal handlers from the real embedded-postgres import", async () => {
|
||||
const before = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,56 @@ type HotRestartShutdownPreparation = {
|
|||
skipDrain: boolean;
|
||||
};
|
||||
|
||||
type ShutdownLogger = {
|
||||
info(obj: object, msg: string): void;
|
||||
error(obj: object, msg: string): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs the final, ordered teardown of the server. It awaits the application
|
||||
* service cleanup first, so a live setup-token login session stops and releases
|
||||
* its sandbox lease before the database and the provider stop. The caller runs
|
||||
* `process.exit(0)` only after this helper resolves, so an orderly shutdown
|
||||
* never leaves a sandbox lease or confidential login state alive past the
|
||||
* process exit.
|
||||
*
|
||||
* A step that rejects does not stop the teardown. The helper logs the error and
|
||||
* continues to the next step. A failed setup-token lease release stays a
|
||||
* durable record for the startup reaper; the helper surfaces it in the log
|
||||
* instead of blocking the exit path.
|
||||
*/
|
||||
export async function finalizeServerShutdown(input: {
|
||||
signal: "SIGINT" | "SIGTERM";
|
||||
shutdownAppServices: (() => Promise<void>) | undefined;
|
||||
stopEmbeddedPostgres: (() => Promise<void>) | null;
|
||||
shutdownInstrumentation: () => Promise<void>;
|
||||
log: ShutdownLogger;
|
||||
}): Promise<void> {
|
||||
const { signal } = input;
|
||||
|
||||
// Await the application service cleanup, so a live setup-token login session
|
||||
// releases its sandbox lease before the database and the provider stop. A
|
||||
// rejected cleanup stays durable for the reaper; it does not block the exit.
|
||||
try {
|
||||
await input.shutdownAppServices?.();
|
||||
} catch (err) {
|
||||
input.log.error({ err, signal }, "Application service shutdown failed");
|
||||
}
|
||||
|
||||
if (input.stopEmbeddedPostgres) {
|
||||
input.log.info({ signal }, "Stopping embedded PostgreSQL");
|
||||
try {
|
||||
await input.stopEmbeddedPostgres();
|
||||
} catch (err) {
|
||||
input.log.error({ err }, "Failed to stop embedded PostgreSQL cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
// Flush buffered OTel spans before the process goes away; without this await
|
||||
// the exporter's final batch is dropped on exit.
|
||||
await input.shutdownInstrumentation();
|
||||
}
|
||||
|
||||
const COORDINATED_SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"] as const;
|
||||
|
||||
type ShutdownSignalTarget = {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,13 @@ import type {
|
|||
AdapterEnvironmentTestResult,
|
||||
AdapterAuthSessionResponse,
|
||||
AdapterAuthSessionOwnerResponse,
|
||||
ClaudeSetupTokenSessionResponse,
|
||||
ClaudeSetupTokenSessionOwnerResponse,
|
||||
ClaudeSetupTokenSessionPrompt,
|
||||
ClaudeSetupTokenCompletionResponse,
|
||||
ClaudeSetupTokenOverwrite,
|
||||
ClaudeOAuthTokenStatusResponse,
|
||||
SubmitBrowserCodeRequest,
|
||||
AgentKeyCreated,
|
||||
AgentRuntimeState,
|
||||
AgentTaskSession,
|
||||
|
|
@ -257,6 +264,51 @@ export const agentsApi = {
|
|||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}/cancel`,
|
||||
{},
|
||||
),
|
||||
// The Claude submitted-browser-code login uses the company-and-environment
|
||||
// setup-token routes. The route fixes the `claude_local` adapter. The start
|
||||
// response carries the panel mode; the authorization URL rides only through the
|
||||
// guarded prompt read. The completion response carries a non-secret
|
||||
// `storedSessionId` claim and no token.
|
||||
// Reads the stored Claude OAuth token status for the authenticated owner. A
|
||||
// 200 carries only the secret id and the latest version; a 404 means the owner
|
||||
// has no stored value (indistinguishable from a foreign value). The client
|
||||
// applies the stored token first and captures the version for a later
|
||||
// version-checked overwrite.
|
||||
getClaudeOAuthTokenStatus: (companyId: string) =>
|
||||
api.get<ClaudeOAuthTokenStatusResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/claude-oauth-token-status`,
|
||||
),
|
||||
startClaudeSetupTokenLogin: (
|
||||
companyId: string,
|
||||
data: { environmentId: string; overwrite?: ClaudeSetupTokenOverwrite },
|
||||
) =>
|
||||
api.post<ClaudeSetupTokenSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions`,
|
||||
{ adapterType: "claude_local", ...data },
|
||||
),
|
||||
getClaudeSetupTokenLoginStatus: (companyId: string, sessionId: string) =>
|
||||
api.get<ClaudeSetupTokenSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
),
|
||||
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string) =>
|
||||
api.get<ClaudeSetupTokenSessionPrompt>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
|
||||
),
|
||||
submitClaudeSetupTokenBrowserCode: (companyId: string, sessionId: string, browserCode: string) =>
|
||||
api.post<ClaudeSetupTokenSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/code`,
|
||||
{ browserCode } satisfies SubmitBrowserCodeRequest,
|
||||
),
|
||||
completeClaudeSetupTokenLogin: (companyId: string, sessionId: string) =>
|
||||
api.post<ClaudeSetupTokenCompletionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/completion`,
|
||||
{},
|
||||
),
|
||||
cancelClaudeSetupTokenLogin: (companyId: string, sessionId: string) =>
|
||||
api.post<void>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/cancel`,
|
||||
{},
|
||||
),
|
||||
availableSkills: () =>
|
||||
api.get<{ skills: AvailableSkill[] }>("/skills/available"),
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -10,9 +10,10 @@ import type {
|
|||
EnvSecretRefBinding,
|
||||
Environment,
|
||||
} from "@paperclipai/shared";
|
||||
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared";
|
||||
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode } from "@paperclipai/shared";
|
||||
import type { AdapterModel } from "../api/agents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { ApiError } from "../api/client";
|
||||
import { environmentsApi } from "../api/environments";
|
||||
import { instanceSettingsApi } from "../api/instanceSettings";
|
||||
import { secretsApi } from "../api/secrets";
|
||||
|
|
@ -57,6 +58,7 @@ import {
|
|||
EnvironmentVariablesEditor,
|
||||
type EnvironmentVariablesEditorHandle,
|
||||
} from "./environment-variables-editor";
|
||||
import { buildFixedClaudeOAuthBinding, CLAUDE_OAUTH_TOKEN_ENV_KEY } from "./environment-variables-editor/model";
|
||||
import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor";
|
||||
import { useProposalReview } from "../pages/secrets/proposal-review";
|
||||
import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery";
|
||||
|
|
@ -444,6 +446,108 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
const set = isCreate
|
||||
? (patch: Partial<CreateConfigValues>) => props.onChange(patch)
|
||||
: null;
|
||||
|
||||
// Create mode holds the non-secret stored-session claim after a Claude
|
||||
// subscription login reaches the server `stored` state. The claim marks the
|
||||
// fixed `CLAUDE_CODE_OAUTH_TOKEN` binding as present. The form sends the claim
|
||||
// in the create request; the server binds and enforces the token
|
||||
// independently. The claim never carries a token value.
|
||||
const claudeStoredSessionId = isCreate ? val!.claudeStoredSessionId ?? null : null;
|
||||
|
||||
// After a login binds CLAUDE_CODE_OAUTH_TOKEN, the user-secret-definitions list
|
||||
// in the cache is stale. The form reads that list once at page load, so it does
|
||||
// not contain the definition the login just bound. The bound row then compares
|
||||
// its key against the stale list and shows a false "no longer exists" health
|
||||
// error. Invalidate the list so the row reads the fresh definitions and clears
|
||||
// the error.
|
||||
const invalidateUserSecretDefinitions = () => {
|
||||
if (!selectedCompanyId) return;
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.secrets.userDefinitions(selectedCompanyId),
|
||||
});
|
||||
};
|
||||
|
||||
// Add the fixed binding to the create-mode form state after the login stores.
|
||||
// Keep every unrelated binding. Hold the claim so the create request sends it.
|
||||
const handleClaudeLoginStored = (storedSessionId: string) => {
|
||||
if (!isCreate || !set) return;
|
||||
const existingBindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
|
||||
set({
|
||||
envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() },
|
||||
claudeStoredSessionId: storedSessionId,
|
||||
});
|
||||
invalidateUserSecretDefinitions();
|
||||
};
|
||||
|
||||
// Edit mode: after a login stores the token, add the fixed binding to the
|
||||
// existing agent and persist it at once. The server already stored the token;
|
||||
// this step binds `CLAUDE_CODE_OAUTH_TOKEN` to the agent's environment through
|
||||
// the normal agent-update patch path, so no manual bind step remains. Flush any
|
||||
// pending editor draft first, keep every unrelated binding, and mark the merged
|
||||
// set into the overlay so the editor and the saved patch agree.
|
||||
//
|
||||
// An update can never consume a fresh login's stored-session claim -- only the
|
||||
// create and hire paths can, per `enforceClaudeOAuthBindingClaim`. So this save
|
||||
// must set `applyStoredClaudeLogin`, the same way `handleApplyStoredClaudeLoginEdit`
|
||||
// does, or the server rejects the patch with the fixed claim error even though
|
||||
// the login already stored the token. Without the flag every fresh login on an
|
||||
// existing agent's own page fails to save the binding.
|
||||
const handleClaudeLoginStoredEdit = async () => {
|
||||
if (isCreate) return;
|
||||
const flushedEnv = flushEnvironmentDraft();
|
||||
const baseEnv =
|
||||
flushedEnv ??
|
||||
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
|
||||
const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() };
|
||||
const nextOverlay: AgentConfigOverlay = {
|
||||
...overlay,
|
||||
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
|
||||
};
|
||||
setOverlay(nextOverlay);
|
||||
await props.onSave({
|
||||
...buildAgentUpdatePatch(props.agent, nextOverlay),
|
||||
applyStoredClaudeLogin: true,
|
||||
});
|
||||
invalidateUserSecretDefinitions();
|
||||
};
|
||||
|
||||
// Create mode: bind the fixed reference to an existing stored login with no new
|
||||
// login round trip. Add the fixed binding and set the apply-existing flag. The
|
||||
// create request sends the flag; the server binds the token only for a user
|
||||
// actor and only when a stored value exists. Keep every unrelated binding.
|
||||
const handleApplyStoredClaudeLogin = () => {
|
||||
if (!isCreate || !set) return;
|
||||
const existingBindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
|
||||
set({
|
||||
envBindings: { ...existingBindings, ...buildFixedClaudeOAuthBinding() },
|
||||
claudeApplyStoredLogin: true,
|
||||
});
|
||||
invalidateUserSecretDefinitions();
|
||||
};
|
||||
|
||||
// Edit mode: bind the fixed reference to an existing stored login with no new
|
||||
// login round trip, then persist it at once. Add the fixed binding to the
|
||||
// existing agent and save the patch with the apply-existing flag. Flush any
|
||||
// pending editor draft first and keep every unrelated binding.
|
||||
const handleApplyStoredClaudeLoginEdit = async () => {
|
||||
if (isCreate) return;
|
||||
const flushedEnv = flushEnvironmentDraft();
|
||||
const baseEnv =
|
||||
flushedEnv ??
|
||||
(eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>));
|
||||
const nextEnv = { ...baseEnv, ...buildFixedClaudeOAuthBinding() };
|
||||
const nextOverlay: AgentConfigOverlay = {
|
||||
...overlay,
|
||||
adapterConfig: { ...overlay.adapterConfig, env: nextEnv },
|
||||
};
|
||||
setOverlay(nextOverlay);
|
||||
await props.onSave({
|
||||
...buildAgentUpdatePatch(props.agent, nextOverlay),
|
||||
applyStoredClaudeLogin: true,
|
||||
});
|
||||
invalidateUserSecretDefinitions();
|
||||
};
|
||||
|
||||
const rawCurrentDefaultEnvironmentId = isCreate
|
||||
? val!.defaultEnvironmentId ?? ""
|
||||
: eff("identity", "defaultEnvironmentId", props.agent.defaultEnvironmentId ?? "");
|
||||
|
|
@ -483,6 +587,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
() => environments.find((environment) => environment.id === effectiveLoginEnvironmentId) ?? null,
|
||||
[environments, effectiveLoginEnvironmentId],
|
||||
);
|
||||
// Load the sandbox provider capabilities. The Claude setup-token login runs on
|
||||
// a real pseudo-terminal, so it needs a provider that advertises the
|
||||
// setup-token login capability. The login panel gate reads this to hide the
|
||||
// panel for a provider without the capability. The gate is advisory; the
|
||||
// server resolves the capability again and fails closed.
|
||||
const { data: environmentCapabilities } = useQuery({
|
||||
queryKey: selectedCompanyId
|
||||
? queryKeys.environments.capabilities(selectedCompanyId)
|
||||
: ["environment-capabilities", "none"],
|
||||
queryFn: () => environmentsApi.capabilities(selectedCompanyId!),
|
||||
enabled: Boolean(selectedCompanyId) && adapterType === "claude_local",
|
||||
});
|
||||
|
||||
// When the instance forces Kubernetes execution, new agents must default to the
|
||||
// managed Kubernetes sandbox environment (never the implicit local default).
|
||||
|
|
@ -790,24 +906,68 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
// re-run on every render (the mutation object has a new identity each render).
|
||||
const resetTestEnvironmentRef = useRef(testEnvironment.reset);
|
||||
resetTestEnvironmentRef.current = testEnvironment.reset;
|
||||
|
||||
// Clear a stale Claude login claim when the adapter type or the effective
|
||||
// environment changes. A login binds `CLAUDE_CODE_OAUTH_TOKEN` to one specific
|
||||
// environment. After the environment changes, the held claim and the fixed
|
||||
// binding no longer match the new target. The create request would still send
|
||||
// the claim, and the server would reject it. So drop the claim, the
|
||||
// apply-existing flag, and the fixed binding, and return the login panel to
|
||||
// its initial state. Only create mode holds this form state; edit mode saves
|
||||
// the binding at login time. Hold the clear in a ref so the effect does not
|
||||
// re-run on every render (`set` and `val` have a new identity each render).
|
||||
const clearClaudeLoginClaimRef = useRef<() => void>(() => {});
|
||||
clearClaudeLoginClaimRef.current = () => {
|
||||
if (!isCreate || !set) return;
|
||||
const bindings = (val!.envBindings ?? {}) as Record<string, EnvBinding>;
|
||||
const hasClaim =
|
||||
val!.claudeStoredSessionId != null || val!.claudeApplyStoredLogin === true;
|
||||
const hasBinding = CLAUDE_OAUTH_TOKEN_ENV_KEY in bindings;
|
||||
if (!hasClaim && !hasBinding) return;
|
||||
const nextBindings = { ...bindings };
|
||||
delete nextBindings[CLAUDE_OAUTH_TOKEN_ENV_KEY];
|
||||
set({
|
||||
envBindings: nextBindings,
|
||||
claudeStoredSessionId: null,
|
||||
claudeApplyStoredLogin: false,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetTestEnvironmentRef.current();
|
||||
setTestActionError(null);
|
||||
clearClaudeLoginClaimRef.current();
|
||||
}, [adapterType, effectiveLoginEnvironmentId]);
|
||||
|
||||
// Show the login affordance only for a current `codex_local` sandbox whose most
|
||||
// recent Test result carries the canonical auth-missing check. The result keeps
|
||||
// its own `adapterType`, so a result from another adapter never gates the panel.
|
||||
// Show the login affordance only for a current `codex_local` or `claude_local`
|
||||
// sandbox whose most recent Test result carries the canonical auth-missing
|
||||
// check. Both adapters emit the same neutral code. The result keeps its own
|
||||
// `adapterType`, so a result from another adapter never gates the panel.
|
||||
const adapterSupportsSandboxLogin =
|
||||
adapterType === "codex_local" || adapterType === "claude_local";
|
||||
const authMissingCheck =
|
||||
testEnvironment.data?.adapterType === "codex_local"
|
||||
testEnvironment.data?.adapterType === "codex_local" ||
|
||||
testEnvironment.data?.adapterType === "claude_local"
|
||||
? testEnvironment.data.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
|
||||
: null;
|
||||
// The Claude login runs on a real pseudo-terminal, so it needs a provider that
|
||||
// advertises the setup-token login capability. Read the capability for the
|
||||
// effective environment provider. The codex login uses a different flow and
|
||||
// does not gate on this capability, so the requirement applies to Claude only.
|
||||
const effectiveLoginProvider =
|
||||
typeof effectiveLoginEnvironment?.config?.provider === "string"
|
||||
? effectiveLoginEnvironment.config.provider
|
||||
: null;
|
||||
const providerSupportsSetupTokenLogin =
|
||||
effectiveLoginProvider != null &&
|
||||
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsSetupTokenLogin === true;
|
||||
const showAdapterLogin =
|
||||
adapterType === "codex_local" &&
|
||||
adapterSupportsSandboxLogin &&
|
||||
effectiveLoginEnvironment?.driver === "sandbox" &&
|
||||
Boolean(effectiveLoginEnvironmentId) &&
|
||||
Boolean(selectedCompanyId) &&
|
||||
Boolean(authMissingCheck);
|
||||
Boolean(authMissingCheck) &&
|
||||
(adapterType !== "claude_local" || providerSupportsSetupTokenLogin);
|
||||
const runEnvironmentTest = useCallback(async () => {
|
||||
if (!selectedCompanyId) {
|
||||
throw new Error("Select a company to test adapter environment");
|
||||
|
|
@ -1344,6 +1504,10 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
companyId={selectedCompanyId!}
|
||||
adapterType={adapterType}
|
||||
environmentId={effectiveLoginEnvironmentId!}
|
||||
onStored={isCreate ? handleClaudeLoginStored : handleClaudeLoginStoredEdit}
|
||||
onApplyStored={
|
||||
isCreate ? handleApplyStoredClaudeLogin : handleApplyStoredClaudeLoginEdit
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -1560,8 +1724,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
value={
|
||||
isCreate
|
||||
? ((val!.envBindings ?? EMPTY_ENV) as Record<string, EnvBinding>)
|
||||
: ((eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
|
||||
)
|
||||
: (eff("adapterConfig", "env", (config.env ?? EMPTY_ENV) as Record<string, EnvBinding>))
|
||||
}
|
||||
secrets={availableSecrets}
|
||||
userSecretDefinitions={userSecretDefinitions}
|
||||
|
|
@ -1837,11 +2000,32 @@ export type AdapterLoginDescriptor = {
|
|||
environmentId: string;
|
||||
};
|
||||
|
||||
export function AdapterLoginPanel({
|
||||
// The panel props. `onStored` reports the non-secret `storedSessionId` claim from
|
||||
// a Claude login that reaches the server `stored` state. The create-mode form
|
||||
// holds that claim for the fixed binding; the callback never carries a token.
|
||||
// `onApplyStored` binds the fixed reference to an existing stored login with no
|
||||
// new login round trip. The panel shows the apply-existing affordance only when
|
||||
// the status route reports a stored value.
|
||||
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
||||
onStored?: (storedSessionId: string) => void;
|
||||
onApplyStored?: () => void;
|
||||
};
|
||||
|
||||
// The login panel dispatcher. It picks the panel mode from the adapter. The
|
||||
// Claude adapter shows the submitted-browser-code panel. Every other adapter
|
||||
// shows the displayed-code panel.
|
||||
export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
|
||||
if (props.adapterType === "claude_local") {
|
||||
return <SubmittedBrowserCodeLoginPanel {...props} />;
|
||||
}
|
||||
return <DisplayedCodeLoginPanel {...props} />;
|
||||
}
|
||||
|
||||
function DisplayedCodeLoginPanel({
|
||||
companyId,
|
||||
adapterType,
|
||||
environmentId,
|
||||
}: AdapterLoginDescriptor) {
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
// The server delivers the one-time prompt on the first owner read only. Latch
|
||||
|
|
@ -1996,6 +2180,546 @@ export function AdapterLoginPanel({
|
|||
);
|
||||
}
|
||||
|
||||
// The public session states that fail a Claude login. The panel returns to its
|
||||
// start state and shows a fixed message at one of these.
|
||||
const CLAUDE_LOGIN_FAILURE_STATUSES = new Set<AdapterAuthSessionStatus>([
|
||||
"failed",
|
||||
"timed_out",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
// The fixed, non-secret message for a failed Claude login. The panel shows this
|
||||
// text and returns to its start state. It never shows a provider message that
|
||||
// could carry a secret.
|
||||
const CLAUDE_LOGIN_FAILED_MESSAGE = "The login did not finish. Start the login again.";
|
||||
|
||||
// The client wall-clock cap for one active login. The panel polls the status
|
||||
// route and the prompt route every two seconds. The server can leave a session
|
||||
// short of a terminal status, and the prompt route returns 404 until the URL is
|
||||
// ready. Without a cap, the panel polls forever. When this cap passes, the panel
|
||||
// stops both polls and shows the timed-out state, so the user can start again.
|
||||
// A generous client failsafe for a live login whose server deadline has not
|
||||
// arrived yet. The server `expiresAt` is the real cutoff; the panel drives the
|
||||
// timer from it. This failsafe only bounds a stuck poll while the status route
|
||||
// has not yet returned `expiresAt`, so a login can never poll forever. The
|
||||
// value is far longer than the server budget, so it never cuts a normal login
|
||||
// short (the earlier fixed 60-second cutoff did, which drove this fix).
|
||||
const CLAUDE_LOGIN_FAILSAFE_TIMEOUT_MS = 15 * 60_000;
|
||||
|
||||
// The fixed, non-secret message for a timed-out Claude login. The panel shows
|
||||
// this text, stops both polls, and returns to its start state.
|
||||
const CLAUDE_LOGIN_TIMED_OUT_MESSAGE = "The login timed out. Start the login again.";
|
||||
|
||||
// The submitted-browser-code login panel for the Claude adapter. It starts a
|
||||
// setup-token login, polls the status route, and reads the authorization URL
|
||||
// from the guarded prompt route. The user opens the URL, reads the browser code,
|
||||
// and submits it. The panel clears the code right after submit. The panel treats
|
||||
// only the server `stored` state as success, and it never shows the OAuth token.
|
||||
function SubmittedBrowserCodeLoginPanel({
|
||||
companyId,
|
||||
environmentId,
|
||||
onStored,
|
||||
onApplyStored,
|
||||
}: AdapterLoginPanelProps) {
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
// The server delivers the authorization URL on the guarded prompt read only.
|
||||
// Latch it so a later poll does not hide the URL.
|
||||
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null);
|
||||
// True when the guarded prompt read reports a non-confidential transport. The
|
||||
// server does not block a plain-HTTP login. The panel shows a non-blocking
|
||||
// disclaimer and the login still proceeds. Latch it beside the URL.
|
||||
const [transportInsecure, setTransportInsecure] = useState(false);
|
||||
// The browser code the user submits. The panel clears it right after submit, so
|
||||
// the secret never lingers in the input.
|
||||
const [browserCode, setBrowserCode] = useState("");
|
||||
// The non-secret stored-session claim from a completed login. It marks the
|
||||
// server `stored` state, the only success state.
|
||||
const [storedSessionId, setStoredSessionId] = useState<string | null>(null);
|
||||
// True after a completion read fails. The panel returns to its start state.
|
||||
const [completionFailed, setCompletionFailed] = useState(false);
|
||||
// True after the client wall-clock cap passes for the active login. The panel
|
||||
// stops both polls and shows the timed-out state.
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
// True after the status poll returns 404. The server removes the row and the
|
||||
// in-memory session at once on any non-stored terminal state, so a status 404
|
||||
// means the login failed and the server cleaned up. The panel stops both
|
||||
// polls and shows a terminal failure state. It shows no credential material.
|
||||
const [statusGone, setStatusGone] = useState(false);
|
||||
// Guards the one completion read per session.
|
||||
const completionStartedRef = useRef(false);
|
||||
// True after the owner applies an existing stored login in this panel. The
|
||||
// apply-existing path binds the fixed reference with no new login round trip,
|
||||
// so the panel shows the applied confirmation and hides the apply affordance.
|
||||
const [appliedStored, setAppliedStored] = useState(false);
|
||||
|
||||
const resetLocalState = () => {
|
||||
setStartError(null);
|
||||
setAuthorizationUrl(null);
|
||||
setTransportInsecure(false);
|
||||
setBrowserCode("");
|
||||
setStoredSessionId(null);
|
||||
setCompletionFailed(false);
|
||||
setTimedOut(false);
|
||||
setStatusGone(false);
|
||||
completionStartedRef.current = false;
|
||||
};
|
||||
|
||||
// Read the stored Claude OAuth token status when the panel mounts. A 200 body
|
||||
// carries the secret id and the version of the owner value; a 404 means the
|
||||
// owner has no stored value. The panel applies the stored token first: when a
|
||||
// value exists, a replacement login rotates it under the captured version
|
||||
// instead of a blind first write. The server rejects a stale capture, so a
|
||||
// change by another process after the capture fails the overwrite.
|
||||
const storedTokenQuery = useQuery({
|
||||
queryKey: ["claude-oauth-token-status", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.getClaudeOAuthTokenStatus(companyId);
|
||||
} catch (error) {
|
||||
// A 404 means the owner has no stored value (indistinguishable from a
|
||||
// foreign value). The panel treats it as "no stored token".
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 2;
|
||||
},
|
||||
});
|
||||
const storedToken = storedTokenQuery.data ?? null;
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () =>
|
||||
agentsApi.startClaudeSetupTokenLogin(companyId, {
|
||||
environmentId,
|
||||
// When the owner already has a stored token, the login rotates it under
|
||||
// the captured version, so a replacement login never conflicts with an
|
||||
// existing value. Without a stored token the login is a first write.
|
||||
...(storedToken
|
||||
? {
|
||||
overwrite: {
|
||||
expectedSecretId: storedToken.secretId,
|
||||
expectedLatestVersion: storedToken.latestVersion,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
onSuccess: (session) => {
|
||||
resetLocalState();
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not start the login.");
|
||||
},
|
||||
});
|
||||
|
||||
const clearActiveSession = () => {
|
||||
// Return the panel to its idle start state. The Log in button is available
|
||||
// again, and both polls stop because the session id is null.
|
||||
setSessionId(null);
|
||||
resetLocalState();
|
||||
};
|
||||
|
||||
const cancelLogin = useMutation({
|
||||
mutationFn: () => agentsApi.cancelClaudeSetupTokenLogin(companyId, sessionId!),
|
||||
onSuccess: () => {
|
||||
clearActiveSession();
|
||||
},
|
||||
onError: (error) => {
|
||||
// The cancel is resilient. The server removes a terminal session, so a
|
||||
// cancel of an already-terminal or unknown session can return a 404. Treat
|
||||
// that 404 the same as a successful cancel: clear the session and stop the
|
||||
// polls. The panel never keeps polling a session the server no longer
|
||||
// holds. Any other error surfaces and keeps the active login.
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
clearActiveSession();
|
||||
return;
|
||||
}
|
||||
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
|
||||
},
|
||||
});
|
||||
|
||||
// Release the server session at once, without a change to the panel state. The
|
||||
// client-cutoff timer and the unmount path both use this. The server holds a
|
||||
// per-owner reservation until the session reaches a terminal state, so an
|
||||
// abandoned session locks the owner out until the server deadline. A best-
|
||||
// effort cancel frees that reservation now, so the same owner can start a new
|
||||
// login and does not hit the "too many active sessions" cap. The server
|
||||
// removes a terminal session, so a 404 means the session is already gone. Treat
|
||||
// that 404 the same as a successful cancel. This is a fire-and-forget cleanup,
|
||||
// so it drops every error. The manual Cancel button uses the `cancelLogin`
|
||||
// mutation instead, because that path also returns the panel to its idle start
|
||||
// state.
|
||||
const releaseServerSession = useCallback(
|
||||
(id: string) => {
|
||||
void agentsApi.cancelClaudeSetupTokenLogin(companyId, id).catch(() => {
|
||||
// Drop the error. A 404 means the server already removed the session. A
|
||||
// cleanup path cannot surface any other error, so it stays silent.
|
||||
});
|
||||
},
|
||||
[companyId],
|
||||
);
|
||||
|
||||
// Both polls run only while a session is active and the client cap has not
|
||||
// passed. The timeout stops the polls, so the panel never polls forever. A
|
||||
// status 404 also stops the polls: the server cleaned up the session, so the
|
||||
// panel enters a terminal failure state instead.
|
||||
const pollingEnabled = Boolean(sessionId) && !timedOut && !statusGone;
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-status", companyId, sessionId],
|
||||
queryFn: () => agentsApi.getClaudeSetupTokenLoginStatus(companyId, sessionId!),
|
||||
enabled: pollingEnabled,
|
||||
// A status 404 is terminal. The server removes a cleaned-up session at once,
|
||||
// so a retry cannot recover it. Stop at once and fail loudly.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
if (timedOut || statusGone) return false;
|
||||
const status = query.state.data?.status;
|
||||
return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status)
|
||||
? false
|
||||
: ADAPTER_LOGIN_POLL_INTERVAL_MS;
|
||||
},
|
||||
});
|
||||
|
||||
// Fail loudly on a status 404. The server cleans up the reservation, the row,
|
||||
// and the in-memory session at once on any non-stored terminal state, so the
|
||||
// status route returns 404 when a login fails and the server cleanup wins the
|
||||
// race against the next poll. React Query keeps the last successful data on
|
||||
// error, so without this branch the panel would hold stale data and show
|
||||
// nothing. Enter the terminal failure state, which stops both polls.
|
||||
useEffect(() => {
|
||||
const error = statusQuery.error;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setStatusGone(true);
|
||||
}
|
||||
}, [statusQuery.error]);
|
||||
|
||||
// Poll the guarded prompt route until it returns the authorization URL. The
|
||||
// route returns 404 until the URL is ready, so the panel treats a 404 as
|
||||
// not-ready and keeps polling.
|
||||
const promptQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-prompt", companyId, sessionId],
|
||||
enabled: pollingEnabled && !authorizationUrl,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.getClaudeSetupTokenLoginPrompt(companyId, sessionId!);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
refetchInterval: (query) =>
|
||||
timedOut || query.state.data?.authorizationUrl ? false : ADAPTER_LOGIN_POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const url = promptQuery.data?.authorizationUrl ?? null;
|
||||
if (url) setAuthorizationUrl(url);
|
||||
// The advisory rides the same guarded prompt read as the URL. Latch it, so a
|
||||
// later poll does not clear the disclaimer.
|
||||
if (promptQuery.data?.transportAdvisory) setTransportInsecure(true);
|
||||
}, [promptQuery.data]);
|
||||
|
||||
const submitCode = useMutation({
|
||||
mutationFn: (code: string) =>
|
||||
agentsApi.submitClaudeSetupTokenBrowserCode(companyId, sessionId!, code),
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not submit the browser code.");
|
||||
},
|
||||
});
|
||||
|
||||
const completeLogin = useMutation({
|
||||
mutationFn: () => agentsApi.completeClaudeSetupTokenLogin(companyId, sessionId!),
|
||||
onSuccess: (result) => {
|
||||
// The server reached the `stored` state. Hold the non-secret claim and
|
||||
// report it to the parent. The response carries no token.
|
||||
setStoredSessionId(result.storedSessionId);
|
||||
onStored?.(result.storedSessionId);
|
||||
},
|
||||
onError: () => {
|
||||
setCompletionFailed(true);
|
||||
},
|
||||
});
|
||||
|
||||
const status = statusQuery.data?.status ?? startLogin.data?.status ?? null;
|
||||
// The server deadline for the active session (ISO string). The status route
|
||||
// and the start response both carry it. The panel drives the client cutoff
|
||||
// from this value, so the client and the server share one deadline.
|
||||
const expiresAt = statusQuery.data?.expiresAt ?? startLogin.data?.expiresAt ?? null;
|
||||
|
||||
// Complete the login once the server authenticates the session. The completion
|
||||
// read returns the non-secret stored-session claim. Fire it once per session.
|
||||
const completeLoginRef = useRef(completeLogin.mutate);
|
||||
completeLoginRef.current = completeLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (status === "authenticated" && !completionStartedRef.current) {
|
||||
completionStartedRef.current = true;
|
||||
completeLoginRef.current();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const isStored = storedSessionId !== null;
|
||||
const isFailure =
|
||||
completionFailed ||
|
||||
statusGone ||
|
||||
Boolean(status && CLAUDE_LOGIN_FAILURE_STATUSES.has(status));
|
||||
const isCompleting = status === "authenticated" && !isStored && !completionFailed;
|
||||
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut;
|
||||
const startDisabled = startLogin.isPending || isActive;
|
||||
|
||||
// Hold the active session id for the unmount cleanup. The panel updates it on
|
||||
// every render. When the panel unmounts, or the parent removes it as the login
|
||||
// closes, with an active, non-terminal session, the cleanup releases that
|
||||
// session on the server. The ref is null once the session leaves the active
|
||||
// state, so the cleanup never cancels a session the server already removed.
|
||||
const activeSessionRef = useRef<string | null>(null);
|
||||
activeSessionRef.current = isActive ? sessionId : null;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const id = activeSessionRef.current;
|
||||
if (id) releaseServerSession(id);
|
||||
};
|
||||
}, [releaseServerSession]);
|
||||
|
||||
// Cap the active login at the server deadline. The timer arms when the login
|
||||
// becomes active and clears when the login leaves the active state (a terminal
|
||||
// status, a stored success, or a new login). It re-arms when `expiresAt`
|
||||
// arrives or changes. When it fires, the panel enters the timed-out state. The
|
||||
// `isActive` guard already excludes `timedOut`, so the timer does not re-arm
|
||||
// after it fires.
|
||||
//
|
||||
// The delay comes from the server `expiresAt`, so the client cutoff matches
|
||||
// the server session budget instead of a fixed short window. While `expiresAt`
|
||||
// has not arrived for the live session, the panel uses a generous failsafe, so
|
||||
// a stuck poll still stops.
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
const deadlineMs = expiresAt !== null ? new Date(expiresAt).getTime() : Number.NaN;
|
||||
const remainingMs = Number.isFinite(deadlineMs)
|
||||
? Math.max(0, deadlineMs - Date.now())
|
||||
: CLAUDE_LOGIN_FAILSAFE_TIMEOUT_MS;
|
||||
const timer = setTimeout(() => {
|
||||
// Release the server session first, then show the timed-out state. The
|
||||
// cancel frees the server reservation now, so an immediate retry by the
|
||||
// same owner starts a new session instead of a lockout on the per-owner
|
||||
// cap. The panel keeps the timed-out state, so the user sees why the login
|
||||
// stopped.
|
||||
if (sessionId) releaseServerSession(sessionId);
|
||||
setTimedOut(true);
|
||||
}, remainingMs);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isActive, expiresAt, sessionId, releaseServerSession]);
|
||||
|
||||
const trimmedCode = browserCode.trim();
|
||||
const canSubmit =
|
||||
isActive &&
|
||||
Boolean(authorizationUrl) &&
|
||||
!isCompleting &&
|
||||
isValidBrowserCode(trimmedCode) &&
|
||||
!submitCode.isPending;
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!canSubmit) return;
|
||||
submitCode.mutate(trimmedCode);
|
||||
// Clear the browser code right after submit, so the secret never lingers in
|
||||
// the input.
|
||||
setBrowserCode("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-foreground">Sign in to the sandbox</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isActive && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs text-muted-foreground hover:text-foreground"
|
||||
disabled={cancelLogin.isPending}
|
||||
onClick={() => cancelLogin.mutate()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{/* Apply the existing stored login with no new login round trip. The
|
||||
affordance shows only when the status route reports a stored value
|
||||
and no active or completed login runs in this panel. */}
|
||||
{onApplyStored && storedToken && !isActive && !isStored && !appliedStored && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
onClick={() => {
|
||||
onApplyStored();
|
||||
setAppliedStored(true);
|
||||
}}
|
||||
>
|
||||
Use saved login
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={startDisabled}
|
||||
onClick={() => startLogin.mutate()}
|
||||
>
|
||||
{storedToken && !isActive && !isStored ? "Log in to replace" : "Log in"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* When the owner already has a stored Claude login, the panel applies it
|
||||
first and does not force a fresh login. Use saved login binds the stored
|
||||
token; a replacement login rotates it under the captured version. */}
|
||||
{storedToken && !isActive && !isStored && !appliedStored && (
|
||||
<div className="text-(length:--text-micro) text-muted-foreground">
|
||||
You have a saved Claude login. Use it to bind this agent, or log in again to replace the
|
||||
stored token.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appliedStored && (
|
||||
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
|
||||
<Check className="size-3 shrink-0" />
|
||||
<span>The saved Claude login is bound to this agent now.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{startError && (
|
||||
<div role="alert" className="text-(length:--text-micro) text-destructive">
|
||||
{startError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* One live region announces the loading, prompt, completing, and terminal
|
||||
states, so a screen reader reports each transition. */}
|
||||
<div role="status" aria-live="polite" className="space-y-2 empty:hidden">
|
||||
{isActive && !authorizationUrl && !isCompleting && (
|
||||
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
<span>Preparing the login…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isActive && authorizationUrl && !isCompleting && (
|
||||
<div className="space-y-2">
|
||||
{transportInsecure && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-2 py-1.5 text-(length:--text-micro) text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200"
|
||||
>
|
||||
<TriangleAlert className="size-3 shrink-0 mt-0.5" />
|
||||
<span>
|
||||
This connection is not encrypted. The login code travels in clear text on this
|
||||
network. Continue only on a network you trust.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="text-(length:--text-micro) text-muted-foreground">
|
||||
Open the authorization page, then enter the browser code it shows.
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
|
||||
Authorization URL
|
||||
</div>
|
||||
<span className="font-mono text-xs text-foreground break-all">{authorizationUrl}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<AdapterLoginCopyButton value={authorizationUrl} label="Copy URL" />
|
||||
<Button
|
||||
asChild
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label="Open the authorization page"
|
||||
title="Open the authorization page"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<a href={authorizationUrl} target="_blank" rel="noreferrer noopener">
|
||||
<ExternalLink className="size-3" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">
|
||||
Browser code
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
aria-label="Browser code"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={browserCode}
|
||||
onChange={(event) => setBrowserCode(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-border bg-background px-2 py-1 font-mono text-xs text-foreground"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs"
|
||||
disabled={!canSubmit}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCompleting && (
|
||||
<div className="flex items-center gap-2 text-(length:--text-micro) text-muted-foreground">
|
||||
<Loader2 className="size-3 animate-spin shrink-0" />
|
||||
<span>Completing the login…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStored && (
|
||||
<div className="flex items-center gap-2 text-(length:--text-micro) text-foreground">
|
||||
<Check className="size-3 shrink-0" />
|
||||
<span>Authenticated. The sandbox has credentials now.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFailure && (
|
||||
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
|
||||
<TriangleAlert className="size-3 shrink-0" />
|
||||
<span>{CLAUDE_LOGIN_FAILED_MESSAGE}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timedOut && !isFailure && !isStored && (
|
||||
<div className="flex items-start gap-2 text-(length:--text-micro) text-destructive">
|
||||
<TriangleAlert className="size-3 shrink-0" />
|
||||
<span>{CLAUDE_LOGIN_TIMED_OUT_MESSAGE}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestResult }) {
|
||||
const statusLabel =
|
||||
result.status === "pass" ? "Passed" : result.status === "warn" ? "Warnings" : "Failed";
|
||||
|
|
|
|||
|
|
@ -134,6 +134,33 @@ export function valueFromRows(rows: EnvRow[]): Record<string, EnvBinding> | unde
|
|||
return Object.keys(record).length > 0 ? record : undefined;
|
||||
}
|
||||
|
||||
// The fixed environment variable name for the Claude subscription OAuth token.
|
||||
// A subscription login stores the token as a user secret under this key. The
|
||||
// adapter reads the variable at run start.
|
||||
export const CLAUDE_OAUTH_TOKEN_ENV_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
|
||||
|
||||
/**
|
||||
* Build the fixed `CLAUDE_CODE_OAUTH_TOKEN` binding. The binding is a
|
||||
* `user_secret_ref`, so the adapter config holds a reference to the user secret,
|
||||
* never the token value. This reuses the editor `user_secret_ref` builder in
|
||||
* {@link valueFromRows}, so the fixed binding keeps the same shape as an
|
||||
* operator-authored user-secret row.
|
||||
*/
|
||||
export function buildFixedClaudeOAuthBinding(): Record<string, EnvBinding> {
|
||||
return (
|
||||
valueFromRows([
|
||||
{
|
||||
...emptyRow("user_secret"),
|
||||
name: CLAUDE_OAUTH_TOKEN_ENV_KEY,
|
||||
userSecretKey: CLAUDE_OAUTH_TOKEN_ENV_KEY,
|
||||
required: true,
|
||||
version: "latest",
|
||||
},
|
||||
]) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export type NameIssueLevel = "error" | "warn";
|
||||
|
|
|
|||
|
|
@ -42,6 +42,32 @@ describe("buildNewAgentHirePayload", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("sends the apply-existing flag when the owner applies a stored Claude login", () => {
|
||||
const payload = buildNewAgentHirePayload({
|
||||
name: "Stored Claude",
|
||||
effectiveRole: "general",
|
||||
configValues: {
|
||||
...defaultCreateValues,
|
||||
adapterType: "claude_local",
|
||||
claudeApplyStoredLogin: true,
|
||||
},
|
||||
adapterConfig: {},
|
||||
});
|
||||
expect(payload).toMatchObject({ applyStoredClaudeLogin: true });
|
||||
// Apply-existing carries no stored-session claim.
|
||||
expect("storedSessionId" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("omits the apply-existing flag when the owner does not apply a stored login", () => {
|
||||
const payload = buildNewAgentHirePayload({
|
||||
name: "Fresh Claude",
|
||||
effectiveRole: "general",
|
||||
configValues: { ...defaultCreateValues, adapterType: "claude_local" },
|
||||
adapterConfig: {},
|
||||
});
|
||||
expect("applyStoredClaudeLogin" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("includes core trust preset permissions when provided", () => {
|
||||
expect(
|
||||
buildNewAgentHirePayload({
|
||||
|
|
|
|||
|
|
@ -40,5 +40,15 @@ export function buildNewAgentHirePayload(input: {
|
|||
}),
|
||||
budgetMonthlyCents: 0,
|
||||
...(permissions ? { permissions } : {}),
|
||||
// The stored-session claim is not an agent column. The server derives the
|
||||
// owner from the actor and consumes the claim to bind the fixed OAuth token.
|
||||
// Send it only after a Claude subscription login reaches the `stored` state.
|
||||
...(configValues.claudeStoredSessionId
|
||||
? { storedSessionId: configValues.claudeStoredSessionId }
|
||||
: {}),
|
||||
// The apply-existing flag is not an agent column. The server binds the fixed
|
||||
// OAuth token reference to the owner stored value with no login round trip.
|
||||
// Send it only after the owner applies an existing stored login.
|
||||
...(configValues.claudeApplyStoredLogin ? { applyStoredClaudeLogin: true } : {}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ const environmentCapabilities: EnvironmentCapabilities = {
|
|||
interactiveSetupConnectionTypes: [],
|
||||
supportsTemplateCapture: false,
|
||||
supportsTemplateDelete: false,
|
||||
supportsSetupTokenLogin: false,
|
||||
displayName: "Fake",
|
||||
source: "builtin",
|
||||
},
|
||||
|
|
@ -206,6 +207,7 @@ const environmentCapabilities: EnvironmentCapabilities = {
|
|||
interactiveSetupConnectionTypes: ["ssh"],
|
||||
supportsTemplateCapture: true,
|
||||
supportsTemplateDelete: true,
|
||||
supportsSetupTokenLogin: true,
|
||||
displayName: "Daytona",
|
||||
source: "plugin",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,380 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getEnvironmentCapabilities } from "@paperclipai/shared";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ToastProvider } from "../context/ToastContext";
|
||||
import { NewAgent } from "./NewAgent";
|
||||
|
||||
// The real adapter registry stays in place so the create request runs the real
|
||||
// Claude config builder. That builder turns the fixed binding into the adapter
|
||||
// `env` map, which is the contract this page test verifies.
|
||||
|
||||
const mockAgentsApi = vi.hoisted(() => ({
|
||||
adapterModelProfiles: vi.fn(),
|
||||
adapterModels: vi.fn(),
|
||||
detectModel: vi.fn(),
|
||||
list: vi.fn(),
|
||||
hire: vi.fn(),
|
||||
testEnvironment: vi.fn(),
|
||||
getClaudeOAuthTokenStatus: vi.fn(),
|
||||
startClaudeSetupTokenLogin: vi.fn(),
|
||||
getClaudeSetupTokenLoginStatus: vi.fn(),
|
||||
getClaudeSetupTokenLoginPrompt: vi.fn(),
|
||||
submitClaudeSetupTokenBrowserCode: vi.fn(),
|
||||
completeClaudeSetupTokenLogin: vi.fn(),
|
||||
cancelClaudeSetupTokenLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockEnvironmentsApi = vi.hoisted(() => ({ list: vi.fn(), capabilities: vi.fn() }));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
getExperimental: vi.fn(),
|
||||
getGeneral: vi.fn(),
|
||||
}));
|
||||
const mockSecretsApi = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
listProposals: vi.fn(),
|
||||
listUserSecretDefinitions: vi.fn(),
|
||||
}));
|
||||
const mockCompanySkillsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockIssuesApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockProjectsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
||||
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
|
||||
const mockClipboard = vi.hoisted(() => ({ copyTextToClipboard: vi.fn() }));
|
||||
const navigateMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
useNavigate: () => navigateMock,
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock("../context/CompanyContext", () => ({
|
||||
useCompany: () => ({ selectedCompanyId: "company-1" }),
|
||||
}));
|
||||
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
|
||||
vi.mock("../api/environments", () => ({ environmentsApi: mockEnvironmentsApi }));
|
||||
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
|
||||
vi.mock("../api/secrets", () => ({ secretsApi: mockSecretsApi }));
|
||||
vi.mock("../api/companySkills", () => ({ companySkillsApi: mockCompanySkillsApi }));
|
||||
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
|
||||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
|
||||
vi.mock("../lib/clipboard", () => ({
|
||||
copyTextToClipboard: mockClipboard.copyTextToClipboard,
|
||||
}));
|
||||
|
||||
vi.mock("../adapters/use-disabled-adapters", () => ({
|
||||
useDisabledAdaptersSync: () => new Set<string>(),
|
||||
}));
|
||||
|
||||
vi.mock("../components/MarkdownEditor", () => ({
|
||||
MarkdownEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label={placeholder ?? "Markdown"}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
async function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> = undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
await result;
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Flush React effects and pending promises until a condition holds. The Claude
|
||||
// login chains a start, a status poll, and a completion read, so a single flush
|
||||
// does not settle every state transition.
|
||||
async function flushUntil(check: () => boolean, timeoutMs = 4000) {
|
||||
const start = Date.now();
|
||||
while (!check()) {
|
||||
if (Date.now() - start > timeoutMs) break;
|
||||
await flushReact();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
function findButton(container: HTMLElement, label: string) {
|
||||
return Array.from(container.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
);
|
||||
}
|
||||
|
||||
async function clickByText(container: HTMLElement, label: string) {
|
||||
const button = findButton(container, label);
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
}
|
||||
|
||||
const CLAUDE_AUTH_MISSING_RESULT = {
|
||||
adapterType: "claude_local",
|
||||
status: "warn",
|
||||
checks: [
|
||||
{
|
||||
code: "claude_hello_probe_auth_required",
|
||||
level: "warn",
|
||||
message: "Claude CLI is installed, but login is required.",
|
||||
},
|
||||
{
|
||||
code: "adapter_auth_missing",
|
||||
level: "warn",
|
||||
message: "The sandbox has no ready authentication for this adapter.",
|
||||
},
|
||||
],
|
||||
testedAt: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
// The provider capabilities the form fetches. Daytona advertises the
|
||||
// setup-token login capability; E2B does not. The Claude login panel shows only
|
||||
// for a provider with the capability, so the sandbox environment uses Daytona.
|
||||
const SANDBOX_CAPABILITIES = getEnvironmentCapabilities(["claude_local", "codex_local"], {
|
||||
sandboxProviders: {
|
||||
daytona: { supportsSetupTokenLogin: true, displayName: "Daytona" },
|
||||
e2b: { supportsSetupTokenLogin: false, displayName: "E2B" },
|
||||
},
|
||||
});
|
||||
|
||||
async function renderNewAgent() {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider>
|
||||
<NewAgent />
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
// Complete the Claude subscription login on the page: run the environment test,
|
||||
// start the login, and let the panel reach the server `stored` state.
|
||||
async function completeClaudeLogin(container: HTMLElement) {
|
||||
await clickByText(container, "Test Agent");
|
||||
await flushUntil(() => Boolean(findButton(container, "Log in")));
|
||||
await clickByText(container, "Log in");
|
||||
await flushUntil(() => (container.textContent ?? "").includes("Authenticated"));
|
||||
}
|
||||
|
||||
describe("NewAgent Claude subscription login", () => {
|
||||
let roots: Root[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
mockAgentsApi.adapterModelProfiles.mockResolvedValue([]);
|
||||
mockAgentsApi.adapterModels.mockResolvedValue([]);
|
||||
mockAgentsApi.detectModel.mockResolvedValue(null);
|
||||
// No existing agents: the page treats the new agent as the first (CEO) and
|
||||
// seeds the name, so the Create button is enabled without a typed name.
|
||||
mockAgentsApi.list.mockResolvedValue([]);
|
||||
mockAgentsApi.hire.mockResolvedValue({ agent: { id: "agent-1", name: "CEO" } });
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
// The default owner has no stored Claude token, so the login is a first
|
||||
// write. A test overrides this to prove the panel applies a stored token
|
||||
// first and passes the captured version as a version-checked overwrite.
|
||||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue(null);
|
||||
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "starting",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: null,
|
||||
});
|
||||
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "authenticated",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
});
|
||||
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockResolvedValue({
|
||||
authorizationUrl: "https://claude.example.test/authorize",
|
||||
});
|
||||
mockAgentsApi.submitClaudeSetupTokenBrowserCode.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "authenticated",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
});
|
||||
mockAgentsApi.completeClaudeSetupTokenLogin.mockResolvedValue({
|
||||
storedSessionId: "stored-session-1",
|
||||
});
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockResolvedValue(undefined);
|
||||
|
||||
mockEnvironmentsApi.list.mockResolvedValue([
|
||||
{ id: "local-1", name: "Local", driver: "local", config: {} },
|
||||
{
|
||||
id: "sandbox-1",
|
||||
name: "Daytona",
|
||||
driver: "sandbox",
|
||||
config: { provider: "daytona" },
|
||||
},
|
||||
]);
|
||||
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
|
||||
// The instance default is the sandbox, so the effective login environment
|
||||
// resolves to it without the user touching the override selector.
|
||||
mockInstanceSettingsApi.get.mockResolvedValue({ defaultEnvironmentId: "sandbox-1" });
|
||||
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableEnvironments: true });
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({ executionMode: "any" });
|
||||
mockSecretsApi.list.mockResolvedValue([]);
|
||||
mockSecretsApi.listProposals.mockResolvedValue([]);
|
||||
mockSecretsApi.listUserSecretDefinitions.mockResolvedValue([]);
|
||||
mockCompanySkillsApi.list.mockResolvedValue([]);
|
||||
mockIssuesApi.list.mockResolvedValue([]);
|
||||
mockProjectsApi.list.mockResolvedValue([]);
|
||||
mockAssetsApi.uploadImage.mockResolvedValue({ contentPath: "/asset" });
|
||||
mockClipboard.copyTextToClipboard.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const root of roots) {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
}
|
||||
roots = [];
|
||||
document.body.innerHTML = "";
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("shows Log in for a Claude sandbox before Create agent", async () => {
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
// Before the test the page shows no login affordance.
|
||||
expect(findButton(result.container, "Log in")).toBeFalsy();
|
||||
|
||||
await clickByText(result.container, "Test Agent");
|
||||
await flushUntil(() => Boolean(findButton(result.container, "Log in")));
|
||||
|
||||
const loginButton = findButton(result.container, "Log in");
|
||||
const createButton = findButton(result.container, "Create agent");
|
||||
expect(loginButton).toBeTruthy();
|
||||
expect(createButton).toBeTruthy();
|
||||
|
||||
// The login panel renders before the Create agent button in the document, so
|
||||
// the user completes the subscription login before an agent exists.
|
||||
const order = loginButton!.compareDocumentPosition(createButton!);
|
||||
expect(order & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("stores the secret and adds the fixed binding through the create request, with no token in the DOM", async () => {
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
await completeClaudeLogin(result.container);
|
||||
|
||||
expect(mockAgentsApi.completeClaudeSetupTokenLogin).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"claude-session-1",
|
||||
);
|
||||
|
||||
await clickByText(result.container, "Create agent");
|
||||
await flushUntil(() => mockAgentsApi.hire.mock.calls.length > 0);
|
||||
|
||||
expect(mockAgentsApi.hire).toHaveBeenCalledTimes(1);
|
||||
const [companyId, payload] = mockAgentsApi.hire.mock.calls[0] as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
expect(companyId).toBe("company-1");
|
||||
|
||||
// The create request carries the non-secret stored-session claim, and the
|
||||
// adapter config carries the fixed reference binding.
|
||||
expect(payload.storedSessionId).toBe("stored-session-1");
|
||||
const adapterConfig = payload.adapterConfig as Record<string, unknown>;
|
||||
const env = adapterConfig.env as Record<string, Record<string, unknown>>;
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toEqual({
|
||||
type: "user_secret_ref",
|
||||
key: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
version: "latest",
|
||||
required: true,
|
||||
});
|
||||
// The binding is a reference. It carries no token value.
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).not.toHaveProperty("value");
|
||||
|
||||
// The token never enters the Document Object Model, and no request payload
|
||||
// carries a token value.
|
||||
expect(result.container.textContent ?? "").not.toContain("sk-ant");
|
||||
expect(JSON.stringify(payload)).not.toContain("sk-ant");
|
||||
});
|
||||
|
||||
it("applies a stored token first and starts a replacement login with the captured version", async () => {
|
||||
// The owner already has a stored Claude token. The panel reads the status,
|
||||
// applies the stored token first, and shows a replace action. A replacement
|
||||
// login carries the captured secret id and version, so the server rotates the
|
||||
// stored value under a version-checked overwrite instead of a first write.
|
||||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
|
||||
secretId: "44444444-4444-4444-8444-444444444444",
|
||||
latestVersion: 3,
|
||||
});
|
||||
|
||||
const result = await renderNewAgent();
|
||||
roots.push(result.root);
|
||||
|
||||
await clickByText(result.container, "Test Agent");
|
||||
// The panel shows the replace action only after it reads the stored-token
|
||||
// status, so the button label proves the panel captured the version.
|
||||
await flushUntil(() => Boolean(findButton(result.container, "Log in to replace")));
|
||||
await clickByText(result.container, "Log in to replace");
|
||||
await flushUntil(() => mockAgentsApi.startClaudeSetupTokenLogin.mock.calls.length > 0);
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith("company-1", {
|
||||
environmentId: "sandbox-1",
|
||||
overwrite: {
|
||||
expectedSecretId: "44444444-4444-4444-8444-444444444444",
|
||||
expectedLatestVersion: 3,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue