feat(auth): normalize agent login in the sandbox onto one session table and a capability contract (#11730)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox agents need a safe login path for each supported adapter
> - Codex device login and Claude setup-token login used separate
session stores and route logic
> - Separate stores made session lookup, expiry, and login capability
checks harder to keep consistent
> - This pull request unifies both flows on one session table and one
capability contract
> - The benefit is one company-scoped login model with public session
identifiers and shared lifecycle rules

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting (multiple of the above)

**Problem or motivation**

Codex and Claude sandbox login used separate session stores and
different route paths. This split increased the risk of inconsistent
company scoping, session lookup, and cleanup.

**Proposed solution**

Use `adapter_auth_sessions` for both login flows. Use public session
identifiers for API access. Select login behavior from projected adapter
capability data. Share the route spine, lease arguments, runner
lifecycle, and reaper rules.

**Alternatives considered**

Keep two session tables and add matching fixes to both routes. This
keeps duplicate logic and does not provide one capability contract, so
this pull request uses shared infrastructure.

**Roadmap alignment**

This change supports the shipped Cloud / Sandbox agents milestone in
`ROADMAP.md`.

## What Changed

- Unify Codex device login and Claude setup-token login on
`adapter_auth_sessions`.
- Return and look up sessions with company-scoped public session
identifiers.
- Enforce one active session for each company, owner, and adapter.
- Share the login route spine, sandbox lease arguments, runner
lifecycle, and missing-auth check.
- Add a standalone setup-token reaper with adapter-specific row
selection.
- Add optional login capability projection for adapters and drive route
and UI selection from that data.
- Rename the provider flag to `supportsLoginPty` and validate its
deprecated alias.
- Remove the old Claude setup-token session table and add the required
migrations.

## Verification

- Server typecheck passed with `tsc`.
- Database typecheck passed.
- UI typecheck passed with `tsc -b`.
- Codex login service and route suites passed.
- Setup-token session, route, and reaper suites passed.
- Adapter session schema, plugin validator, capability projection, UI
render, and Daytona suites passed.
- GitHub Actions must confirm the complete CI gate after pull request
creation.

## Risks

- The migrations remove short-lived in-flight login rows during
deployment. A login that spans the migration can continue until its
provider lease expires.
- The Codex credential store remains company-scoped. A cross-owner
credential race remains a documented, board-accepted risk.
- API clients that use internal session row identifiers no longer work.
The API accepts only public session identifiers.

## Model Used

Codex, GPT-5, exact runtime model ID not exposed in this handoff, large
context window, reasoning, and repository tool use. The implementing
engineer produced the code with AI assistance.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [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:
Nicky Leach 2026-08-19 11:51:31 -07:00 committed by GitHub
parent 61b4fc02d4
commit e0b64529b3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 42573 additions and 936 deletions

View File

@ -84,6 +84,32 @@ export type {
RuntimeStatusUpdate,
} from "./runtime-progress.js";
export { inferOpenAiCompatibleBiller } from "./billing.js";
export {
ADAPTER_LOGIN_PANEL_MODES,
ADAPTER_LOGIN_SANDBOX_TRANSPORTS,
ADAPTER_LOGIN_TIMEOUT_POLICIES,
ADAPTER_LOGIN_COMPLETION_CLAIMS,
assertValidAdapterLoginCapability,
validateAdapterLoginCapability,
} from "./login-capability.js";
export type {
AdapterLoginPanelMode,
AdapterLoginSandboxTransport,
AdapterLoginTimeoutPolicy,
AdapterLoginCompletionClaim,
AdapterLoginPrompt,
AdapterLoginCompletionContext,
AdapterLoginCapability,
} from "./login-capability.js";
export { raceLoginRunnerExit } from "./login-runner-lifecycle.js";
export type {
LoginRunnerOutcome,
LoginRunnerResult,
LoginRunnerLog,
LoginRunnerLifecycleOptions,
LoginRunnerDisposable,
LoginRunnerRaceResult,
} from "./login-runner-lifecycle.js";
// Keep the root adapter-utils entry browser-safe because the UI imports it.
// The sandbox callback bridge stays available via its dedicated subpath export.
export type {

View File

@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import {
assertValidAdapterLoginCapability,
validateAdapterLoginCapability,
type AdapterLoginCapability,
} from "./login-capability.js";
// A well-formed login capability. Each test starts from a clone of this value
// and then breaks one field, so each case checks exactly one rule.
function validCapability(): AdapterLoginCapability {
return {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
};
}
describe("assertValidAdapterLoginCapability", () => {
it("accepts a well-formed capability with only the required fields", () => {
expect(() => assertValidAdapterLoginCapability(validCapability(), "vendor")).not.toThrow();
});
it("accepts a well-formed capability with every optional member set", () => {
const capability: AdapterLoginCapability = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => "vendor setup-token",
parsePrompt: () => null,
captureCredential: () => null,
onComplete: async () => {},
completionClaim: "storedSessionId",
};
expect(() => assertValidAdapterLoginCapability(capability, "vendor")).not.toThrow();
});
it("rejects a non-object capability", () => {
expect(() => assertValidAdapterLoginCapability(null, "vendor")).toThrow(/must be an object/);
expect(() => assertValidAdapterLoginCapability("displayed_code", "vendor")).toThrow(
/must be an object/,
);
});
it("rejects an unknown panelMode", () => {
const bad = { ...validCapability(), panelMode: "hidden_code" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/panelMode/);
});
it("rejects an unknown sandboxTransport", () => {
const bad = { ...validCapability(), sandboxTransport: "http" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/sandboxTransport/);
});
it("rejects an unknown timeoutPolicy", () => {
const bad = { ...validCapability(), timeoutPolicy: "unbounded" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/timeoutPolicy/);
});
it("rejects a missing getCommand", () => {
const bad = { ...validCapability(), getCommand: "vendor login" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/getCommand/);
});
it("rejects a missing parsePrompt", () => {
const bad = { ...validCapability(), parsePrompt: undefined };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/parsePrompt/);
});
it("rejects a non-function captureCredential", () => {
const bad = { ...validCapability(), captureCredential: "token" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/captureCredential/);
});
it("rejects a non-function onComplete", () => {
const bad = { ...validCapability(), onComplete: true };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/onComplete/);
});
it("rejects an unknown completionClaim", () => {
const bad = { ...validCapability(), completionClaim: "storedToken" };
expect(() => assertValidAdapterLoginCapability(bad, "vendor")).toThrow(/completionClaim/);
});
it("names the adapter in the error text", () => {
const bad = { ...validCapability(), panelMode: "hidden_code" };
expect(() => assertValidAdapterLoginCapability(bad, "codex_local")).toThrow(/codex_local/);
});
});
describe("validateAdapterLoginCapability", () => {
it("accepts a module with no login capability", () => {
expect(() => validateAdapterLoginCapability({ type: "vendor_local" })).not.toThrow();
});
it("accepts a module with a well-formed login capability", () => {
expect(() =>
validateAdapterLoginCapability({ type: "vendor_local", loginCapability: validCapability() }),
).not.toThrow();
});
it("rejects a module with a malformed login capability", () => {
const bad = { ...validCapability(), panelMode: "hidden_code" };
expect(() =>
validateAdapterLoginCapability({ type: "vendor_local", loginCapability: bad }),
).toThrow(/vendor_local/);
});
});

View File

@ -0,0 +1,169 @@
// The adapter login capability. An adapter declares this optional capability to
// describe how the server drives an interactive sandbox login. The module holds
// the capability types, the fixed value sets, and one runtime validator. The
// validator fails closed: it rejects a malformed capability shape with a clear
// error, so the loader never accepts a partial capability.
//
// Security (secret handling): the capability data holds no secret. The scalar
// fields carry only a fixed, non-secret value. The function members are code,
// not data; a member can return a runtime secret (for example a minted token),
// but the capability object never stores a secret. API-key-only vendors declare
// no login capability.
/**
* The login panel mode. `displayed_code` shows a one-time code that the user
* enters in the browser. `submitted_browser_code` asks the user to paste a code
* from the browser back into the login flow.
*/
export const ADAPTER_LOGIN_PANEL_MODES = ["displayed_code", "submitted_browser_code"] as const;
export type AdapterLoginPanelMode = (typeof ADAPTER_LOGIN_PANEL_MODES)[number];
/**
* The sandbox transport that the login command needs. `streamed_exec` runs the
* command over the streamed exec channel. `pseudo_terminal` runs the command on
* a real pseudo-terminal, because a pipe emits no login prompt.
*/
export const ADAPTER_LOGIN_SANDBOX_TRANSPORTS = ["streamed_exec", "pseudo_terminal"] as const;
export type AdapterLoginSandboxTransport = (typeof ADAPTER_LOGIN_SANDBOX_TRANSPORTS)[number];
/**
* The host-side timeout policy. `caller_bounded` lets the caller set the
* timeout. `fixed` binds the timeout to a fixed adapter value.
*/
export const ADAPTER_LOGIN_TIMEOUT_POLICIES = ["caller_bounded", "fixed"] as const;
export type AdapterLoginTimeoutPolicy = (typeof ADAPTER_LOGIN_TIMEOUT_POLICIES)[number];
/**
* The optional completion claim that the login flow records on success.
* `storedSessionId` marks that the flow stored a session identifier.
*/
export const ADAPTER_LOGIN_COMPLETION_CLAIMS = ["storedSessionId"] as const;
export type AdapterLoginCompletionClaim = (typeof ADAPTER_LOGIN_COMPLETION_CLAIMS)[number];
/**
* The normalized login prompt. `url` is the validated authorization URL. `code`
* is the one-time code to show, present only in `displayed_code` mode. A prompt
* carries no credential secret.
*/
export interface AdapterLoginPrompt {
url: string;
code?: string;
}
/**
* The completion context. The server passes it to the completion hook. It
* carries the non-secret completion state. It carries no credential byte.
*/
export interface AdapterLoginCompletionContext {
/** The session identifier that the flow stored, when it stored one. */
storedSessionId?: string;
}
/**
* The optional adapter login capability. It declares how the server drives an
* interactive sandbox login for the adapter. The capability data holds no
* secret. An adapter with no interactive login (for example an API-key-only
* vendor) declares no capability.
*/
export interface AdapterLoginCapability {
/** The login panel mode. */
panelMode: AdapterLoginPanelMode;
/** The sandbox transport that the login command needs. */
sandboxTransport: AdapterLoginSandboxTransport;
/** The host-side timeout policy. */
timeoutPolicy: AdapterLoginTimeoutPolicy;
/** Returns the fixed, non-secret login command. */
getCommand: () => string;
/**
* Parses the authorization prompt from the login output. Returns null when the
* output holds no prompt yet. The parser keeps every input byte out of its
* result and out of every thrown error.
*/
parsePrompt: (output: string) => AdapterLoginPrompt | null;
/**
* Captures the minted credential from the login output. Returns the raw
* credential bytes, or null when the output holds no credential yet. Only a
* flow that prints the credential to the terminal sets it. The result is a
* runtime secret; the capability data never stores it.
*/
captureCredential?: (output: string) => Buffer | null;
/**
* Runs after a successful login. It records the non-secret completion state.
* It carries no credential byte.
*/
onComplete?: (ctx: AdapterLoginCompletionContext) => Promise<void>;
/** The optional completion claim that the flow records on success. */
completionClaim?: AdapterLoginCompletionClaim;
}
function isOneOf<T extends readonly string[]>(values: T, candidate: unknown): candidate is T[number] {
return typeof candidate === "string" && (values as readonly string[]).includes(candidate);
}
/**
* Validates one login capability. The function fails closed: it throws a clear
* error for a malformed shape. It checks each scalar field against its fixed
* value set, checks each required function member, and checks each optional
* member only when the member is present. `adapterType` names the adapter in the
* error text.
*/
export function assertValidAdapterLoginCapability(
value: unknown,
adapterType: string,
): asserts value is AdapterLoginCapability {
const prefix = `Adapter "${adapterType}" declares an invalid login capability`;
if (typeof value !== "object" || value === null) {
throw new Error(`${prefix}: the capability must be an object.`);
}
const cap = value as Record<string, unknown>;
if (!isOneOf(ADAPTER_LOGIN_PANEL_MODES, cap.panelMode)) {
throw new Error(
`${prefix}: "panelMode" must be one of ${ADAPTER_LOGIN_PANEL_MODES.join(", ")}.`,
);
}
if (!isOneOf(ADAPTER_LOGIN_SANDBOX_TRANSPORTS, cap.sandboxTransport)) {
throw new Error(
`${prefix}: "sandboxTransport" must be one of ${ADAPTER_LOGIN_SANDBOX_TRANSPORTS.join(", ")}.`,
);
}
if (!isOneOf(ADAPTER_LOGIN_TIMEOUT_POLICIES, cap.timeoutPolicy)) {
throw new Error(
`${prefix}: "timeoutPolicy" must be one of ${ADAPTER_LOGIN_TIMEOUT_POLICIES.join(", ")}.`,
);
}
if (typeof cap.getCommand !== "function") {
throw new Error(`${prefix}: "getCommand" must be a function.`);
}
if (typeof cap.parsePrompt !== "function") {
throw new Error(`${prefix}: "parsePrompt" must be a function.`);
}
if (cap.captureCredential !== undefined && typeof cap.captureCredential !== "function") {
throw new Error(`${prefix}: "captureCredential" must be a function when present.`);
}
if (cap.onComplete !== undefined && typeof cap.onComplete !== "function") {
throw new Error(`${prefix}: "onComplete" must be a function when present.`);
}
if (
cap.completionClaim !== undefined &&
!isOneOf(ADAPTER_LOGIN_COMPLETION_CLAIMS, cap.completionClaim)
) {
throw new Error(
`${prefix}: "completionClaim" must be one of ${ADAPTER_LOGIN_COMPLETION_CLAIMS.join(", ")} when present.`,
);
}
}
/**
* Validates the optional login capability of an adapter module. The function is
* a no-op when the module declares no login capability. It throws a clear error
* when the module declares a malformed capability, so the loader fails closed.
*/
export function validateAdapterLoginCapability(mod: {
type?: unknown;
loginCapability?: unknown;
}): void {
if (mod.loginCapability === undefined) return;
const adapterType = typeof mod.type === "string" && mod.type.length > 0 ? mod.type : "unknown";
assertValidAdapterLoginCapability(mod.loginCapability, adapterType);
}

View File

@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "vitest";
import {
raceLoginRunnerExit,
type LoginRunnerDisposable,
type LoginRunnerOutcome,
type LoginRunnerRaceResult,
type LoginRunnerResult,
} from "./login-runner-lifecycle.js";
describe("LoginRunnerOutcome", () => {
it("names the four terminal outcomes", () => {
const outcomes: LoginRunnerOutcome[] = ["success", "failure", "timeout", "cancelled"];
expect(new Set(outcomes).size).toBe(4);
});
});
describe("LoginRunnerResult", () => {
it("carries the fixed status fields", () => {
const result: LoginRunnerResult = { outcome: "success", exitCode: 0, promptSurfaced: true };
expect(result).toEqual({ outcome: "success", exitCode: 0, promptSurfaced: true });
});
});
describe("raceLoginRunnerExit", () => {
it("resolves with the exit kind when the work resolves first", async () => {
const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: 0 }), 10_000, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 0 });
});
it("passes a non-zero exit code through", async () => {
const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: 7 }), 10_000, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 7 });
});
it("passes a null exit code through", async () => {
const raced = await raceLoginRunnerExit(Promise.resolve({ exitCode: null }), 10_000, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: null });
});
it("resolves with the timeout kind when the work hangs past the timeout", async () => {
const hang = new Promise<{ exitCode: number | null }>(() => {});
const raced = await raceLoginRunnerExit(hang, 5, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "timeout" });
});
it("resolves with the cancelled kind when the signal aborts", async () => {
const hang = new Promise<{ exitCode: number | null }>(() => {});
const controller = new AbortController();
const racePromise = raceLoginRunnerExit(hang, 10_000, controller.signal);
controller.abort();
expect(await racePromise).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
});
it("resolves with the cancelled kind when the signal is already aborted", async () => {
const hang = new Promise<{ exitCode: number | null }>(() => {});
const controller = new AbortController();
controller.abort();
const raced = await raceLoginRunnerExit(hang, 10_000, controller.signal);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
});
it("returns cancelled for a pre-aborted signal even when the work can resolve", async () => {
const controller = new AbortController();
controller.abort();
const raced = await raceLoginRunnerExit(
Promise.resolve({ exitCode: 0 }),
10_000,
controller.signal,
);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
});
it("consumes a late work rejection after a pre-aborted signal cancels the race", async () => {
let rejectWork: (error: Error) => void = () => {};
const work = new Promise<{ exitCode: number | null }>((_resolve, reject) => {
rejectWork = reject;
});
const controller = new AbortController();
controller.abort();
const raced = await raceLoginRunnerExit(work, 10_000, controller.signal);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "cancelled" });
// The pre-aborted signal already cancelled the race. The work then rejects.
// The helper consumes the late rejection, so it never becomes an unhandled
// rejection.
expect(() => rejectWork(new Error("late"))).not.toThrow();
await Promise.resolve();
});
it("rejects when the work rejects", async () => {
const error = new Error("the driver errored");
await expect(raceLoginRunnerExit(Promise.reject(error), 10_000, undefined)).rejects.toBe(error);
});
it("does not double-settle when the signal aborts after the exit", async () => {
const controller = new AbortController();
const raced = await raceLoginRunnerExit(
Promise.resolve({ exitCode: 0 }),
10_000,
controller.signal,
);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 0 });
// The race already settled. A late abort must not throw or change the result.
expect(() => controller.abort()).not.toThrow();
});
it("removes the abort listener after it settles on the exit", async () => {
const controller = new AbortController();
const removeSpy = vi.spyOn(controller.signal, "removeEventListener");
await raceLoginRunnerExit(Promise.resolve({ exitCode: 0 }), 10_000, controller.signal);
expect(removeSpy).toHaveBeenCalledWith("abort", expect.any(Function));
});
it("consumes a late work rejection after the timeout settles the race", async () => {
let rejectWork: (error: Error) => void = () => {};
const work = new Promise<{ exitCode: number | null }>((_resolve, reject) => {
rejectWork = reject;
});
const raced = await raceLoginRunnerExit(work, 5, undefined);
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "timeout" });
// The race already settled on the timeout. The work then rejects. The helper
// consumes the late rejection, so it never becomes an unhandled rejection.
expect(() => rejectWork(new Error("late"))).not.toThrow();
await Promise.resolve();
});
});
describe("login-runner lifecycle ordering", () => {
it("disposes a driver one time", async () => {
const dispose = vi.fn(async () => {});
const driver: LoginRunnerDisposable = { dispose };
await driver.dispose();
expect(dispose).toHaveBeenCalledTimes(1);
});
it("races to a terminal state, then disposes the driver", async () => {
const events: string[] = [];
const driver: LoginRunnerDisposable = {
dispose: vi.fn(async () => {
events.push("dispose");
}),
};
const work = Promise.resolve({ exitCode: 0 }).then((value) => {
events.push("exit");
return value;
});
const raced = await raceLoginRunnerExit(work, 10_000, undefined);
await driver.dispose();
expect(raced).toEqual<LoginRunnerRaceResult>({ kind: "exit", exitCode: 0 });
expect(events).toEqual(["exit", "dispose"]);
});
});

View File

@ -0,0 +1,116 @@
// The shared login-runner lifecycle contract. Both login runners use these
// types: the Codex device-login runner and the Claude setup-token runner. The
// module defines the runner outcome union, the result base type, the fixed-log
// callback type, the timeout and cancellation options, the dispose contract, and
// the timeout race helper. The module holds types and one pure helper only. It
// does not change runner behavior.
//
// Security (secret handling): the outcome and the result carry only a fixed,
// non-secret status. They never carry a URL, a code, or a token byte. The log
// callback receives only fixed status lines. Each runner keeps this rule.
/**
* The terminal outcome of a login runner. A runner reports exactly one value.
* The value is a fixed, non-secret status.
*/
export type LoginRunnerOutcome = "success" | "failure" | "timeout" | "cancelled";
/**
* The base result of a login runner. Every runner returns at least these three
* fields. A runner can extend the base with its own non-secret status fields. The
* result never carries a URL, a code, or a token byte.
*/
export interface LoginRunnerResult {
outcome: LoginRunnerOutcome;
exitCode: number | null;
promptSurfaced: boolean;
}
/**
* A non-leaking progress sink. It receives only fixed status lines. A runner
* never passes a URL, a code, or a token byte to this sink.
*/
export type LoginRunnerLog = (line: string) => void;
/**
* The shared lifecycle options. Both runners set the host-side timeout, accept an
* optional cancellation signal, and accept an optional log sink. Each runner
* option type extends this base with its own callbacks.
*/
export interface LoginRunnerLifecycleOptions {
/** The host-side timeout in milliseconds. */
timeoutMs: number;
/** An optional cancellation signal. */
signal?: AbortSignal;
/** A non-leaking progress sink. It receives only fixed status lines. */
log?: LoginRunnerLog;
}
/**
* The dispose contract. A login driver releases its resources through `dispose`.
* The runner always disposes the driver one time for every terminal state. The
* method must be safe to call one time on every path.
*/
export interface LoginRunnerDisposable {
/** Releases the driver resources. */
dispose(): Promise<void>;
}
/**
* The result of the timeout race. The `exit` kind carries the command exit code.
* The `timeout` kind and the `cancelled` kind each mark a terminal state with no
* exit code.
*/
export type LoginRunnerRaceResult =
| { kind: "exit"; exitCode: number | null }
| { kind: "timeout" }
| { kind: "cancelled" };
/**
* Races the streaming `work` against the timeout and the cancellation signal. The
* work result resolves the race with an `exit` status; the timeout resolves it
* with a `timeout` status; the signal resolves it with a `cancelled` status. A
* pre-aborted signal resolves the race with a `cancelled` status at once, before
* the helper starts the timer or listens to the work. A work error rejects the
* race, so the caller can convert it to a fixed, non-secret error. A late work
* rejection after the race already settled is consumed here, so it never becomes
* an unhandled rejection. The helper clears the timer and removes the signal
* listener on the first settle.
*/
export function raceLoginRunnerExit(
work: Promise<{ exitCode: number | null }>,
timeoutMs: number,
signal: AbortSignal | undefined,
): Promise<LoginRunnerRaceResult> {
return new Promise<LoginRunnerRaceResult>((resolve, reject) => {
// The signal is already aborted. Resolve with the cancelled status now. The
// "abort" event does not fire again for an already-aborted signal, so a late
// listener never runs. The helper still consumes a late work rejection below.
if (signal?.aborted) {
work.then(
() => {},
() => {},
);
resolve({ kind: "cancelled" });
return;
}
let settled = false;
const cleanup = () => {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
};
const finish = (run: () => void) => {
if (settled) return;
settled = true;
cleanup();
run();
};
const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
if (signal) signal.addEventListener("abort", onAbort, { once: true });
work.then(
(value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
(error) => finish(() => reject(error)),
);
});
}

View File

@ -500,6 +500,14 @@ export interface ServerAdapterModule {
* and provisioned in fresh remote environments such as sandboxes.
*/
getRuntimeCommandSpec?: (config: Record<string, unknown>) => AdapterRuntimeCommandSpec | null;
/**
* Optional: declare the interactive sandbox login capability. The server uses
* it to drive the login flow and to project the safe panel fields to the user
* interface. An adapter with no interactive login (for example an
* API-key-only vendor) omits it. The capability data holds no secret.
*/
loginCapability?: import("./login-capability.js").AdapterLoginCapability;
}
// ---------------------------------------------------------------------------

View File

@ -55,6 +55,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "^0.66.0",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {

View File

@ -1,12 +1,7 @@
/**
* 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.
* The canonical check code the Claude Test engine emits when a sandbox target has
* no ready authentication. This module re-exports the one shared constant, so
* the adapters and the user interface gate login from the same code. See the
* source in the shared package for the full contract.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";

View File

@ -1,3 +1,10 @@
import {
raceLoginRunnerExit,
type LoginRunnerDisposable,
type LoginRunnerLifecycleOptions,
type LoginRunnerOutcome,
type LoginRunnerResult,
} from "@paperclipai/adapter-utils";
import {
parseSetupTokenCredential,
parseSetupTokenPrompt,
@ -69,7 +76,7 @@ export const CLAUDE_SETUP_TOKEN_MAX_BUFFER_CHARS = 64 * 1024;
* directly; a caller injects a concrete driver. A production driver binds these
* methods to a PTY child that runs {@link CLAUDE_SETUP_TOKEN_COMMAND}.
*/
export interface SetupTokenPtyDriver {
export interface SetupTokenPtyDriver extends LoginRunnerDisposable {
/**
* Starts `command` in a PTY and streams the terminal output to `onData` in
* memory. Resolves with the child exit code when the child ends. A driver must
@ -87,8 +94,6 @@ export interface SetupTokenPtyDriver {
* and safe to call more than one time.
*/
stop(): void;
/** Releases the driver resources. */
dispose(): Promise<void>;
}
/** Receives the parsed prompt one time in memory. The caller displays it. */
@ -110,19 +115,20 @@ export type SetupTokenCodeProvider = (signal: AbortSignal) => Promise<string>;
*/
export type SetupTokenCredentialSink = (authBytes: Buffer) => Promise<void>;
export type SetupTokenOutcome = "success" | "failure" | "timeout" | "cancelled";
/** The setup-token runner outcome. It is a fixed, non-secret status. */
export type SetupTokenOutcome = LoginRunnerOutcome;
/** The runner result. It never carries a URL, a code, or a token byte. */
export interface SetupTokenLoginResult {
outcome: SetupTokenOutcome;
exitCode: number | null;
promptSurfaced: boolean;
/**
* The runner result. It never carries a URL, a code, or a token byte. It extends
* the base result with the two setup-token status fields.
*/
export interface SetupTokenLoginResult extends LoginRunnerResult {
codeSubmitted: boolean;
/** True when the runner bound the token and invoked `onCredential` one time. */
credentialDelivered: boolean;
}
export interface RunSetupTokenLoginOptions {
export interface RunSetupTokenLoginOptions extends LoginRunnerLifecycleOptions {
/** The login command. Defaults to {@link CLAUDE_SETUP_TOKEN_COMMAND}. */
command?: string;
/** Receives the parsed prompt one time in memory. The caller displays it. */
@ -141,12 +147,6 @@ export interface RunSetupTokenLoginOptions {
* write. Defaults to {@link CODE_SUBMIT_SETTLE_MS}. A test sets it to 0.
*/
codeSubmitSettleMs?: number;
/** The host-side timeout in milliseconds. */
timeoutMs: number;
/** An optional cancellation signal. */
signal?: AbortSignal;
/** A non-leaking progress sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
/**
@ -168,45 +168,6 @@ function settleDelay(ms: number, signal: AbortSignal): Promise<void> {
});
}
type RaceResult =
| { kind: "exit"; exitCode: number | null }
| { kind: "timeout" }
| { kind: "cancelled" };
/**
* Races the streaming start against the timeout and the cancellation signal. The
* start result resolves the race; the timeout and the signal resolve the race
* with a terminal status. A driver error rejects the race, so the caller can
* convert it to a fixed, non-secret error. A late start rejection after the race
* already settled is consumed here, so it never becomes an unhandled rejection.
*/
function raceStart(
start: Promise<{ exitCode: number | null }>,
timeoutMs: number,
signal: AbortSignal,
): Promise<RaceResult> {
return new Promise<RaceResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener("abort", onAbort);
};
const finish = (run: () => void) => {
if (settled) return;
settled = true;
cleanup();
run();
};
const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
signal.addEventListener("abort", onAbort, { once: true });
start.then(
(value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
(error) => finish(() => reject(error)),
);
});
}
/**
* Stops the child and releases the driver for a terminal state. The runner calls
* this on every exit path: a normal exit, a non-zero exit, a timeout, a
@ -394,7 +355,7 @@ export async function runSetupTokenLogin(
}
const start = driver.start(command, onData);
const raced = await raceStart(start, timeoutMs, controller.signal);
const raced = await raceLoginRunnerExit(start, timeoutMs, controller.signal);
// Release a pending code-input routine, then let it settle. The routine is
// self-guarding, so this await never rejects.
controller.abort();

View File

@ -54,6 +54,7 @@
"dependencies": {
"@agentclientprotocol/codex-acp": "^1.2.0",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"picocolors": "^1.1.1"
},
"devDependencies": {

View File

@ -1,10 +1,7 @@
/**
* The canonical check code both Codex Test engines emit 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 canonical check code the Codex Test engine emits when a sandbox target has
* no ready authentication. This module re-exports the one shared constant, so
* the adapters and the user interface gate login from the same code. See the
* source in the shared package for the full contract.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";

View File

@ -1,3 +1,10 @@
import {
raceLoginRunnerExit,
type LoginRunnerDisposable,
type LoginRunnerLifecycleOptions,
type LoginRunnerOutcome,
type LoginRunnerResult,
} from "@paperclipai/adapter-utils";
import { parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js";
// The device-login runner. It runs the Codex device-login command through an
@ -35,7 +42,7 @@ const MAX_PARSE_BUFFER_CHARS = 64 * 1024;
* three methods to a non-persisting Daytona exec path, a file read, and a
* sandbox delete.
*/
export interface SandboxLoginDriver {
export interface SandboxLoginDriver extends LoginRunnerDisposable {
/**
* Runs `command` in the sandbox and streams standard output to `onStdout` in
* memory. Resolves with the command exit code when the command ends. A driver
@ -44,23 +51,18 @@ export interface SandboxLoginDriver {
execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>;
/** Reads the bytes of one file from the sandbox. */
readFile(path: string): Promise<Buffer>;
/** Deletes the sandbox and releases its resources. */
dispose(): Promise<void>;
}
/** Receives the parsed prompt one time in memory. The caller displays it. */
export type DeviceLoginPromptSink = (prompt: DeviceLoginPrompt) => void;
export type DeviceLoginOutcome = "success" | "failure" | "timeout" | "cancelled";
/** The device-login runner outcome. It is a fixed, non-secret status. */
export type DeviceLoginOutcome = LoginRunnerOutcome;
/** The runner result. It never carries a URL, a code, or a token byte. */
export interface DeviceLoginResult {
outcome: DeviceLoginOutcome;
exitCode: number | null;
promptSurfaced: boolean;
}
export type DeviceLoginResult = LoginRunnerResult;
export interface RunDeviceLoginOptions {
export interface RunDeviceLoginOptions extends LoginRunnerLifecycleOptions {
/** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */
command?: string;
/** Receives the parsed prompt one time in memory. The caller displays it. */
@ -73,51 +75,6 @@ export interface RunDeviceLoginOptions {
onCredential?: (authBytes: Buffer) => void | Promise<void>;
/** The sandbox path of the credential file to read on success. */
authPath?: string;
/** The host-side timeout in milliseconds. */
timeoutMs: number;
/** An optional cancellation signal. */
signal?: AbortSignal;
/** A non-leaking progress sink. It receives only fixed status lines. */
log?: (line: string) => void;
}
type RaceResult =
| { kind: "exit"; exitCode: number | null }
| { kind: "timeout" }
| { kind: "cancelled" };
/**
* Races the streaming exec against the timeout and the cancellation signal. The
* exec result resolves the race; the timeout and the signal resolve the race
* with a terminal status. A driver error rejects the race, so the caller can
* convert it to a fixed, non-secret error. A late exec rejection after the race
* already settled is consumed here, so it never becomes an unhandled rejection.
*/
function raceExec(
exec: Promise<{ exitCode: number | null }>,
timeoutMs: number,
signal: AbortSignal | undefined,
): Promise<RaceResult> {
return new Promise<RaceResult>((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
if (signal) signal.removeEventListener("abort", onAbort);
};
const finish = (run: () => void) => {
if (settled) return;
settled = true;
cleanup();
run();
};
const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs);
const onAbort = () => finish(() => resolve({ kind: "cancelled" }));
if (signal) signal.addEventListener("abort", onAbort, { once: true });
exec.then(
(value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })),
(error) => finish(() => reject(error)),
);
});
}
/**
@ -167,7 +124,7 @@ export async function runDeviceLogin(
}
const exec = driver.execStreaming(command, onStdout);
const raced = await raceExec(exec, timeoutMs, signal);
const raced = await raceLoginRunnerExit(exec, timeoutMs, signal);
if (raced.kind === "timeout") {
log("[paperclip] Device login timed out; disposing the sandbox.");

View File

@ -32,7 +32,7 @@ export {
type DeviceLoginResult,
type RunDeviceLoginOptions,
} from "./device-login-runner.js";
export { DEVICE_LOGIN_URL, type DeviceLoginPrompt } from "./device-login-parse.js";
export { DEVICE_LOGIN_URL, parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js";
export {
promoteDeviceLoginCredential,
checkStagedCredentialReadiness,

View File

@ -1,10 +1,15 @@
import fs from "node:fs";
import { getTableConfig } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { adapterAuthSessions } from "./schema/adapter_auth_sessions.js";
import {
ADAPTER_AUTH_SESSION_ACTIVE_STATES,
adapterAuthSessions,
} from "./schema/adapter_auth_sessions.js";
type PgTable = Parameters<typeof getTableConfig>[0];
const ACTIVE_INDEX = "adapter_auth_sessions_company_owner_adapter_active_uq";
function findIndex(table: PgTable, indexName: string) {
return getTableConfig(table).indexes.find((candidate) => candidate.config.name === indexName);
}
@ -15,53 +20,114 @@ function indexColumns(table: PgTable, indexName: string): string[] {
return index.config.columns.map((column) => (column as { name: string }).name);
}
function columnIsNotNull(table: PgTable, columnName: string): boolean {
const column = getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
return column?.notNull ?? false;
function column(table: PgTable, columnName: string) {
return getTableConfig(table).columns.find((candidate) => candidate.name === columnName);
}
// 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 columnIsNotNull(table: PgTable, columnName: string): boolean {
return column(table, columnName)?.notNull ?? false;
}
// The migration that re-scopes the active index. 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("adapter_auth_sessions_company_adapter_active_uq")) {
if (content.includes(ACTIVE_INDEX)) {
return content;
}
}
throw new Error("no migration creates adapter_auth_sessions_company_adapter_active_uq");
throw new Error(`no migration creates ${ACTIVE_INDEX}`);
}
describe("adapter auth sessions schema", () => {
it("serializes on the company credential slot over the active statuses", () => {
const active = findIndex(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq");
it("serializes the active slot on company, owner, and adapter", () => {
const active = findIndex(adapterAuthSessions, ACTIVE_INDEX);
expect(active).toBeDefined();
expect(active?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_company_adapter_active_uq")).toEqual([
expect(indexColumns(adapterAuthSessions, ACTIVE_INDEX)).toEqual([
"company_id",
"started_by_user_id",
"adapter_type",
]);
// The active index is partial; the where clause is present.
expect(active?.config.where).toBeDefined();
});
it("keeps the owner principal non-null and out of the uniqueness key", () => {
it("keeps the owner principal non-null and drops the environment from the key", () => {
expect(columnIsNotNull(adapterAuthSessions, "started_by_user_id")).toBe(true);
const activeColumns = indexColumns(
adapterAuthSessions,
"adapter_auth_sessions_company_adapter_active_uq",
);
expect(activeColumns).not.toContain("started_by_user_id");
const activeColumns = indexColumns(adapterAuthSessions, ACTIVE_INDEX);
expect(activeColumns).toContain("started_by_user_id");
expect(activeColumns).not.toContain("environment_id");
});
it("generates a partial unique index over the three active statuses", () => {
it("adds the public session id, non-null and unique", () => {
expect(column(adapterAuthSessions, "public_session_id")).toBeDefined();
expect(columnIsNotNull(adapterAuthSessions, "public_session_id")).toBe(true);
const uniqueOnPublicSessionId = findIndex(
adapterAuthSessions,
"adapter_auth_sessions_public_session_id_uq",
);
expect(uniqueOnPublicSessionId).toBeDefined();
expect(uniqueOnPublicSessionId?.config.unique).toBe(true);
expect(indexColumns(adapterAuthSessions, "adapter_auth_sessions_public_session_id_uq")).toEqual([
"public_session_id",
]);
});
it("adds the claim marker, nullable", () => {
// A row starts with no claim consumption. The service fills `bound_at` only
// when the create path consumes the stored claim.
expect(column(adapterAuthSessions, "bound_at")).toBeDefined();
expect(columnIsNotNull(adapterAuthSessions, "bound_at")).toBe(false);
});
it("merges the two state unions and drops the persisting state", () => {
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).toEqual([
"starting",
"waiting_for_user",
"promoting",
"awaiting_code",
"submitting",
]);
// The removed state stays out of the active set.
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("persisting");
// The one-time claim state and the terminal states do not hold the slot.
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("stored");
expect([...ADAPTER_AUTH_SESSION_ACTIVE_STATES]).not.toContain("completed");
});
it("holds no secret-bearing column", () => {
// The durable store keeps only ids, the state, the lease reference, and the
// deadlines. It never holds the prompt, the login URL, the browser code, the
// token, or a process chunk.
const names = getTableConfig(adapterAuthSessions).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("generates the partial unique index over the merged active states", () => {
const sql = activeIndexMigrationSql();
expect(sql).toContain('CREATE UNIQUE INDEX "adapter_auth_sessions_company_adapter_active_uq"');
expect(sql).toContain('("company_id","adapter_type")');
expect(sql).toContain("'starting', 'waiting_for_user', 'promoting'");
expect(sql).toContain('"started_by_user_id" text NOT NULL');
expect(sql).toContain(`CREATE UNIQUE INDEX "${ACTIVE_INDEX}"`);
expect(sql).toContain('("company_id","started_by_user_id","adapter_type")');
expect(sql).toContain(
"'starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting'",
);
// The new non-null column and the unique index are in the migration.
expect(sql).toContain('"public_session_id" varchar(128) NOT NULL');
expect(sql).toContain(`CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq"`);
// The removed state and the non-active 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("'persisting'");
expect(whereClause).not.toContain("'stored'");
expect(whereClause).not.toContain("'completed'");
expect(whereClause).not.toContain("'authenticated'");
});
});

View File

@ -1,116 +0,0 @@
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'");
});
});

View File

@ -0,0 +1,18 @@
-- Extend "adapter_auth_sessions" to hold both login flows. The migration adds
-- the public session id and the claim marker, and it re-scopes the active
-- credential slot to company + owner + adapter. The migration copies no rows
-- from "claude_setup_token_sessions"; that table stays in place for a later drop.
--
-- NOT NULL on a live table: "public_session_id" is a new non-null column with no
-- default, and "adapter_auth_sessions" can hold rows when the migration runs. The
-- rows are short-lived login sessions, and the board excluded deploy-window
-- support, so the migration deletes the existing rows instead of a backfill of a
-- generated value. A dropped login session re-starts on the next request. The
-- table stays small because the reaper removes finished rows, so the one-time
-- full delete is safe.
DELETE FROM "adapter_auth_sessions";--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN "public_session_id" varchar(128) NOT NULL;--> statement-breakpoint
ALTER TABLE "adapter_auth_sessions" ADD COLUMN "bound_at" timestamp with time zone;--> statement-breakpoint
DROP INDEX "adapter_auth_sessions_company_adapter_active_uq";--> statement-breakpoint
CREATE UNIQUE INDEX "adapter_auth_sessions_company_owner_adapter_active_uq" ON "adapter_auth_sessions" USING btree ("company_id","started_by_user_id","adapter_type") WHERE "adapter_auth_sessions"."status" IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting');--> statement-breakpoint
CREATE UNIQUE INDEX "adapter_auth_sessions_public_session_id_uq" ON "adapter_auth_sessions" USING btree ("public_session_id");

View File

@ -0,0 +1,5 @@
-- Drop the old Claude setup-token table. The unified "adapter_auth_sessions"
-- table now holds the Claude setup-token store for both login flows, so the
-- old table has no reader. Migration 0224 moved the columns and the active-slot
-- index onto "adapter_auth_sessions"; this migration removes the dead table.
DROP TABLE IF EXISTS "claude_setup_token_sessions";

File diff suppressed because it is too large Load Diff

View File

@ -1555,6 +1555,20 @@
"when": 1787000753489,
"tag": "0223_robust_zaladane",
"breakpoints": true
},
{
"idx": 224,
"version": "7",
"when": 1787000754489,
"tag": "0224_unified_adapter_auth_sessions",
"breakpoints": true
},
{
"idx": 225,
"version": "7",
"when": 1787101144413,
"tag": "0225_drop_claude_setup_token_sessions",
"breakpoints": true
}
]
}

View File

@ -1,13 +1,47 @@
import { sql } from "drizzle-orm";
import { index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
import { index, pgTable, text, timestamp, uniqueIndex, uuid, varchar } from "drizzle-orm/pg-core";
import type { AdapterAuthSessionInternalStatus, AgentAdapterType } from "@paperclipai/shared";
import { companies } from "./companies.js";
import { environments } from "./environments.js";
// The merged state set for the unified login table. One column holds the state
// of both login flows: the device-login flow and the setup-token flow. The set
// joins the two prior state unions into one union. The set drops the dead
// `persisting` state.
//
// The active states hold the company credential slot for one company, owner, and
// adapter. The partial unique index applies to the active states only.
// - `starting`, `waiting_for_user`, `promoting`: the device-login active states.
// - `awaiting_code`, `submitting`: the setup-token active states.
// The non-active states do not hold the slot:
// - `stored`: the one-time setup-token claim. The create path consumes it and
// writes `bound_at`.
// - `authenticated`, `completed`: the terminal success states.
// - `cleanup_pending`: the terminal state whose sandbox delete failed.
// - `failed`, `timed_out`, `cancelled`: the shared terminal failure states.
export type AdapterAuthSessionState =
| AdapterAuthSessionInternalStatus
| "awaiting_code"
| "submitting"
| "stored"
| "completed";
// The active states of the merged set. The partial unique index and the store
// use this list. The list excludes `stored`, every terminal state, and the
// removed `persisting` state.
export const ADAPTER_AUTH_SESSION_ACTIVE_STATES = [
"starting",
"waiting_for_user",
"promoting",
"awaiting_code",
"submitting",
] as const satisfies readonly AdapterAuthSessionState[];
// The durable store for an adapter login session. One row tracks one login
// attempt for one adapter in one environment. The row keeps the owner principal,
// the provider lease reference, the status, and the finish times. The row never
// stores the prompt, a credential byte, or the raw provider secret.
// the public session id, the provider lease reference, the status, and the
// finish times. The row never stores the prompt, a credential byte, or the raw
// provider secret.
export const adapterAuthSessions = pgTable(
"adapter_auth_sessions",
{
@ -17,12 +51,20 @@ export const adapterAuthSessions = pgTable(
adapterType: text("adapter_type").$type<AgentAdapterType>().notNull(),
// The immutable owner principal. The service sets this column one time at
// create and never updates it. The service returns the prompt only to this
// owner.
// owner. The active-slot index includes this column, so the column stays
// non-null; a null owner escapes a partial unique index.
startedByUserId: text("started_by_user_id").notNull(),
// The opaque public session id. The public API returns this id, and the
// store keys its lookups by it. The column is non-null, length-bounded, and
// unique. The store fills it at create with a CSPRNG value.
publicSessionId: varchar("public_session_id", { length: 128 }).notNull(),
// The provider lease reference for the sandbox. The reaper reads it to retry
// a failed sandbox delete. It is not a public field.
providerLeaseId: text("provider_lease_id"),
status: text("status").$type<AdapterAuthSessionInternalStatus>().notNull().default("starting"),
// The merged login state. The column is `text`, so it stores every value of
// the merged `AdapterAuthSessionState` set. The compile-time `$type` is the
// merged union, so both login stores share one status type.
status: text("status").$type<AdapterAuthSessionState>().notNull().default("starting"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
// The promotion claim deadline. The service sets this column when it moves the
// row to `promoting`. While the deadline is in the future, the claim is live,
@ -31,6 +73,11 @@ export const adapterAuthSessions = pgTable(
// stalled `promoting` row. The service clears the column on every terminal
// transition.
promotionExpiresAt: timestamp("promotion_expires_at", { withTimezone: true }),
// The claim-consumption marker for a `stored` row. The column 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. A device-login row never sets this column.
boundAt: timestamp("bound_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
// The fixed, non-secret failure code. The public response reads it.
failureReason: text("failure_reason"),
@ -43,11 +90,19 @@ export const adapterAuthSessions = pgTable(
table.status,
),
// Serialize on the company credential slot. Only one active session can hold
// the slot per adapter. The index applies to the three active statuses. It
// does not include the owner or the environment; those stay columns only.
companyAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_adapter_active_uq")
.on(table.companyId, table.adapterType)
.where(sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting')`),
// the slot per company, owner, and adapter. The index applies to the active
// states of the merged set. It does not include the environment; the board
// rule scopes the slot to the owner, not the environment.
companyOwnerAdapterActiveUq: uniqueIndex("adapter_auth_sessions_company_owner_adapter_active_uq")
.on(table.companyId, table.startedByUserId, table.adapterType)
.where(
sql`${table.status} IN ('starting', 'waiting_for_user', 'promoting', 'awaiting_code', 'submitting')`,
),
// The public session id is unique across the table. The store keys its
// lookups by this id.
publicSessionIdUq: uniqueIndex("adapter_auth_sessions_public_session_id_uq").on(
table.publicSessionId,
),
environmentIdx: index("adapter_auth_sessions_environment_idx").on(table.environmentId),
expiresIdx: index("adapter_auth_sessions_expires_idx").on(table.expiresAt),
providerLeaseIdx: index("adapter_auth_sessions_provider_lease_idx").on(table.providerLeaseId),

View File

@ -1,83 +0,0 @@
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),
}),
);

View File

@ -35,11 +35,6 @@ 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";

View File

@ -1,10 +1,12 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.daytona-sandbox-provider";
// 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";
// 0.1.3 renames the login transport flag from `supportsSetupTokenLogin` to the
// neutral `supportsLoginPty`. The boot reconcile reads the persisted manifest
// raw and does not re-run the validator, so it never canonicalizes the old
// name. The version bump makes the reconcile refresh the persisted manifest for
// an existing install, so the renamed capability propagates.
const PLUGIN_VERSION = "0.1.3";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
@ -44,10 +46,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,
// Daytona hosts an interactive login on a real pseudo-terminal. It is the
// only bundled provider that implements the login pseudo-terminal methods,
// so it advertises the capability.
supportsLoginPty: true,
configSchema: {
type: "object",
properties: {

View File

@ -0,0 +1,9 @@
import { describe, it, expect } from "vitest";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
describe("ADAPTER_AUTH_MISSING_CHECK_CODE", () => {
it("keeps the canonical missing-auth check code stable", () => {
expect(ADAPTER_AUTH_MISSING_CHECK_CODE).toBe("adapter_auth_missing");
});
});

View File

@ -0,0 +1,12 @@
/**
* The canonical check code an adapter 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 adapters and the user interface
* import this one constant, so the user interface can gate login from one code
* across adapters.
*/
export const ADAPTER_AUTH_MISSING_CHECK_CODE = "adapter_auth_missing";

View File

@ -49,7 +49,7 @@ export interface EnvironmentProviderCapability {
templateRefKind?: string;
templateConfigBinding?: PluginEnvironmentTemplateConfigBinding;
supportsTemplateDelete: boolean;
supportsSetupTokenLogin: boolean;
supportsLoginPty: boolean;
displayName?: string;
description?: string;
source?: "builtin" | "plugin";
@ -157,7 +157,7 @@ export function getEnvironmentCapabilities(
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
supportsSetupTokenLogin: false,
supportsLoginPty: false,
displayName: "Fake",
source: "builtin",
},
@ -177,7 +177,7 @@ export function getEnvironmentCapabilities(
templateRefKind: capability.templateRefKind,
templateConfigBinding: capability.templateConfigBinding,
supportsTemplateDelete: capability.supportsTemplateDelete ?? false,
supportsSetupTokenLogin: capability.supportsSetupTokenLogin ?? false,
supportsLoginPty: capability.supportsLoginPty ?? false,
displayName: capability.displayName,
description: capability.description,
source: capability.source ?? "plugin",

View File

@ -1,4 +1,5 @@
export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js";
export { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./adapter-auth-check-code.js";
export {
decisionEffectStalenessSchema,
decisionOptionStyleSchema,

View File

@ -216,10 +216,19 @@ export interface PluginEnvironmentDriverDeclaration {
/** 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.
* Provider can host an interactive login on a real pseudo-terminal. Only a
* provider with this flag exposes the login pseudo-terminal methods. The login
* server and the login UI both gate on this flag, so a provider without it
* never starts a login.
*/
supportsLoginPty?: boolean;
/**
* Deprecated alias for `supportsLoginPty`. It exists only so an external
* plugin manifest that declares the old name still loads. The manifest
* validator canonicalizes it onto `supportsLoginPty` and drops it. Do not read
* this field; read `supportsLoginPty`.
*
* @deprecated Use `supportsLoginPty`.
*/
supportsSetupTokenLogin?: boolean;
/** JSON Schema describing the driver's provider-specific configuration. */

View File

@ -350,3 +350,64 @@ describe("sandbox provider capability declaration validators", () => {
expect(resolveDeclaredSandboxCapabilities(driver!).reusableLeases).toBe(false);
});
});
describe("login pty transport capability and legacy alias", () => {
it("test_login_pty_new_field_parses_and_carries_the_flag", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsLoginPty: true }),
);
expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true);
});
it("test_legacy_alias_only_canonicalizes_onto_login_pty", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({ supportsSetupTokenLogin: true }),
);
const driver = parsed.environmentDrivers?.[0];
// The validator maps the deprecated alias onto the canonical field at parse
// time, and it drops the alias so a downstream reader cannot read the old
// name.
expect(driver?.supportsLoginPty).toBe(true);
expect(driver).not.toHaveProperty("supportsSetupTokenLogin");
});
it("test_conflicting_alias_and_new_field_reject", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({
supportsLoginPty: true,
supportsSetupTokenLogin: false,
}),
);
expect(rejected.success).toBe(false);
});
it("test_matching_alias_and_new_field_pass", () => {
const parsed = pluginManifestV1Schema.parse(
buildSandboxProviderManifest({
supportsLoginPty: true,
supportsSetupTokenLogin: true,
}),
);
expect(parsed.environmentDrivers?.[0]?.supportsLoginPty).toBe(true);
});
it("test_unknown_login_field_misspelling_rejects", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ supportsLoginPTY: true }),
);
expect(rejected.success).toBe(false);
});
it("test_login_pty_accepts_literal_booleans_only", () => {
const rejected = pluginManifestV1Schema.safeParse(
buildSandboxProviderManifest({ supportsLoginPty: "true" }),
);
expect(rejected.success).toBe(false);
});
});

View File

@ -157,8 +157,8 @@ export const pluginEnvironmentTemplateConfigBindingSchema = z.object({
// The nested sandbox capability declaration is `.strict()` so an unknown
// capability key is a validation error, not a silently dropped field. The outer
// driver schema below is non-strict and drops unknown top-level keys, so the
// declaration itself is the gate that a typo in a capability name cannot pass.
// driver schema below is also `.strict()`, so a typo in a top-level capability
// name cannot pass either.
export const sandboxProviderCapabilitiesSchema = z.object({
reusableLeases: z.boolean().optional(),
nativeSyncIn: z.boolean().optional(),
@ -170,6 +170,9 @@ export const sandboxProviderCapabilitiesSchema = z.object({
export type SandboxProviderCapabilitiesInput = z.infer<typeof sandboxProviderCapabilitiesSchema>;
// The driver declaration schema is `.strict()` so an unknown top-level key is a
// validation error, not a silently dropped field. A misspelled capability name
// (for example `supportsLoginPTY`) fails validation instead of dropping.
export const pluginEnvironmentDriverDeclarationSchema = z.object({
driverKey: z.string().min(1).regex(
/^[a-z0-9][a-z0-9._-]*$/,
@ -187,8 +190,38 @@ export const pluginEnvironmentDriverDeclarationSchema = z.object({
templateConfigBinding: pluginEnvironmentTemplateConfigBindingSchema.optional(),
templateIdentityPaths: z.array(z.string().min(1).max(200)).max(20).optional(),
supportsTemplateDelete: z.boolean().optional(),
// The neutral transport capability: does the provider host an interactive
// login on a real pseudo-terminal? Validate a literal boolean only.
supportsLoginPty: z.boolean().optional(),
// Deprecated alias for `supportsLoginPty`. It exists only so an external
// plugin manifest that declares the old name still loads. Validate a literal
// boolean only. The transform below canonicalizes it onto `supportsLoginPty`
// and drops it, so every downstream reader reads only the canonical field.
supportsSetupTokenLogin: z.boolean().optional(),
configSchema: jsonSchemaSchema,
}).strict().superRefine((driver, ctx) => {
// Reject a manifest that carries both the alias and the canonical field with
// different values. Matching values pass. A single field passes.
if (
driver.supportsLoginPty !== undefined
&& driver.supportsSetupTokenLogin !== undefined
&& driver.supportsLoginPty !== driver.supportsSetupTokenLogin
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"supportsSetupTokenLogin is a deprecated alias for supportsLoginPty; the two flags must not carry different values",
path: ["supportsLoginPty"],
});
}
}).transform((driver) => {
// Canonicalize before any authorization or projection reads the value. The
// canonical field wins; the alias fills it in when only the alias is present.
// Drop the alias so every downstream reader reads only `supportsLoginPty`. An
// undefined value is omitted on JSON serialization, so a driver that declares
// neither field carries no login flag.
const { supportsSetupTokenLogin, ...rest } = driver;
return { ...rest, supportsLoginPty: rest.supportsLoginPty ?? supportsSetupTokenLogin };
});
export type PluginEnvironmentDriverDeclarationInput = z.infer<

View File

@ -177,19 +177,22 @@ vi.mock("../services/codex-device-login-service.js", async (importOriginal) => {
function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, AdapterAuthSessionRow> } {
const rows = new Map<string, AdapterAuthSessionRow>();
const activeSlots = new Set<string>();
const slotKey = (companyId: string, adapterType: string) => `${companyId}|${adapterType}`;
// The active slot is scoped to the company, the owner, and the adapter.
const slotKey = (companyId: string, startedByUserId: string, adapterType: string) =>
`${companyId}|${startedByUserId}|${adapterType}`;
const isActive = (status: AdapterAuthSessionRow["status"]) =>
status === "starting" || status === "waiting_for_user" || status === "promoting";
return {
rows,
async insert(input) {
const key = slotKey(input.companyId, input.adapterType);
const key = slotKey(input.companyId, input.startedByUserId, input.adapterType);
if (activeSlots.has(key)) {
throw new AdapterAuthSessionConflictError();
}
activeSlots.add(key);
rows.set(input.id, {
id: input.id,
publicSessionId: input.publicSessionId,
companyId: input.companyId,
environmentId: input.environmentId,
adapterType: input.adapterType,
@ -213,7 +216,8 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType));
if (!isActive(input.status))
activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType));
},
async compareAndSetStatus(input) {
const row = rows.get(input.sessionId);
@ -222,14 +226,25 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType));
if (!isActive(input.status))
activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType));
return true;
},
async get(sessionId) {
const row = rows.get(sessionId);
return row ? { ...row } : null;
},
async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) {
async getByPublicId(publicSessionId, companyId) {
// Scope the read to the company and the public session id, so a
// foreign-company lookup reads nothing and the internal id never matches.
for (const row of rows.values()) {
if (row.publicSessionId === publicSessionId && row.companyId === companyId) {
return { ...row };
}
}
return null;
},
async withCompanyAdapterPromotionLock(_companyId, _startedByUserId, _adapterType, fn) {
// The route test runs on a single event loop, so it needs no real lock. The
// pass-through keeps the promotion contract satisfied.
return fn();
@ -370,13 +385,21 @@ describe("adapter device-login routes", () => {
adapterType: "codex_local",
startedByUserId: OWNER_A,
});
// The row persists the immutable owner from the actor.
// The row persists the immutable owner from the actor. The response carries
// the public session id, so read the row by the public id, not the internal id.
const store = harness.store as ReturnType<typeof createMemoryStore>;
const row = store.rows.get(res.body.sessionId);
const row = await store.getByPublicId(res.body.sessionId, COMPANY_1);
expect(row?.startedByUserId).toBe(OWNER_A);
// The public session id is never the internal row id.
expect(row?.id).not.toBe(res.body.sessionId);
// No row is keyed by the public session id in the internal-id map.
expect(store.rows.get(res.body.sessionId)).toBeUndefined();
});
it("rejects a non-codex adapter", async () => {
it("rejects an adapter whose login capability drives a different transport", async () => {
// The Claude adapter declares a pseudo-terminal login, not a streamed-exec
// device login. The guard reads the capability transport, so it rejects the
// adapter with a fixed 400 before any lease.
const app = await createApp();
const res = await request(app)
@ -387,6 +410,58 @@ describe("adapter device-login routes", () => {
expect(harness.acquisitions).toHaveLength(0);
});
it("starts a device login for a third adapter that declares the streamed-exec capability", async () => {
// A third adapter, not the Codex adapter, declares a streamed-exec login
// capability. The guard reads the registry capability, not the adapter name,
// so the adapter passes the guard and starts a session. This proves no
// adapter-name branch remains in the guard path. The test overrides an
// existing adapter type so the strict request schema accepts it.
const app = await createApp();
const { registerServerAdapter, unregisterServerAdapter } = await import("../adapters/index.js");
registerServerAdapter({
type: "gemini_local",
execute: async () => {
throw new Error("not used");
},
testEnvironment: async () => {
throw new Error("not used");
},
loginCapability: {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
},
});
try {
const res = await request(app)
.post(loginPath(COMPANY_1, "gemini_local"))
.send({ environmentId: SANDBOX_ENV_1 });
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body).toMatchObject({ environmentId: SANDBOX_ENV_1, status: "starting" });
expect(harness.acquisitions).toHaveLength(1);
expect(harness.acquisitions[0]).toMatchObject({ adapterType: "gemini_local" });
} finally {
unregisterServerAdapter("gemini_local");
}
});
it("rejects a malformed start body with the strict schema before any side effect", async () => {
const app = await createApp();
// The strict start schema rejects an unknown field. The old lax parse
// accepted an extra field and started a session; the shared spine now fails
// the request with a fixed 400 before it acquires a lease.
const res = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1, unexpectedField: "x" });
expect(res.status, JSON.stringify(res.body)).toBe(400);
expect(harness.acquisitions).toHaveLength(0);
});
it("rejects a local environment", async () => {
mockEnvironmentService.getById.mockResolvedValueOnce({
id: SANDBOX_ENV_1,
@ -522,7 +597,7 @@ describe("adapter device-login routes", () => {
expect(restart.body.sessionId).not.toBe(sessionId);
});
it("returns 409 for a second active start by a different owner", async () => {
it("lets a second owner start an active login in the same company", async () => {
const app = await createApp();
const first = await request(app)
@ -531,13 +606,16 @@ describe("adapter device-login routes", () => {
expect(first.status, JSON.stringify(first.body)).toBe(201);
// A different owner starts a second login for the same company and adapter.
// The active slot is scoped to the company, the owner, and the adapter, so
// the second owner holds an independent slot and the start succeeds.
currentActor = boardActor(OWNER_B);
const second = await request(app)
.post(loginPath(COMPANY_1))
.send({ environmentId: SANDBOX_ENV_1 });
expect(second.status, JSON.stringify(second.body)).toBe(409);
// The second start never acquires a lease.
expect(harness.acquisitions).toHaveLength(1);
expect(second.status, JSON.stringify(second.body)).toBe(201);
expect(second.body.sessionId).not.toBe(first.body.sessionId);
// Each owner's start acquires its own lease.
expect(harness.acquisitions).toHaveLength(2);
});
it("returns 409 for a second active start in a different environment", async () => {
@ -576,7 +654,10 @@ describe("adapter device-login routes", () => {
expect(status.body.failure?.reason).toBe("promotion_failed");
});
const row = (harness.store as ReturnType<typeof createMemoryStore>).rows.get(sessionId);
const row = await (harness.store as ReturnType<typeof createMemoryStore>).getByPublicId(
sessionId,
COMPANY_1,
);
expect(row?.status).toBe("failed");
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
});
@ -602,7 +683,10 @@ describe("adapter device-login routes", () => {
expect(status.body.failure?.reason).toBe("promotion_failed");
});
const row = (harness.store as ReturnType<typeof createMemoryStore>).rows.get(sessionId);
const row = await (harness.store as ReturnType<typeof createMemoryStore>).getByPublicId(
sessionId,
COMPANY_1,
);
expect(row?.status).toBe("failed");
expect(mockDeviceLoginPromotion).toHaveBeenCalledTimes(1);
});

View File

@ -6,8 +6,8 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
activityLog,
adapterAuthSessions,
agents,
claudeSetupTokenSessions,
companies,
companySecretBindings,
companySecretProviderConfigs,
@ -227,7 +227,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
await db.delete(companySecretProviderConfigs);
await db.delete(userSecretDeclarations);
await db.delete(userSecretDefinitions);
await db.delete(claudeSetupTokenSessions);
await db.delete(adapterAuthSessions);
await db.delete(agents);
await db.delete(environments);
await db.delete(companies);
@ -303,8 +303,8 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
async function readClaim(sessionId: string) {
const rows = await db
.select()
.from(claudeSetupTokenSessions)
.where(eq(claudeSetupTokenSessions.sessionId, sessionId));
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.publicSessionId, sessionId));
return rows[0];
}
@ -507,10 +507,10 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
});
const lockHeld = lockDb.transaction(async (tx) => {
await tx.execute(
sql`SELECT bound_at FROM claude_setup_token_sessions WHERE session_id = ${sessionId} FOR UPDATE`,
sql`SELECT bound_at FROM adapter_auth_sessions WHERE public_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}`,
sql`UPDATE adapter_auth_sessions SET expires_at = clock_timestamp() + interval '300 milliseconds' WHERE public_session_id = ${sessionId}`,
);
signalLocked();
await gate;
@ -639,7 +639,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
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);
const claims = await db.select().from(adapterAuthSessions);
expect(claims).toHaveLength(0);
});
@ -803,7 +803,6 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
return {
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
targetAgentId: null,
adapterType: "claude_local",
environmentId: scope.environmentId,
};
@ -818,7 +817,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
// The durable row transitioned to `stored` and stays unconsumed.
const claim = await readClaim(sessionId);
expect(claim?.state).toBe("stored");
expect(claim?.status).toBe("stored");
expect(claim?.boundAt).toBeNull();
// The owner value committed in the same transaction.
const status = await secretService(db).readClaudeOAuthUserSecretStatus(
@ -848,7 +847,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
).rejects.toThrow();
const claim = await readClaim(sessionId);
expect(claim?.state).toBe("submitting");
expect(claim?.status).toBe("submitting");
expect(claim?.boundAt).toBeNull();
expect(
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
@ -865,7 +864,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
writer({ scope: claimScope(scope), sessionId, token: "sk-ant-oat01-EXPIRED" }),
).rejects.toThrow();
expect((await readClaim(sessionId))?.state).toBe("submitting");
expect((await readClaim(sessionId))?.status).toBe("submitting");
expect(
await secretService(db).readClaudeOAuthUserSecretStatus(scope.companyId, scope.ownerUserId),
).toBeNull();
@ -918,7 +917,7 @@ describeEmbeddedPostgres("agent service Claude OAuth binding claim", () => {
});
// The claim transitioned and the stored value rotated to a new version.
expect((await readClaim(sessionId))?.state).toBe("stored");
expect((await readClaim(sessionId))?.status).toBe("stored");
const after = await secretService(db).readClaudeOAuthUserSecretStatus(
scope.companyId,
scope.ownerUserId,

View File

@ -86,7 +86,7 @@ function createMemoryReaperStore(): AdapterAuthReaperStore & {
const row = rows.get(sessionId);
return row ? { ...row } : null;
},
async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) {
async withCompanyAdapterPromotionLock(_companyId, _startedByUserId, _adapterType, fn) {
// The in-memory store runs on a single event loop, so it needs no real
// lock. The pass-through keeps the reaper contract satisfied.
return fn();

View File

@ -66,12 +66,15 @@ const OWNER_B = "user-b";
// the status through a serialized tail, so a test waits for the write to land
// before it drives the next step.
async function waitForStatus(
store: { get(sessionId: string): Promise<AdapterAuthSessionRow | null> },
sessionId: string,
store: {
getByPublicId(publicSessionId: string, companyId: string): Promise<AdapterAuthSessionRow | null>;
},
publicSessionId: string,
companyId: string,
status: AdapterAuthSessionRow["status"],
): Promise<void> {
for (let attempt = 0; attempt < 1000; attempt += 1) {
const row = await store.get(sessionId);
const row = await store.getByPublicId(publicSessionId, companyId);
if (row?.status === status) return;
await new Promise((resolve) => setImmediate(resolve));
}
@ -153,17 +156,21 @@ function createMemoryStore(): AdapterAuthSessionStore & {
} {
const rows = new Map<string, AdapterAuthSessionRow>();
const activeSlots = new Set<string>();
const slotKey = (companyId: string, adapterType: string) => `${companyId}|${adapterType}`;
// The active company credential slot is scoped to the company, the owner, and
// the adapter, so two owners in one company hold independent slots.
const slotKey = (companyId: string, startedByUserId: string, adapterType: string) =>
`${companyId}|${startedByUserId}|${adapterType}`;
const isActive = (status: AdapterAuthSessionRow["status"]) =>
status === "starting" || status === "waiting_for_user" || status === "promoting";
return {
rows,
async insert(input) {
const key = slotKey(input.companyId, input.adapterType);
const key = slotKey(input.companyId, input.startedByUserId, input.adapterType);
if (activeSlots.has(key)) throw new AdapterAuthSessionConflictError();
activeSlots.add(key);
rows.set(input.id, {
id: input.id,
publicSessionId: input.publicSessionId,
companyId: input.companyId,
environmentId: input.environmentId,
adapterType: input.adapterType,
@ -187,7 +194,8 @@ function createMemoryStore(): AdapterAuthSessionStore & {
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType));
if (!isActive(input.status))
activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType));
},
async compareAndSetStatus(input) {
const row = rows.get(input.sessionId);
@ -196,14 +204,25 @@ function createMemoryStore(): AdapterAuthSessionStore & {
if (input.failureReason !== undefined) row.failureReason = input.failureReason;
if (input.finishedAt !== undefined) row.finishedAt = input.finishedAt;
if (input.promotionExpiresAt !== undefined) row.promotionExpiresAt = input.promotionExpiresAt;
if (!isActive(input.status)) activeSlots.delete(slotKey(row.companyId, row.adapterType));
if (!isActive(input.status))
activeSlots.delete(slotKey(row.companyId, row.startedByUserId, row.adapterType));
return true;
},
async get(sessionId) {
const row = rows.get(sessionId);
return row ? { ...row } : null;
},
async withCompanyAdapterPromotionLock(_companyId, _adapterType, fn) {
async getByPublicId(publicSessionId, companyId) {
// Scope the read to the company and the public session id, so a
// foreign-company lookup reads nothing and the internal id never matches.
for (const row of rows.values()) {
if (row.publicSessionId === publicSessionId && row.companyId === companyId) {
return { ...row };
}
}
return null;
},
async withCompanyAdapterPromotionLock(_companyId, _startedByUserId, _adapterType, fn) {
// The in-memory store runs on a single event loop, so it needs no real
// lock. The pass-through keeps the store contract satisfied.
return fn();
@ -275,15 +294,17 @@ describe("codex device login service", () => {
// The prompt is surfaced only after the conditional move to
// `waiting_for_user` wins, so wait for that surface, then drain the microtask
// that retains the prompt before the owner reads it.
await waitForStatus(store, session.sessionId, "waiting_for_user");
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
await new Promise((resolve) => setImmediate(resolve));
// The owner reads the one-time prompt through the owner read path.
const owner = await service.readOwnerSession(session.sessionId, OWNER_A);
const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(owner?.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
// The read returns the public session id, never the internal row id.
expect(owner?.sessionId).toBe(session.sessionId);
// A non-owner never reads the prompt.
const other = await service.readOwnerSession(session.sessionId, OWNER_B);
const other = await service.readOwnerSession(session.sessionId, companyId, OWNER_B);
expect(other?.prompt).toBeNull();
const outcome = await completed;
@ -292,15 +313,18 @@ describe("codex device login service", () => {
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
expect(promoted).toHaveLength(1);
// The promotion runs with the session and company context, so it resolves the
// company scope and the sole-active-owner check for this exact session.
expect(promotionContexts).toEqual([{ sessionId: session.sessionId, companyId }]);
// The promotion runs with the internal session id and the company context, so
// it resolves the company scope and the sole-active-owner check for this exact
// session. The internal id is not the public session id.
const internalRow = await store.getByPublicId(session.sessionId, companyId);
expect(promotionContexts).toEqual([{ sessionId: internalRow?.id, companyId }]);
expect(internalRow?.id).not.toBe(session.sessionId);
// The prompt is one-time: a second owner read returns null.
const secondRead = await service.readOwnerSession(session.sessionId, OWNER_A);
const secondRead = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(secondRead?.prompt).toBeNull();
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("authenticated");
expect(row?.finishedAt).not.toBeNull();
@ -338,8 +362,9 @@ describe("codex device login service", () => {
},
},
});
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -350,9 +375,9 @@ describe("codex device login service", () => {
expect(outcome.status).toBe("failed");
expect(promoteCalls).toBe(0);
expect(deleteCalls).toHaveLength(1);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("failed");
const failed = await service.readOwnerSession(session.sessionId, OWNER_A);
const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(failed?.failure?.reason).toBe("promotion_failed");
});
@ -360,8 +385,9 @@ describe("codex device login service", () => {
const store = createMemoryStore();
const { runtime, deleteCalls } = createFakeRuntime({ exec: execFailure });
const service = makeService({ store, runtime });
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -370,7 +396,7 @@ describe("codex device login service", () => {
expect(outcome.status).toBe("failed");
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
const failed = await service.readOwnerSession(session.sessionId, OWNER_A);
const failed = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(failed?.status).toBe("failed");
expect(failed?.failure?.reason).toBe("login_command_failed");
});
@ -395,8 +421,9 @@ describe("codex device login service", () => {
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
const service = makeService({ store, runtime });
const controller = new AbortController();
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -407,7 +434,7 @@ describe("codex device login service", () => {
expect(outcome.status).toBe("cancelled");
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("cancelled");
});
@ -462,8 +489,9 @@ describe("codex device login service", () => {
});
const service = makeService({ store, runtime });
const controller = new AbortController();
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -475,10 +503,10 @@ describe("codex device login service", () => {
expect(outcome.cleanupPending).toBe(true);
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("cleanup_pending");
// The public read resolves the retained terminal, never a false-clean.
const publicRead = await service.readOwnerSession(session.sessionId, OWNER_A);
const publicRead = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(publicRead?.status).toBe(terminal);
},
);
@ -499,8 +527,9 @@ describe("codex device login service", () => {
},
},
});
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -512,9 +541,9 @@ describe("codex device login service", () => {
expect(outcome.cleanupPending).toBe(true);
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("cleanup_pending");
const publicRead = await service.readOwnerSession(session.sessionId, OWNER_A);
const publicRead = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
expect(publicRead?.status).toBe("failed");
expect(publicRead?.failure?.reason).toBe("promotion_failed");
});
@ -527,8 +556,9 @@ describe("codex device login service", () => {
delete: async () => ({ outcome: "not_found" }),
});
const service = makeService({ store, runtime });
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -537,7 +567,7 @@ describe("codex device login service", () => {
expect(outcome.status).toBe("authenticated");
expect(outcome.cleanupPending).toBe(false);
expect(outcome.sandboxDeleteObserved).toBe(true);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("authenticated");
});
@ -560,19 +590,22 @@ describe("codex device login service", () => {
});
const promote = vi.fn(() => {});
const service = makeService({ store, runtime, promotion: { promote } });
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
// The prompt moves the row to the active `waiting_for_user` state.
await waitForStatus(store, session.sessionId, "waiting_for_user");
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
// The reaper wins the slot: it terminates the row before the promotion claim.
// The status write keys on the internal id, not the public session id.
const seededRow = await store.getByPublicId(session.sessionId, companyId);
await store.setStatus({
sessionId: session.sessionId,
sessionId: seededRow!.id,
status: "timed_out",
at: new Date(),
finishedAt: new Date(),
@ -586,7 +619,7 @@ describe("codex device login service", () => {
// The lost claim writes no credential and never authenticates.
expect(promote).not.toHaveBeenCalled();
expect(outcome.status).not.toBe("authenticated");
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("timed_out");
// The service still deletes its own sandbox on the lost-claim path.
expect(deleteCalls).toHaveLength(1);
@ -616,18 +649,18 @@ describe("codex device login service", () => {
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
await waitForStatus(store, session.sessionId, "waiting_for_user");
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
// A non-owner cannot cancel the session.
expect(await service.cancelOwnerSession(session.sessionId, OWNER_B)).toBeNull();
expect(await service.cancelOwnerSession(session.sessionId, companyId, OWNER_B)).toBeNull();
// The owner cancel resolves the public terminal status at once.
const cancelled = await service.cancelOwnerSession(session.sessionId, OWNER_A);
const cancelled = await service.cancelOwnerSession(session.sessionId, companyId, OWNER_A);
expect(cancelled?.status).toBe("cancelled");
// The row holds the internal cleanup_pending state that encodes the
// cancelled terminal, so the reaper deletes the sandbox and finalizes it.
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("cleanup_pending");
expect(row?.finishedAt).not.toBeNull();
@ -662,19 +695,20 @@ describe("codex device login service", () => {
});
const { runtime } = createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") });
const service = makeService({ store, runtime, promotion: { promote } });
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
});
await waitForStatus(store, session.sessionId, "promoting");
await waitForStatus(store, session.sessionId, companyId, "promoting");
// The cancel skips the promoting row, so the credential write finishes. The
// public projection of a promoting row is `waiting_for_user`.
const result = await service.cancelOwnerSession(session.sessionId, OWNER_A);
const result = await service.cancelOwnerSession(session.sessionId, companyId, OWNER_A);
expect(result?.status).toBe("waiting_for_user");
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("promoting");
// Release the promotion, so the run reaches its own terminal.
@ -691,8 +725,9 @@ describe("codex device login service", () => {
const store = createMemoryStore();
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
const service = makeService({ store, runtime });
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -706,7 +741,7 @@ describe("codex device login service", () => {
// claim.
await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS - 1);
expect(settled).toBe(false);
const midRow = await store.get(session.sessionId);
const midRow = await store.getByPublicId(session.sessionId, companyId);
expect(["starting", "waiting_for_user"]).toContain(midRow?.status);
// The last millisecond fires the timeout.
@ -715,7 +750,7 @@ describe("codex device login service", () => {
expect(outcome.status).toBe("timed_out");
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("timed_out");
} finally {
vi.useRealTimers();
@ -733,8 +768,9 @@ describe("codex device login service", () => {
},
});
const service = makeService({ store, runtime });
const companyId = randomUUID();
const { session, completed } = await service.start({
companyId: randomUUID(),
companyId,
environmentId: randomUUID(),
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
@ -745,7 +781,7 @@ describe("codex device login service", () => {
expect(outcome.cleanupPending).toBe(true);
expect(outcome.sandboxDeleteObserved).toBe(true);
expect(deleteCalls).toHaveLength(1);
const row = await store.get(session.sessionId);
const row = await store.getByPublicId(session.sessionId, companyId);
expect(row?.status).toBe("cleanup_pending");
} finally {
vi.useRealTimers();
@ -887,73 +923,112 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
return environmentId;
}
it.each([
{ name: "two owners, same environment" },
{ name: "one owner, two environments" },
])(
"returns one session, one lease, and one 409 for concurrent starts ($name)",
async ({ name }) => {
const { companyId, environmentId: environmentA } = await seedCompanyEnvironment();
const twoOwners = name.startsWith("two owners");
const environmentB = twoOwners ? environmentA : await seedEnvironment(companyId);
const ownerB = twoOwners ? OWNER_B : OWNER_A;
it("lets two owners in one company each hold an active session", async () => {
// The active slot is scoped to the company, the owner, and the adapter. Two
// owners in one company hold independent slots, so both concurrent starts
// succeed and each acquires one lease.
const { companyId, environmentId } = await seedCompanyEnvironment();
const store = createDbAdapterAuthSessionStore(db);
const { runtime, acquisitions } = createFakeRuntime({ exec: execHang });
const service = makeService({ store, runtime });
const controller = new AbortController();
const store = createDbAdapterAuthSessionStore(db);
const { runtime, acquisitions } = createFakeRuntime({ exec: execHang });
const service = makeService({ store, runtime });
const controller = new AbortController();
const results = await Promise.allSettled([
service.start({
companyId,
environmentId,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
signal: controller.signal,
}),
service.start({
companyId,
environmentId,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_B,
signal: controller.signal,
}),
]);
const results = await Promise.allSettled([
service.start({
companyId,
environmentId: environmentA,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
signal: controller.signal,
}),
service.start({
companyId,
environmentId: environmentB,
adapterType: ADAPTER_TYPE,
startedByUserId: ownerB,
signal: controller.signal,
}),
]);
const fulfilled = results.filter(
(result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof service.start>>> =>
result.status === "fulfilled",
);
expect(fulfilled).toHaveLength(2);
expect(acquisitions).toHaveLength(2);
const rows = await db
.select()
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.companyId, companyId));
expect(rows).toHaveLength(2);
const fulfilled = results.filter(
(result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof service.start>>> =>
result.status === "fulfilled",
);
const rejected = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
const conflict = rejected[0]!.reason;
expect(conflict).toBeInstanceOf(AdapterAuthSessionConflictError);
expect(conflict.statusCode).toBe(409);
// Release the surviving runs so the hanging login commands end.
controller.abort();
await Promise.all(fulfilled.map((result) => result.value.completed));
});
// Exactly one start acquired a lease; the losing start never acquired.
expect(acquisitions).toHaveLength(1);
const rows = await db
.select()
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.companyId, companyId));
expect(rows).toHaveLength(1);
it("returns one session, one lease, and one 409 when one owner starts twice", async () => {
// One owner starting twice in two environments conflicts on the active slot.
// The slot dropped the environment term, so the second environment does not
// grant a second active session.
const { companyId, environmentId: environmentA } = await seedCompanyEnvironment();
const environmentB = await seedEnvironment(companyId);
// Release the surviving run so the hanging login command ends.
controller.abort();
await fulfilled[0]!.value.completed;
},
);
const store = createDbAdapterAuthSessionStore(db);
const { runtime, acquisitions } = createFakeRuntime({ exec: execHang });
const service = makeService({ store, runtime });
const controller = new AbortController();
it("rejects a second start with a 409 while the first login holds the promotion claim", async () => {
const results = await Promise.allSettled([
service.start({
companyId,
environmentId: environmentA,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
signal: controller.signal,
}),
service.start({
companyId,
environmentId: environmentB,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
signal: controller.signal,
}),
]);
const fulfilled = results.filter(
(result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof service.start>>> =>
result.status === "fulfilled",
);
const rejected = results.filter(
(result): result is PromiseRejectedResult => result.status === "rejected",
);
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
const conflict = rejected[0]!.reason;
expect(conflict).toBeInstanceOf(AdapterAuthSessionConflictError);
expect(conflict.statusCode).toBe(409);
// Exactly one start acquired a lease; the losing start never acquired.
expect(acquisitions).toHaveLength(1);
const rows = await db
.select()
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.companyId, companyId));
expect(rows).toHaveLength(1);
// Release the surviving run so the hanging login command ends.
controller.abort();
await fulfilled[0]!.value.completed;
});
it("rejects the same owner's second start with a 409 while the first holds the promotion claim", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const store = createDbAdapterAuthSessionStore(db);
const { runtime } = createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") });
// A promotion that never resolves. The first login stays in `promoting`, so
// it holds the active company slot through the claim window.
// it holds the active slot through the claim window.
let releasePromotion!: () => void;
const promotionGate = new Promise<void>((resolve) => {
releasePromotion = resolve;
@ -972,16 +1047,16 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
});
// The first login reaches the `promoting` claim and holds it.
await waitForStatus(store, first.session.sessionId, "promoting");
await waitForStatus(store, first.session.sessionId, companyId, "promoting");
// A second start for the same company and adapter conflicts on the active
// slot, even though the first login is mid-promotion.
// A second start for the same company, owner, and adapter conflicts on the
// active slot, even though the first login is mid-promotion.
await expect(
service.start({
companyId,
environmentId,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_B,
startedByUserId: OWNER_A,
}),
).rejects.toBeInstanceOf(AdapterAuthSessionConflictError);
@ -1003,6 +1078,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
environmentId,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
publicSessionId: randomUUID(),
providerLeaseId: `lease-${sessionId}`,
status: "promoting",
expiresAt: past,
@ -1033,7 +1109,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
const sectionActive = new Promise<void>((resolve) => {
markSectionActive = resolve;
});
const promotion = store.withCompanyAdapterPromotionLock(companyId, ADAPTER_TYPE, async () => {
const promotion = store.withCompanyAdapterPromotionLock(companyId, OWNER_A, ADAPTER_TYPE, async () => {
markSectionActive();
await sectionGate;
// Decision H: the write proceeds only while the session still owns the slot.
@ -1078,7 +1154,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
// A delayed promotion now runs its section. The ownership check reads the
// reclaimed row, so it writes no credential and the terminal claim fails.
let wroteCredential = false;
await store.withCompanyAdapterPromotionLock(companyId, ADAPTER_TYPE, async () => {
await store.withCompanyAdapterPromotionLock(companyId, OWNER_A, ADAPTER_TYPE, async () => {
const row = await store.get(sessionId);
if (row?.status === "promoting" && row.companyId === companyId) {
wroteCredential = true;
@ -1122,8 +1198,8 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
startedByUserId: OWNER_A,
signal: controller.signal,
});
const sessionId = started.session.sessionId;
await waitForStatus(store, sessionId, "starting");
const publicSessionId = started.session.sessionId;
await waitForStatus(store, publicSessionId, companyId, "starting");
// The reaper reclaims the expired row and times it out.
const { runtime: reaperRuntime } = createReaperRuntime();
@ -1134,7 +1210,7 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
});
const sweep = await reaper.sweep();
expect(sweep.expiredTimedOut).toBe(1);
expect((await store.get(sessionId))?.status).toBe("timed_out");
expect((await store.getByPublicId(publicSessionId, companyId))?.status).toBe("timed_out");
// The delayed prompt arrives now. The conditional transition must not revive
// the reaped row, and the owner must not read a prompt for it.
@ -1142,12 +1218,94 @@ describeEmbeddedPostgres("codex device login service concurrency (embedded postg
await new Promise((resolve) => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
const owner = await service.readOwnerSession(sessionId, OWNER_A);
expect((await store.get(sessionId))?.status).toBe("timed_out");
const owner = await service.readOwnerSession(publicSessionId, companyId, OWNER_A);
expect((await store.getByPublicId(publicSessionId, companyId))?.status).toBe("timed_out");
expect(owner?.prompt ?? null).toBeNull();
// End the run so no timer survives.
controller.abort();
await started.completed;
});
describe("public session id lookup", () => {
// Start one session and read its rows. The service returns the public session
// id, and the store keeps the internal id private.
async function startOneSession(companyId: string, environmentId: string) {
const store = createDbAdapterAuthSessionStore(db);
const controller = new AbortController();
const { runtime } = createFakeRuntime({ exec: execHang });
const service = makeService({ store, runtime });
const started = await service.start({
companyId,
environmentId,
adapterType: ADAPTER_TYPE,
startedByUserId: OWNER_A,
signal: controller.signal,
});
const publicSessionId = started.session.sessionId;
const row = await store.getByPublicId(publicSessionId, companyId);
return { store, service, controller, started, publicSessionId, row };
}
it("reads a session by its public id in the same company", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const { store, controller, started, publicSessionId, row } = await startOneSession(
companyId,
environmentId,
);
// The lookup by the public id succeeds in the same company.
expect(row).not.toBeNull();
expect(row?.publicSessionId).toBe(publicSessionId);
// The internal primary-key id is a distinct value, never the public id.
expect(row?.id).toBeTruthy();
expect(row?.id).not.toBe(publicSessionId);
// The API-facing row read stays scoped: the same public id resolves the
// same row on a second read.
const again = await store.getByPublicId(publicSessionId, companyId);
expect(again?.id).toBe(row?.id);
controller.abort();
await started.completed;
});
it("does not read a session for a foreign company", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const { store, controller, started, publicSessionId } = await startOneSession(
companyId,
environmentId,
);
// A different company id never resolves the row, even with the exact public
// session id. The predicate carries the company id, so a query never keys on
// the public session id alone.
const foreignCompanyId = randomUUID();
expect(await store.getByPublicId(publicSessionId, foreignCompanyId)).toBeNull();
controller.abort();
await started.completed;
});
it("never accepts the internal row id as an API identifier", async () => {
const { companyId, environmentId } = await seedCompanyEnvironment();
const { store, service, controller, started, publicSessionId, row } =
await startOneSession(companyId, environmentId);
const internalId = row!.id;
// The store never resolves a row by the internal id through the public-id
// path, even within the same company.
expect(await store.getByPublicId(internalId, companyId)).toBeNull();
// The owner read returns the public session id, never the internal id.
const owner = await service.readOwnerSession(publicSessionId, companyId, OWNER_A);
expect(owner?.sessionId).toBe(publicSessionId);
expect(owner?.sessionId).not.toBe(internalId);
// The owner read rejects the internal id as an API identifier.
expect(await service.readOwnerSession(internalId, companyId, OWNER_A)).toBeNull();
controller.abort();
await started.completed;
});
});
});

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import type { AdapterLoginCapability } from "@paperclipai/adapter-utils";
import { validateAdapterModule } from "./plugin-loader.js";
// A minimal external adapter module. The loader calls `createServerAdapter()`
// and then validates the returned module. Each test controls the returned
// `loginCapability` to check the fail-closed rule at load time.
function makeModule(loginCapability?: unknown) {
return {
createServerAdapter: () => ({
type: "vendor_local",
execute: async () => ({}),
testEnvironment: async () => ({}),
...(loginCapability === undefined ? {} : { loginCapability }),
}),
};
}
const validLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
};
describe("validateAdapterModule login capability", () => {
it("loads an adapter with no login capability", () => {
expect(() => validateAdapterModule(makeModule(), "vendor-pkg")).not.toThrow();
});
it("loads an adapter with a well-formed login capability", () => {
expect(() => validateAdapterModule(makeModule(validLoginCapability), "vendor-pkg")).not.toThrow();
});
it("rejects an adapter with a malformed login capability", () => {
const bad = { ...validLoginCapability, panelMode: "hidden_code" };
expect(() => validateAdapterModule(makeModule(bad), "vendor-pkg")).toThrow(
/invalid login capability/,
);
});
it("rejects an adapter with a non-object login capability", () => {
expect(() => validateAdapterModule(makeModule("displayed_code"), "vendor-pkg")).toThrow(
/invalid login capability/,
);
});
});

View File

@ -13,6 +13,7 @@ import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type { ServerAdapterModule } from "./types.js";
import { validateAdapterLoginCapability } from "@paperclipai/adapter-utils";
import { logger } from "../middleware/logger.js";
import {
@ -145,7 +146,7 @@ function extractUiParserSource(
// Load / reload
// ---------------------------------------------------------------------------
function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
export function validateAdapterModule(mod: unknown, packageName: string): ServerAdapterModule {
const m = mod as Record<string, unknown>;
const createServerAdapter = m.createServerAdapter;
if (typeof createServerAdapter !== "function") {
@ -161,6 +162,19 @@ function validateAdapterModule(mod: unknown, packageName: string): ServerAdapter
`createServerAdapter() from "${packageName}" returned an invalid module (missing "type").`,
);
}
// Fail closed on a malformed login capability. The validator throws a clear
// error, so the loader rejects the adapter instead of loading it with a
// partial capability.
try {
validateAdapterLoginCapability(adapterModule);
} catch (err) {
throw new Error(
`createServerAdapter() from "${packageName}" returned an invalid login capability: ` +
`${err instanceof Error ? err.message : String(err)}`,
);
}
return adapterModule;
}

View File

@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { assertValidAdapterLoginCapability } from "@paperclipai/adapter-utils";
import { requireServerAdapter } from "./registry.js";
// The registry registers a login capability for the two built-in interactive
// adapters. The test checks the scalar values and the presence of the required
// callbacks. It also runs the shared validator, so the built-in capabilities
// obey the same fail-closed contract as an external adapter.
describe("built-in adapter login capabilities", () => {
it("registers the Codex device-login capability", () => {
const capability = requireServerAdapter("codex_local").loginCapability;
expect(capability).toBeDefined();
if (!capability) return;
expect(capability.panelMode).toBe("displayed_code");
expect(capability.sandboxTransport).toBe("streamed_exec");
expect(capability.timeoutPolicy).toBe("caller_bounded");
expect(capability.completionClaim).toBeUndefined();
expect(typeof capability.getCommand).toBe("function");
expect(typeof capability.parsePrompt).toBe("function");
expect(() => assertValidAdapterLoginCapability(capability, "codex_local")).not.toThrow();
});
it("registers the Claude setup-token capability", () => {
const capability = requireServerAdapter("claude_local").loginCapability;
expect(capability).toBeDefined();
if (!capability) return;
expect(capability.panelMode).toBe("submitted_browser_code");
expect(capability.sandboxTransport).toBe("pseudo_terminal");
expect(capability.timeoutPolicy).toBe("fixed");
expect(capability.completionClaim).toBe("storedSessionId");
expect(typeof capability.getCommand).toBe("function");
expect(typeof capability.parsePrompt).toBe("function");
expect(typeof capability.captureCredential).toBe("function");
expect(() => assertValidAdapterLoginCapability(capability, "claude_local")).not.toThrow();
});
});

View File

@ -9,6 +9,7 @@ import {
buildSandboxNpmInstallCommand,
getAdapterSessionManagement,
} from "@paperclipai/adapter-utils";
import type { AdapterLoginCapability } from "@paperclipai/adapter-utils";
import {
execute as claudeExecute,
listClaudeSkills,
@ -19,6 +20,9 @@ import {
sessionCodec as claudeSessionCodec,
getQuotaWindows as claudeGetQuotaWindows,
getConfigSchema as getClaudeConfigSchema,
CLAUDE_SETUP_TOKEN_COMMAND,
parseSetupTokenPrompt,
parseSetupTokenCredential,
} from "@paperclipai/adapter-claude-local/server";
import {
agentConfigurationDoc as claudeAgentConfigurationDoc,
@ -33,6 +37,8 @@ import {
sessionCodec as codexSessionCodec,
getQuotaWindows as codexGetQuotaWindows,
getConfigSchema as getCodexConfigSchema,
CODEX_DEVICE_LOGIN_COMMAND,
parseDeviceLoginPrompt,
} from "@paperclipai/adapter-codex-local/server";
import {
agentConfigurationDoc as codexAgentConfigurationDoc,
@ -181,6 +187,43 @@ The standalone ACPX adapter has been retired. Use:
Paperclip keeps this tombstone registered so stale acpx_local rows fail clearly instead of falling back to the process adapter.
`;
// The Claude interactive login capability. Claude runs `claude setup-token` on a
// real pseudo-terminal. The user pastes a browser code back into the flow. The
// flow uses a fixed host-side timeout and records a stored session identifier on
// success. The capability data holds no secret; the callbacks return runtime
// values only.
const claudeLoginCapability: AdapterLoginCapability = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => CLAUDE_SETUP_TOKEN_COMMAND,
parsePrompt: (output) => {
const prompt = parseSetupTokenPrompt(output);
return prompt ? { url: prompt.url } : null;
},
captureCredential: (output) => {
const token = parseSetupTokenCredential(output);
return token === null ? null : Buffer.from(token, "utf8");
},
completionClaim: "storedSessionId",
};
// The Codex interactive login capability. Codex runs `codex login --device-auth`
// over the streamed exec channel. The flow shows a one-time code that the user
// enters in the browser. The caller sets the host-side timeout. The device-login
// flow writes its credential inside the sandbox, so the capability declares no
// terminal credential capture and no completion claim.
const codexLoginCapability: AdapterLoginCapability = {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => CODEX_DEVICE_LOGIN_COMMAND,
parsePrompt: (output) => {
const prompt = parseDeviceLoginPrompt(output);
return prompt ? { url: prompt.url, code: prompt.code } : null;
},
};
const claudeLocalAdapter: ServerAdapterModule = {
type: "claude_local",
execute: stampClaudeAgentIdHeader(claudeExecute),
@ -210,6 +253,7 @@ const claudeLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: claudeAgentConfigurationDoc,
getConfigSchema: getClaudeConfigSchema,
getQuotaWindows: claudeGetQuotaWindows,
loginCapability: claudeLoginCapability,
};
const acpxLocalAdapter: ServerAdapterModule = {
@ -282,6 +326,7 @@ const codexLocalAdapter: ServerAdapterModule = {
agentConfigurationDoc: codexAgentConfigurationDoc,
getConfigSchema: getCodexConfigSchema,
getQuotaWindows: codexGetQuotaWindows,
loginCapability: codexLoginCapability,
};
const cursorLocalAdapter: ServerAdapterModule = {

View File

@ -493,12 +493,10 @@ export async function createApp(
confidentialEdgeTlsTerminated: setupTokenLoginEdgeTlsTerminated,
setupTokenLogin: setupTokenLoginTransport,
onSetupTokenLoginService: (service) => {
// Capture the service, so the graceful-shutdown hook cancels every live
// session and releases each lease. The standalone scheduled reaper owns
// the startup and interval lease cleanup now (SR-4).
setupTokenLoginService = service;
// Startup reaper (SR-4): release any lease whose login session is
// terminal or past its deadline after a restart. The DB is ready here.
void service.reap().catch((err) => {
logger.error({ err }, "Setup-token login startup reaper failed");
});
},
}),
);

View File

@ -72,6 +72,7 @@ import {
createCodexDeviceLoginReaper,
createProductionLoginSessionReaperRuntime,
} from "./services/codex-device-login-reaper.js";
import { createProductionSetupTokenReaper } from "./services/setup-token-reaper.js";
import { resolveWorktreeRunExecutionActivationState } from "./services/instance-settings.js";
import {
parseAdapterRegistryEnv,
@ -1166,6 +1167,32 @@ export async function startServer(): Promise<StartedServer> {
}));
};
// The restart-safe cleanup backstop for the Claude setup-token login flow. It
// runs on startup and on the scheduler interval, so a sandbox lease survives a
// server restart and a release failure. It releases any lease whose login
// session is terminal, past its deadline, or already consumed.
const setupTokenReaper = createProductionSetupTokenReaper({
db: db as any,
environmentRuntime: environmentRuntimeService(db as any, { pluginWorkerManager }),
log: (line) => logger.info(line),
});
const logSetupTokenReaperResult = (
result: Awaited<ReturnType<typeof setupTokenReaper.sweep>>,
) => {
if (result.released > 0 || result.failed > 0) {
logger.info(result, "setup-token login reaper released leases");
}
};
const scheduleSetupTokenReaperSweep = () => {
if (heartbeatSchedulerStopped) return;
trackHeartbeatSchedulerWork(setupTokenReaper
.sweep()
.then(logSetupTokenReaperResult)
.catch((err) => {
logger.error({ err }, "setup-token login reaper sweep failed");
}));
};
const tools = toolAccessService(db as any, {
deploymentMode: config.deploymentMode,
deploymentExposure: config.deploymentExposure,
@ -1303,6 +1330,15 @@ export async function startServer(): Promise<StartedServer> {
logger.error({ err }, "startup adapter login reaper sweep failed");
});
// Run the setup-token login reaper once at startup, so a login sandbox lease
// that outlived a server restart releases before timer ticks start.
await setupTokenReaper
.sweep()
.then(logSetupTokenReaperResult)
.catch((err) => {
logger.error({ err }, "startup setup-token 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);
@ -1366,6 +1402,7 @@ export async function startServer(): Promise<StartedServer> {
scheduleMergedPullRequestConfirmationSweep();
scheduleTerminalWorkspaceSweep();
scheduleAdapterLoginReaperSweep();
scheduleSetupTokenReaperSweep();
scheduleEnvironmentLeaseCleanupSweep();
if (heartbeatSchedulerStopped) return;

View File

@ -0,0 +1,107 @@
import type { Request, Response } from "express";
import type { z } from "zod";
/**
* The result of the shared adapter login start-route spine. The spine returns it
* when every guard passes. `ownerUserId` is the immutable session owner from the
* actor. `data` is the validated request body.
*/
export interface AdapterLoginStartResolution<TData> {
ownerUserId: string;
data: TData;
}
/**
* The injected steps of the shared adapter login start-route spine. The Codex
* device login start route and the Claude setup-token login start route both
* provide them. Each step holds the per-flow rule; the spine holds the shared
* order.
*/
export interface AdapterLoginStartSpineInput<TData extends { environmentId: string }> {
req: Request;
res: Response;
/**
* Derives the immutable session owner from the actor. It also runs the company
* access check. It throws a mapped HTTP error for a forbidden actor. The shared
* error handler maps the thrown error to the response.
*/
deriveOwner: () => Promise<string> | string;
/**
* The strict request schema. `.strict()` rejects an unknown field. The spine
* validates the request body against it before any session or lease side
* effect.
*/
requestSchema: z.ZodType<TData>;
/** The fixed 400 text for an invalid request. Each route keeps its own text. */
invalidRequestError: string;
/**
* Merges these fields into the request body before the parse. The Codex route
* injects the adapter type from the path, so the client body needs no adapter
* type field.
*/
requestOverrides?: Record<string, unknown>;
/**
* Runs an optional per-flow guard after the owner derivation and before the
* request validation. The Codex route checks the path adapter type here. It
* throws a mapped HTTP error to reject the request.
*/
guardBeforeValidate?: () => void | Promise<void>;
/**
* Runs an optional per-flow guard after the validation and before the sandbox
* check. The Claude route checks the body adapter type and the transport
* readiness here. It returns `true` when it sends a response; the spine then
* stops and returns null.
*/
guardAfterValidate?: (data: TData) => boolean | Promise<boolean>;
/**
* Checks the sandbox environment. It throws a mapped HTTP error for a missing,
* archived, non-sandbox, or foreign environment.
*/
assertSandbox: (data: TData) => Promise<void>;
}
/**
* Runs the shared start-route spine for an adapter login. It runs the same
* ordered steps for both start routes, and it runs every step before any session
* or lease side effect:
*
* 1. It derives the session owner and runs the company access check.
* 2. It runs the optional pre-validation guard.
* 3. It validates the request body with the strict schema. It sends the fixed
* 400 and returns null on a failure.
* 4. It runs the optional post-validation guard. It returns null when the guard
* sends a response.
* 5. It checks the sandbox environment.
*
* It returns the owner and the validated body when every step passes. It returns
* null when a step sends a response; the caller must then stop.
*/
export async function runAdapterLoginStartSpine<TData extends { environmentId: string }>(
input: AdapterLoginStartSpineInput<TData>,
): Promise<AdapterLoginStartResolution<TData> | null> {
const ownerUserId = await input.deriveOwner();
if (input.guardBeforeValidate) {
await input.guardBeforeValidate();
}
const body =
input.req.body && typeof input.req.body === "object"
? (input.req.body as Record<string, unknown>)
: {};
const candidate = input.requestOverrides ? { ...body, ...input.requestOverrides } : body;
const parsed = input.requestSchema.safeParse(candidate);
if (!parsed.success) {
input.res.status(400).json({ error: input.invalidRequestError });
return null;
}
const data = parsed.data;
if (input.guardAfterValidate) {
const stop = await input.guardAfterValidate(data);
if (stop) return null;
}
await input.assertSandbox(data);
return { ownerUserId, data };
}

View File

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { AdapterLoginCapability, ServerAdapterModule } from "@paperclipai/adapter-utils";
import { buildAdapterCapabilities } from "./adapters.js";
// The adapter listing projects the safe scalar login fields to the client. The
// projection carries the panel mode, the sandbox transport, and the timeout
// policy. It never carries a function member or a secret. An adapter with no
// login capability projects no `login` object.
function makeAdapter(overrides: Partial<ServerAdapterModule> = {}): ServerAdapterModule {
return {
type: "vendor_local",
execute: async () => {
throw new Error("not used");
},
testEnvironment: async () => {
throw new Error("not used");
},
...overrides,
} as ServerAdapterModule;
}
const displayedCodeLogin: AdapterLoginCapability = {
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
getCommand: () => "vendor login",
parsePrompt: () => null,
};
describe("buildAdapterCapabilities login projection", () => {
it("projects the safe scalar login fields", () => {
const caps = buildAdapterCapabilities(makeAdapter({ loginCapability: displayedCodeLogin }));
expect(caps.login).toEqual({
panelMode: "displayed_code",
sandboxTransport: "streamed_exec",
timeoutPolicy: "caller_bounded",
});
});
it("omits the login object when the adapter declares no capability", () => {
const caps = buildAdapterCapabilities(makeAdapter());
expect(caps.login).toBeUndefined();
});
it("never projects the function members or a completion claim", () => {
const caps = buildAdapterCapabilities(
makeAdapter({
loginCapability: {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
getCommand: () => "vendor setup-token",
parsePrompt: () => null,
captureCredential: () => null,
completionClaim: "storedSessionId",
},
}),
);
expect(caps.login).toEqual({
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
});
expect(caps.login).not.toHaveProperty("getCommand");
expect(caps.login).not.toHaveProperty("parsePrompt");
expect(caps.login).not.toHaveProperty("captureCredential");
expect(caps.login).not.toHaveProperty("completionClaim");
});
});

View File

@ -41,6 +41,11 @@ import {
} from "../services/adapter-plugin-store.js";
import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js";
import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js";
import type {
AdapterLoginPanelMode,
AdapterLoginSandboxTransport,
AdapterLoginTimeoutPolicy,
} from "@paperclipai/adapter-utils";
import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js";
import { logger } from "../middleware/logger.js";
import { forbidden } from "../errors.js";
@ -79,6 +84,18 @@ interface AdapterInstallRequest {
version?: string;
}
/**
* The safe scalar login fields the adapter listing projects to the client. It
* carries only the panel mode, the sandbox transport, and the timeout policy.
* It carries no function member and no secret. The user interface reads it to
* pick the login flow and the login panel.
*/
interface AdapterLoginProjection {
panelMode: AdapterLoginPanelMode;
sandboxTransport: AdapterLoginSandboxTransport;
timeoutPolicy: AdapterLoginTimeoutPolicy;
}
interface AdapterCapabilities {
supportsInstructionsBundle: boolean;
supportsSkills: boolean;
@ -86,6 +103,11 @@ interface AdapterCapabilities {
requiresMaterializedRuntimeSkills: boolean;
supportsModelProfiles: boolean;
supportsAcp: boolean;
/**
* The projected login capability. It is present only when the adapter
* declares an interactive login capability. It is absent otherwise.
*/
login?: AdapterLoginProjection;
}
interface AdapterInfo {
@ -134,7 +156,13 @@ function readAdapterPackageVersionFromDisk(record: AdapterPluginRecord): string
}
}
function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities {
/**
* Build the client capability view for one adapter. The login projection carries
* only the safe scalar fields; it drops the function members and the completion
* claim, so the response holds no secret and no code.
*/
export function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabilities {
const login = adapter.loginCapability;
return {
supportsInstructionsBundle: adapter.supportsInstructionsBundle ?? false,
supportsSkills: Boolean(adapter.listSkills || adapter.syncSkills),
@ -142,6 +170,15 @@ function buildAdapterCapabilities(adapter: ServerAdapterModule): AdapterCapabili
requiresMaterializedRuntimeSkills: adapter.requiresMaterializedRuntimeSkills ?? false,
supportsModelProfiles: Boolean(adapter.modelProfiles?.length || adapter.listModelProfiles),
supportsAcp: Boolean(adapter.acp),
...(login
? {
login: {
panelMode: login.panelMode,
sandboxTransport: login.sandboxTransport,
timeoutPolicy: login.timeoutPolicy,
},
}
: {}),
};
}

View File

@ -29,6 +29,7 @@ import {
updateAgentSchema,
supportedEnvironmentDriversForAdapter,
LOW_TRUST_REVIEW_PRESET,
startAdapterAuthSessionRequestSchema,
startClaudeSetupTokenSessionRequestSchema,
submitBrowserCodeRequestSchema,
} from "@paperclipai/shared";
@ -61,6 +62,7 @@ import {
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
import { assertAuthenticated, assertBoard, assertCompanyAccess, assertInstanceAdmin, buildActorSecretContext, getAccessibleResource, getActorInfo, hasCompanyAccess } from "./authz.js";
import { runAdapterLoginStartSpine } from "./adapter-login-route-spine.js";
import {
assertNoAgentHostWorkspaceCommandMutation,
collectAgentAdapterWorkspaceCommandPaths,
@ -140,7 +142,6 @@ import {
} from "@paperclipai/adapter-codex-local/server";
import {
AdapterAuthSessionConflictError,
CODEX_DEVICE_LOGIN_ADAPTER_TYPE,
createCodexDeviceLoginService,
createDbAdapterAuthSessionStore,
createProductionLoginSessionRuntime,
@ -362,12 +363,10 @@ export function agentRoutes(
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;
row.adapterType === identity.adapterType;
const inMemorySetupTokenCleanupStore: SetupTokenCleanupStore = {
async record(record): Promise<void> {
setupTokenCleanupRows.set(record.sessionId, { ...record });
@ -507,6 +506,7 @@ export function agentRoutes(
// after the fence; the lock spans the whole section.
const outcome = await adapterLoginStore.withCompanyAdapterPromotionLock(
context.companyId,
context.startedByUserId,
context.adapterType,
() =>
promoteDeviceLoginCredential({
@ -1167,9 +1167,20 @@ export function agentRoutes(
return userId;
}
// The device-login flow supports only the Codex adapter. Reject any other type.
function assertCodexLoginAdapter(type: string): void {
if (type !== CODEX_DEVICE_LOGIN_ADAPTER_TYPE) {
// Read the interactive login capability the registry declares for an adapter
// type. Return null when the adapter declares no capability, so a guard fails
// closed on the absent case.
function getRegistryLoginCapability(type: string) {
return findActiveServerAdapter(type)?.loginCapability ?? null;
}
// The device-login route drives a login over the streamed exec channel. It
// serves any adapter whose registry login capability declares that transport.
// The guard reads the capability, not the adapter name, so a new adapter with
// the same transport passes with no code change. It rejects an adapter with no
// matching capability with a fixed 400.
function assertStreamedExecLoginAdapter(type: string): void {
if (getRegistryLoginCapability(type)?.sandboxTransport !== "streamed_exec") {
throw badRequest(`Adapter "${type}" does not support a device login.`);
}
}
@ -1236,7 +1247,7 @@ export function agentRoutes(
const resolved = provider
? await resolvePluginSandboxProviderDriverByKey({ db, driverKey: provider })
: null;
if (!resolved?.driver.supportsSetupTokenLogin) {
if (!resolved?.driver.supportsLoginPty) {
throw unprocessable(SETUP_TOKEN_PROVIDER_UNSUPPORTED, {
code: SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE,
});
@ -1251,19 +1262,17 @@ export function agentRoutes(
async function readOwnerLoginSession(
companyId: string,
adapterType: string,
sessionId: string,
publicSessionId: string,
requestingUserId: string,
): Promise<AdapterAuthSessionOwnerResponse | null> {
const row = await adapterLoginStore.get(sessionId);
if (
!row ||
row.companyId !== companyId ||
row.adapterType !== adapterType ||
row.startedByUserId !== requestingUserId
) {
// Read by the public session id, scoped to the company. The store predicate
// already carries the company id, so a foreign-company caller reads nothing
// and the internal row id never matches. Keep the adapter and owner checks.
const row = await adapterLoginStore.getByPublicId(publicSessionId, companyId);
if (!row || row.adapterType !== adapterType || row.startedByUserId !== requestingUserId) {
return null;
}
return adapterLoginService.readOwnerSession(sessionId, requestingUserId);
return adapterLoginService.readOwnerSession(publicSessionId, companyId, requestingUserId);
}
async function assertCanReadConfigurations(req: Request, companyId: string) {
@ -2494,33 +2503,35 @@ export function agentRoutes(
async (req, res) => {
const companyId = req.params.companyId as string;
const type = req.params.type as string;
const startedByUserId = await assertCanManageAdapterLogin(req, companyId);
assertCodexLoginAdapter(type);
const environmentId =
typeof req.body?.environmentId === "string" && req.body.environmentId.trim().length > 0
? (req.body.environmentId as string)
: null;
if (!environmentId) {
throw badRequest("A sandbox environment is required to start a device login.");
}
const ttlSeconds =
typeof req.body?.ttlSeconds === "number" && Number.isFinite(req.body.ttlSeconds)
? (req.body.ttlSeconds as number)
: undefined;
// Reject a non-sandbox or inactive environment before the service starts.
await assertSandboxLoginEnvironment(companyId, environmentId);
// The shared start-route spine derives the owner, checks the path adapter
// type, validates the strict request schema, and checks the sandbox
// environment before any session or lease side effect. The client body
// carries no adapter type, so the spine injects the path type into the
// parse. The strict schema rejects an unknown field, a non-uuid
// environment id, and an out-of-range time-to-live with a fixed 400.
const resolved = await runAdapterLoginStartSpine({
req,
res,
deriveOwner: () => assertCanManageAdapterLogin(req, companyId),
guardBeforeValidate: () => assertStreamedExecLoginAdapter(type),
requestSchema: startAdapterAuthSessionRequestSchema,
invalidRequestError: "The device login start request is invalid.",
requestOverrides: { adapterType: type },
assertSandbox: (data) => assertSandboxLoginEnvironment(companyId, data.environmentId),
});
if (!resolved) return;
const { ownerUserId: startedByUserId, data } = resolved;
const controller = new AbortController();
let result: Awaited<ReturnType<typeof adapterLoginService.start>>;
try {
result = await adapterLoginService.start({
companyId,
environmentId,
environmentId: data.environmentId,
adapterType: type,
startedByUserId,
ttlSeconds,
ttlSeconds: data.ttlSeconds,
signal: controller.signal,
});
} catch (error) {
@ -2556,7 +2567,7 @@ export function agentRoutes(
const type = req.params.type as string;
const sessionId = req.params.sessionId as string;
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
assertCodexLoginAdapter(type);
assertStreamedExecLoginAdapter(type);
const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId);
if (!owner) {
@ -2576,7 +2587,7 @@ export function agentRoutes(
const type = req.params.type as string;
const sessionId = req.params.sessionId as string;
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
assertCodexLoginAdapter(type);
assertStreamedExecLoginAdapter(type);
// Scope the cancel to this company, adapter, and owner. A non-owner and a
// cross-company caller both receive a 404 and cannot cancel a session.
@ -2589,7 +2600,7 @@ export function agentRoutes(
// even when this process does not own the in-flight run, so a cross-process
// cancel or a cancel after a restart does not leave the slot held until the
// expiry. The reaper deletes the sandbox and finalizes the terminal.
const cancelled = await adapterLoginService.cancelOwnerSession(sessionId, ownerUserId);
const cancelled = await adapterLoginService.cancelOwnerSession(sessionId, companyId, ownerUserId);
// Abort the in-flight run this process owns, so the local login stops at
// once instead of waiting for the reaper. A run in another process, or an
// already-terminal run, has no controller here.
@ -4631,66 +4642,85 @@ export function agentRoutes(
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,
// The shared start-route spine derives the owner, validates the strict
// request schema, runs the Claude-only guards, and checks the sandbox
// environment before any session, lease, or pseudo-terminal side effect.
//
// The owner step runs the company access check, derives the owner, and then
// sets `Cache-Control: no-store`, so a rejected member sees no cache header
// and every other response carries it. The strict schema rejects an unknown
// field, including a legacy `ttlSeconds`, with a fixed 400. The post-validate
// guard rejects a non-Claude adapter with a fixed 400 and fails closed with
// the fixed no-secret 503 until the live login transport binds. The sandbox
// check fails closed on a missing, archived, non-sandbox, fake-provider, or
// foreign environment, and on a provider without the setup-token login
// capability, so no rejected environment reaches a session row, a lease, or a
// pseudo-terminal.
const resolved = await runAdapterLoginStartSpine({
req,
res,
deriveOwner: () => {
assertCompanyAccess(req, companyId);
const ownerUserId = deriveSetupTokenOwnerUserId(req);
res.setHeader("Cache-Control", "no-store");
return ownerUserId;
},
requestSchema: startClaudeSetupTokenSessionRequestSchema,
invalidRequestError: "The Claude login start request is invalid.",
guardAfterValidate: (data) => {
// The setup-token route drives a login on a pseudo-terminal and records a
// stored session identifier on success. It serves any adapter whose
// registry login capability declares that transport and that claim. The
// guard reads the capability, not the adapter name, so a new adapter with
// the same capability passes with no code change. It rejects an adapter
// with no matching capability with a fixed 400.
const capability = getRegistryLoginCapability(data.adapterType);
if (
capability?.sandboxTransport !== "pseudo_terminal" ||
capability.completionClaim !== "storedSessionId"
) {
res.status(400).json({ error: "This adapter does not support a setup-token login." });
return true;
}
if (!SETUP_TOKEN_LOGIN_TRANSPORT_READY) {
res.status(503).json({ error: SETUP_TOKEN_START_FAILED });
return true;
}
return false;
},
assertSandbox: (data) =>
assertSandboxLoginEnvironment(companyId, data.environmentId, {
requireSetupTokenLoginProvider: true,
}),
});
if (!resolved) return;
const { ownerUserId, data } = resolved;
const { environmentId, adapterType } = data;
const confirmedOverwrite: ClaudeSetupTokenOverwrite | null = data.overwrite ?? null;
const scope: SetupTokenSessionScope = {
companyId,
ownerUserId,
targetAgentId: null,
adapterType,
environmentId,
confirmedOverwrite,
};
// Read the panel mode from the adapter capability. The guard already checked
// the capability, so it is present here. The client renders the panel from
// this value instead of a hard-coded mode.
const panelMode =
getRegistryLoginCapability(adapterType)?.panelMode ?? "submitted_browser_code";
try {
const started = await setupTokenLoginService.start(scope);
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.
// correct 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",
panelMode,
prompt: null,
};
res.status(201).json(body);

View File

@ -742,7 +742,7 @@ export function environmentRoutes(
templateRefKind: driver.templateRefKind,
templateConfigBinding: driver.templateConfigBinding,
supportsTemplateDelete: driver.supportsTemplateDelete,
supportsSetupTokenLogin: driver.supportsSetupTokenLogin ?? false,
supportsLoginPty: driver.supportsLoginPty ?? false,
displayName: driver.displayName,
description: driver.description,
source: "plugin" as const,

View File

@ -105,6 +105,22 @@ const mockRunSecretRedactionRegistry = vi.hoisted(() => ({
const mockSecretService = vi.hoisted(() => ({
readClaudeOAuthUserSecretStatus: vi.fn(),
}));
// The registry lookup the start-route guard reads. A `beforeEach` sets the
// default so `claude_local` declares the setup-token capability. A test overrides
// it to prove the guard reads the capability, not the adapter name.
const mockFindActiveServerAdapter = vi.hoisted(() => vi.fn());
// The Claude setup-token login capability the registry declares for the built-in
// adapter. The guard requires the pseudo-terminal transport and the stored-session
// completion claim.
const CLAUDE_LOGIN_CAPABILITY = {
panelMode: "submitted_browser_code",
sandboxTransport: "pseudo_terminal",
timeoutPolicy: "fixed",
completionClaim: "storedSessionId",
getCommand: () => "",
parsePrompt: () => null,
} as const;
function registerModuleMocks(): void {
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
@ -160,11 +176,16 @@ function registerModuleMocks(): void {
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent: unknown, config: unknown) => config),
workspaceOperationService: () => ({}),
}));
// The start-route guard reads the login capability from the registry. The
// `beforeEach` default declares the Claude setup-token capability for
// `claude_local` and no capability for any other type, so the guard passes for
// `claude_local` and fails closed for a different adapter through capability
// data alone.
vi.doMock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
detectAdapterModel: vi.fn(),
findActiveServerAdapter: vi.fn(),
findActiveServerAdapter: mockFindActiveServerAdapter,
requireServerAdapter: vi.fn(),
}));
}
@ -430,6 +451,12 @@ beforeEach(() => {
registerModuleMocks();
vi.clearAllMocks();
useOwner();
// The default registry declares the Claude setup-token capability for
// `claude_local` and no capability for any other type. A test overrides this to
// prove the guard reads the capability, not the adapter name.
mockFindActiveServerAdapter.mockImplementation((type: string) =>
type === "claude_local" ? { type, loginCapability: CLAUDE_LOGIN_CAPABILITY } : undefined,
);
// The default owner has no stored Claude value. A test overrides this to prove
// the status route returns the metadata for a present owner value.
mockSecretService.readClaudeOAuthUserSecretStatus.mockResolvedValue(null);
@ -459,7 +486,7 @@ beforeEach(() => {
mockResolvePluginSandboxProviderDriverByKey.mockImplementation(
async ({ driverKey }: { driverKey: string }) =>
driverKey === "daytona"
? { plugin: { id: "plugin-daytona" }, driver: { supportsSetupTokenLogin: true } }
? { plugin: { id: "plugin-daytona" }, driver: { supportsLoginPty: true } }
: null,
);
});
@ -651,7 +678,10 @@ describe("company-and-environment setup-token route — object-level authorizati
expect(transport.submittedCodes).toEqual([]);
});
it("rejects an adapter other than claude_local at start", async () => {
it("rejects an adapter whose login capability does not match the setup-token guard", async () => {
// The Codex adapter declares a streamed-exec device login, not a
// pseudo-terminal setup-token login. The guard reads the capability, so it
// rejects the adapter with a fixed 400 before any session.
const transport = buildTransport({ onSubmit: "pending" });
const { app } = await createApp({ transport });
@ -664,6 +694,32 @@ describe("company-and-environment setup-token route — object-level authorizati
// holds no record.
expect(transport.records).toEqual([]);
});
it("starts a setup-token login for a third adapter that declares the capability", async () => {
// A third adapter, not the Claude adapter, declares the pseudo-terminal
// setup-token capability with the stored-session claim. The guard reads the
// capability, not the adapter name, so the adapter passes the guard and
// starts a session. This proves no adapter-name branch remains in the guard
// path. The start response reads the panel mode from the capability.
mockFindActiveServerAdapter.mockImplementation((type: string) =>
type === "gemini_local"
? {
type,
loginCapability: { ...CLAUDE_LOGIN_CAPABILITY, panelMode: "displayed_code" },
}
: undefined,
);
const transport = buildTransport({ onSubmit: "pending" });
const { app } = await createApp({ transport });
const res = await startCompanySession(app, {
environmentId: ENVIRONMENT_ID,
adapterType: "gemini_local",
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.body.panelMode).toBe("displayed_code");
expect(res.body.status).toBe("waiting_for_user");
});
});
describe("company-and-environment setup-token route — idempotent cancel", () => {

View File

@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import type { Environment } from "@paperclipai/shared";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
// A minimal environment stand-in. The helper copies the reference without a
// read, so the test does not need a full environment row.
const ENVIRONMENT = { id: "env-1", name: "Sandbox", driver: "sandbox" } as unknown as Environment;
describe("buildLoginLeaseAcquireArgs", () => {
it("sets the fixed lease arguments that both login services share", () => {
const args = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT, adapterType: "codex" },
});
// A null issue, a null heartbeat run, and a null execution workspace disable
// lease reuse, so the login session always runs in a fresh sandbox.
expect(args.issueId).toBeNull();
expect(args.heartbeatRunId).toBeNull();
expect(args.persistedExecutionWorkspace).toBeNull();
// The helper applies the active custom-image template for both services.
expect(args.applyCustomImageTemplate).toBe(true);
});
it("passes the lease metadata through to the acquire arguments", () => {
const args = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT, adapterType: "claude" },
});
expect(args.companyId).toBe("co-1");
expect(args.environment).toBe(ENVIRONMENT);
expect(args.adapterType).toBe("claude");
});
it("defaults the adapter type to null when the caller omits it", () => {
const args = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
});
expect(args.adapterType).toBeNull();
});
it("passes the target agent through to the lease agent and defaults to null", () => {
const withAgent = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
targetAgentId: "agent-7",
});
expect(withAgent.agentId).toBe("agent-7");
const withoutAgent = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
});
expect(withoutAgent.agentId).toBeNull();
});
it("passes the company-binding assertion through and leaves it unset by default", () => {
const asserted = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
assertCompanyBinding: true,
});
expect(asserted.assertCompanyBinding).toBe(true);
const unset = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
});
expect(unset.assertCompanyBinding).toBeUndefined();
});
it("passes the requested expiry through and defaults to null", () => {
const deadline = new Date("2026-01-01T00:00:00.000Z");
const bounded = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
requestedExpiresAt: deadline,
});
expect(bounded.requestedExpiresAt).toBe(deadline);
const unbounded = buildLoginLeaseAcquireArgs({
metadata: { companyId: "co-1", environment: ENVIRONMENT },
});
expect(unbounded.requestedExpiresAt).toBeNull();
});
});

View File

@ -0,0 +1,72 @@
import type { Environment } from "@paperclipai/shared";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
/**
* The argument object that the runtime `acquireRunLease` seam accepts. The type
* derives from the service method, so a change to the acquire input stays in
* sync with this helper.
*/
export type AcquireLoginLeaseArgs = Parameters<EnvironmentRuntimeService["acquireRunLease"]>[0];
/**
* The lease metadata that identifies the login sandbox. The helper copies these
* fields to the acquire arguments without a change.
*/
export interface LoginLeaseMetadata {
companyId: string;
environment: Environment;
/** The agent adapter type for this login (mixed-harness environments). */
adapterType?: string | null;
}
/**
* The options for one login lease acquire. The helper sets the fixed arguments
* and passes each option through to the acquire arguments.
*/
export interface BuildLoginLeaseAcquireArgsOptions {
/** The lease metadata that identifies the login sandbox. */
metadata: LoginLeaseMetadata;
/**
* The agent that owns the login. The codex flow passes null. The setup-token
* flow passes the target agent.
*/
targetAgentId?: string | null;
/**
* Re-check the environment company binding inside the lease insert
* transaction. The setup-token flow sets this to true.
*/
assertCompanyBinding?: boolean;
/**
* The latest time the acquired lease may stay active. The setup-token flow
* passes the session deadline. The codex flow passes nothing.
*/
requestedExpiresAt?: Date | null;
}
/**
* Build the fixed sandbox lease arguments for a login service. Both login
* services acquire a lease with a null issue, a null heartbeat run, and a null
* execution workspace, and both apply the active custom-image template. This
* helper sets those fixed arguments in one place and passes the caller options
* through without a change to the lease behavior.
*/
export function buildLoginLeaseAcquireArgs(
options: BuildLoginLeaseAcquireArgsOptions,
): AcquireLoginLeaseArgs {
return {
companyId: options.metadata.companyId,
environment: options.metadata.environment,
adapterType: options.metadata.adapterType ?? null,
agentId: options.targetAgentId ?? null,
// A null issue, a null heartbeat run, and a null execution workspace disable
// lease reuse, so the login session always runs in a fresh sandbox.
issueId: null,
heartbeatRunId: null,
persistedExecutionWorkspace: null,
// Apply the active custom-image template, so the sandbox binds to the
// trusted image and runtime identity.
applyCustomImageTemplate: true,
assertCompanyBinding: options.assertCompanyBinding,
requestedExpiresAt: options.requestedExpiresAt ?? null,
};
}

View File

@ -586,7 +586,6 @@ export function agentService(db: Db) {
companyId: input.companyId,
ownerUserId: ownerUserId ?? "",
adapterType: CLAUDE_LOCAL_ADAPTER_TYPE,
environmentId: input.environmentId ?? "",
});
if (!consumed) {
throw claudeOAuthClaimRejectedError();

View File

@ -1,8 +1,9 @@
import { inArray } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { adapterAuthSessions } from "@paperclipai/db";
import {
ADAPTER_AUTH_ACTIVE_STATUSES,
CODEX_DEVICE_LOGIN_ADAPTER_TYPE,
decodePendingTerminal,
LOGIN_LEASE_SESSION_TAG_KEY,
observeSandboxDelete,
@ -166,6 +167,7 @@ export function createCodexDeviceLoginReaper(deps: CodexDeviceLoginReaperDeps) {
const pendingWrite = terminalCleanupWrite(false, "timed_out", null);
const claimed = await store.withCompanyAdapterPromotionLock(
row.companyId,
row.startedByUserId,
row.adapterType,
() =>
store.compareAndSetStatus({
@ -313,10 +315,15 @@ export function createProductionLoginSessionReaperRuntime(
.selectDistinct({ environmentId: adapterAuthSessions.environmentId })
.from(adapterAuthSessions)
.where(
inArray(adapterAuthSessions.status, [
...ADAPTER_AUTH_ACTIVE_STATUSES,
"cleanup_pending",
]),
and(
// The shared table also holds the setup-token rows. Filter by the
// device-login adapter, so the orphan sweep reads only Codex rows.
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
inArray(adapterAuthSessions.status, [
...ADAPTER_AUTH_ACTIVE_STATUSES,
"cleanup_pending",
]),
),
);
const tagged: TaggedLease[] = [];
for (const { environmentId } of environmentRows) {

View File

@ -21,6 +21,7 @@ import {
type SandboxLoginDriver,
} from "@paperclipai/adapter-codex-local/server";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import { environmentService } from "./environments.js";
// The login-session service. It creates a login session, acquires a fresh
@ -104,11 +105,12 @@ export interface LoginSessionRuntime {
}
/** The per-session context the promotion seam needs. The service knows the
* session, the company, and the adapter, so the promotion resolves the company
* scope and the sole-active-owner check for this exact session. */
* session, the company, the owner, and the adapter, so the promotion resolves
* the company slot and the sole-active-owner check for this exact session. */
export interface CredentialPromotionContext {
sessionId: string;
companyId: string;
startedByUserId: string;
adapterType: AgentAdapterType;
}
@ -210,6 +212,10 @@ export class AdapterAuthSessionConflictError extends Error {
export interface AdapterAuthSessionRow {
id: string;
/** The public, CSPRNG session identifier. The API returns and looks up this
* value. It never equals the internal primary-key `id`, so a caller cannot
* address a row by the internal id. */
publicSessionId: string;
companyId: string;
environmentId: string;
adapterType: AgentAdapterType;
@ -227,6 +233,9 @@ export interface AdapterAuthSessionRow {
export interface InsertAdapterAuthSessionInput {
id: string;
/** The public, CSPRNG session identifier. The service builds it and returns it
* to the client; the store persists it in `public_session_id`. */
publicSessionId: string;
companyId: string;
environmentId: string;
adapterType: AgentAdapterType;
@ -272,33 +281,44 @@ export interface AdapterAuthSessionStore {
/** A conditional status write. It returns true only when it changed one row. */
compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise<boolean>;
get(sessionId: string): Promise<AdapterAuthSessionRow | null>;
/**
* Read a session by its public session id, scoped to the company and the Codex
* device-login adapter. The predicate carries the company id, so a query never
* keys on the public session id alone, and a foreign-company caller reads
* nothing. It never accepts the internal primary-key `id`.
*/
getByPublicId(publicSessionId: string, companyId: string): Promise<AdapterAuthSessionRow | null>;
/** Run `fn` while the process holds the promotion critical-section lock for the
* company and adapter. The reaper reclaims a stale `promoting` row inside this
* lock, so a reclaim never interleaves with a live credential write. */
* company, owner, and adapter slot. The reaper reclaims a stale `promoting`
* row inside this lock, so a reclaim never interleaves with a live credential
* write for the same slot. */
withCompanyAdapterPromotionLock<T>(
companyId: string,
startedByUserId: string,
adapterType: AgentAdapterType,
fn: () => Promise<T>,
): Promise<T>;
}
/** Build the advisory-lock key for the promotion critical section. The key is
* company-scoped and adapter-scoped, so two different company slots never
* contend. The credential-promotion path and the reaper reclaim both derive the
* key from this function, so they take the exact same lock. */
* scoped to the company, the owner, and the adapter, so two different slots
* never contend. The credential-promotion path and the reaper reclaim both
* derive the key from this function, so they take the exact same lock. */
export function adapterLoginPromotionLockKey(
companyId: string,
startedByUserId: string,
adapterType: AgentAdapterType,
): string {
return `paperclip:adapter-login-promotion:${companyId}:${adapterType}`;
return `paperclip:adapter-login-promotion:${companyId}:${startedByUserId}:${adapterType}`;
}
/**
* Run `fn` inside the promotion critical section for one company and adapter.
* Run `fn` inside the promotion critical section for one company, owner, and
* adapter slot.
*
* The function opens a database transaction and takes a transaction-scoped
* PostgreSQL advisory lock. The lock serializes the credential-promotion path
* against the reaper reclaim, so a reaper never releases the company slot while a
* against the reaper reclaim, so a reaper never releases the slot while a
* credential write runs, and a stale promotion never writes after the reaper
* reclaims the slot. The transaction holds the lock through `fn`, so the caller
* runs the ownership check and the credential write in one mutually-exclusive
@ -308,10 +328,11 @@ export function adapterLoginPromotionLockKey(
export async function withAdapterLoginPromotionLock<T>(
db: Db,
companyId: string,
startedByUserId: string,
adapterType: AgentAdapterType,
fn: () => Promise<T>,
): Promise<T> {
const key = adapterLoginPromotionLockKey(companyId, adapterType);
const key = adapterLoginPromotionLockKey(companyId, startedByUserId, adapterType);
return db.transaction(async (tx) => {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
return fn();
@ -354,10 +375,12 @@ export interface AdapterAuthReaperStore {
compareAndSetStatus(input: CompareAndSetAdapterAuthSessionStatusInput): Promise<boolean>;
get(sessionId: string): Promise<AdapterAuthSessionRow | null>;
/** Run `fn` while the process holds the promotion critical-section lock for the
* company and adapter. The reaper reclaims a stale `promoting` row inside this
* lock, so a reclaim never interleaves with a live credential write. */
* company, owner, and adapter slot. The reaper reclaims a stale `promoting`
* row inside this lock, so a reclaim never interleaves with a live credential
* write for the same slot. */
withCompanyAdapterPromotionLock<T>(
companyId: string,
startedByUserId: string,
adapterType: AgentAdapterType,
fn: () => Promise<T>,
): Promise<T>;
@ -385,12 +408,16 @@ function isUniqueViolation(error: unknown): boolean {
function toRow(row: typeof adapterAuthSessions.$inferSelect): AdapterAuthSessionRow {
return {
id: row.id,
publicSessionId: row.publicSessionId,
companyId: row.companyId,
environmentId: row.environmentId,
adapterType: row.adapterType,
startedByUserId: row.startedByUserId,
providerLeaseId: row.providerLeaseId ?? null,
status: row.status,
// The unified `status` column type is the merged login-state union. A Codex
// device-login row only ever holds a Codex internal status, so narrow the
// read back to the internal union the service reasons about.
status: row.status as AdapterAuthSessionInternalStatus,
expiresAt: row.expiresAt ?? null,
promotionExpiresAt: row.promotionExpiresAt ?? null,
finishedAt: row.finishedAt ?? null,
@ -427,6 +454,11 @@ export function createDbAdapterAuthSessionStore(
environmentId: input.environmentId,
adapterType: input.adapterType,
startedByUserId: input.startedByUserId,
// The unified table requires a unique public session id. The service
// builds it from a CSPRNG and returns it to the client, so the store
// persists that value here. It never uses the internal id, a timestamp,
// or a counter.
publicSessionId: input.publicSessionId,
status: "starting",
expiresAt: input.expiresAt,
createdAt: input.at,
@ -477,6 +509,25 @@ export function createDbAdapterAuthSessionStore(
const row = rows[0];
return row ? toRow(row) : null;
},
async getByPublicId(publicSessionId, companyId) {
// The predicate carries the company id and the Codex device-login adapter,
// so a read never keys on the public session id alone. A foreign-company or
// foreign-adapter caller reads nothing. The internal primary-key `id` never
// matches, so a caller cannot address a row by the internal id.
const rows = await db
.select()
.from(adapterAuthSessions)
.where(
and(
eq(adapterAuthSessions.publicSessionId, publicSessionId),
eq(adapterAuthSessions.companyId, companyId),
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
),
)
.limit(1);
const row = rows[0];
return row ? toRow(row) : null;
},
async listExpiredActiveSessions(nowAt) {
// The partial index on the active statuses and the index on `expiresAt`
// both support this scan. The scan is bounded by the active-status set, so
@ -489,6 +540,9 @@ export function createDbAdapterAuthSessionStore(
.from(adapterAuthSessions)
.where(
and(
// The shared table also holds the setup-token rows, so every reaper
// scan filters by the device-login adapter to reach only Codex rows.
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
inArray(adapterAuthSessions.status, [...ADAPTER_AUTH_ACTIVE_STATUSES]),
isNotNull(adapterAuthSessions.expiresAt),
lte(adapterAuthSessions.expiresAt, nowAt),
@ -505,20 +559,30 @@ export function createDbAdapterAuthSessionStore(
const rows = await db
.select()
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.status, "cleanup_pending"));
.where(
and(
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
eq(adapterAuthSessions.status, "cleanup_pending"),
),
);
return rows.map(toRow);
},
async listLeaseReferences() {
const rows = await db
.select({ providerLeaseId: adapterAuthSessions.providerLeaseId })
.from(adapterAuthSessions)
.where(isNotNull(adapterAuthSessions.providerLeaseId));
.where(
and(
eq(adapterAuthSessions.adapterType, CODEX_DEVICE_LOGIN_ADAPTER_TYPE),
isNotNull(adapterAuthSessions.providerLeaseId),
),
);
return rows
.map((row) => row.providerLeaseId)
.filter((value): value is string => value != null);
},
async withCompanyAdapterPromotionLock(companyId, adapterType, fn) {
return withAdapterLoginPromotionLock(db, companyId, adapterType, fn);
async withCompanyAdapterPromotionLock(companyId, startedByUserId, adapterType, fn) {
return withAdapterLoginPromotionLock(db, companyId, startedByUserId, adapterType, fn);
},
};
}
@ -644,6 +708,10 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
input: StartCodexDeviceLoginInput,
): Promise<StartCodexDeviceLoginResult> {
const sessionId = randomUUID();
// The public session identifier the API returns and looks up. It is an
// independent CSPRNG value, so it never equals the internal `sessionId` and a
// caller cannot address the row by the internal id.
const publicSessionId = randomUUID();
const startedAt = now();
const ttlSeconds = input.ttlSeconds ?? CODEX_DEVICE_LOGIN_TIMEOUT_MS / 1000;
const expiresAt = new Date(startedAt.getTime() + ttlSeconds * 1000);
@ -662,6 +730,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
// acquires a lease for a losing start.
await store.insert({
id: sessionId,
publicSessionId,
companyId: input.companyId,
environmentId: input.environmentId,
adapterType: input.adapterType,
@ -726,7 +795,8 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
activity("lease_acquired");
const session: AdapterAuthSessionResponse = {
sessionId,
// The API identifier is the public session id, never the internal `sessionId`.
sessionId: publicSessionId,
environmentId: input.environmentId,
status: "starting",
expiresAt: expiresAt.toISOString(),
@ -837,6 +907,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
await promotion.promote(credential, {
sessionId,
companyId: input.companyId,
startedByUserId: input.startedByUserId,
adapterType: input.adapterType,
});
} catch {
@ -972,24 +1043,28 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
}
async function readOwnerSession(
sessionId: string,
publicSessionId: string,
companyId: string,
requestingUserId: string,
): Promise<AdapterAuthSessionOwnerResponse | null> {
const row = await store.get(sessionId);
// Look the row up by its public session id, scoped to the company. A
// foreign-company caller reads nothing, and the internal id never matches.
const row = await store.getByPublicId(publicSessionId, companyId);
if (!row) return null;
const isOwner = row.startedByUserId === requestingUserId;
const status = resolvePublicStatus(row);
// Deliver the one-time prompt to the owner principal exactly once. The read
// and the delete run with no await between them, so the first authorized
// owner read consumes the prompt and every later read returns null. This
// keeps the short-lived device code out of a repeated response.
// keeps the short-lived device code out of a repeated response. The prompt
// map keys on the internal id, so read it by `row.id`, not the public id.
let prompt: DeviceLoginPrompt | null = null;
if (isOwner) {
prompt = promptsBySession.get(sessionId) ?? null;
if (prompt) promptsBySession.delete(sessionId);
prompt = promptsBySession.get(row.id) ?? null;
if (prompt) promptsBySession.delete(row.id);
}
return {
sessionId: row.id,
sessionId: row.publicSessionId,
environmentId: row.environmentId,
status,
expiresAt: row.expiresAt?.toISOString() ?? null,
@ -1011,14 +1086,18 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
// row, so a cancel never interrupts an in-flight credential write; that
// promotion runs to its own terminal.
async function cancelOwnerSession(
sessionId: string,
publicSessionId: string,
companyId: string,
requestingUserId: string,
): Promise<AdapterAuthSessionOwnerResponse | null> {
const row = await store.get(sessionId);
// Look the row up by its public session id, scoped to the company. The status
// write keys on the internal `row.id`, so a cancel never addresses a row by
// the public id alone.
const row = await store.getByPublicId(publicSessionId, companyId);
if (!row || row.startedByUserId !== requestingUserId) return null;
const write = terminalCleanupWrite(false, "cancelled", null);
await store.compareAndSetStatus({
sessionId,
sessionId: row.id,
expectedStatuses: ["starting", "waiting_for_user"],
status: write.status,
at: now(),
@ -1026,7 +1105,7 @@ export function createCodexDeviceLoginService(deps: CodexDeviceLoginServiceDeps)
finishedAt: now(),
promotionExpiresAt: null,
});
return readOwnerSession(sessionId, requestingUserId);
return readOwnerSession(publicSessionId, companyId, requestingUserId);
}
return { start, readOwnerSession, cancelOwnerSession };
@ -1157,20 +1236,15 @@ export function createProductionLoginSessionRuntime(
if (!environment) {
throw new Error(`Environment "${input.environmentId}" is not found.`);
}
const record = await deps.environmentRuntime.acquireRunLease({
companyId: input.companyId,
environment,
issueId: null,
agentId: 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: input.adapterType,
// Apply the active custom-image template, so the sandbox binds to the
// trusted image and runtime identity.
applyCustomImageTemplate: true,
});
const record = await deps.environmentRuntime.acquireRunLease(
buildLoginLeaseAcquireArgs({
metadata: {
companyId: input.companyId,
environment,
adapterType: input.adapterType,
},
}),
);
// Tag the lease with the session identifier, so the reaper resolves an
// orphan lease that no live session references. The tag carries no secret.
await environmentsSvc.updateLeaseMetadata(record.lease.id, {

View File

@ -78,7 +78,7 @@ export interface ReadyPluginEnvironmentDriver {
templateRefKind?: PluginEnvironmentDriverDeclaration["templateRefKind"];
templateConfigBinding?: PluginEnvironmentDriverDeclaration["templateConfigBinding"];
supportsTemplateDelete?: PluginEnvironmentDriverDeclaration["supportsTemplateDelete"];
supportsSetupTokenLogin?: PluginEnvironmentDriverDeclaration["supportsSetupTokenLogin"];
supportsLoginPty?: PluginEnvironmentDriverDeclaration["supportsLoginPty"];
}
export function pluginDriverProviderKey(config: Pick<PluginEnvironmentConfig, "pluginKey" | "driverKey">): string {
@ -257,7 +257,7 @@ export async function listReadyPluginEnvironmentDrivers(input: {
templateRefKind: driver.templateRefKind,
templateConfigBinding: driver.templateConfigBinding,
supportsTemplateDelete: driver.supportsTemplateDelete,
supportsSetupTokenLogin: driver.supportsSetupTokenLogin,
supportsLoginPty: driver.supportsLoginPty,
})),
);
}

View File

@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import { createSetupTokenReaper } from "./setup-token-reaper.js";
import {
isTerminalSessionState,
type SetupTokenCleanupIdentity,
type SetupTokenCleanupRecord,
} from "./setup-token-session.js";
const NOW = 5_000;
// An in-memory reaper store. The test seeds records directly, so no database
// runs. The `listReapable` predicate mirrors the production store: a record is
// reapable when its session is terminal, its deadline is past, or its claim is
// already consumed.
function createMemoryStore() {
const rows = new Map<string, SetupTokenCleanupRecord>();
const matches = (row: SetupTokenCleanupRecord, identity: SetupTokenCleanupIdentity) =>
row.companyId === identity.companyId &&
row.ownerUserId === identity.ownerUserId &&
row.adapterType === identity.adapterType;
return {
rows,
seed(record: Partial<SetupTokenCleanupRecord> & Pick<SetupTokenCleanupRecord, "sessionId" | "leaseId">) {
const row: SetupTokenCleanupRecord = {
sessionId: record.sessionId,
companyId: record.companyId ?? "company-1",
ownerUserId: record.ownerUserId ?? "user-1",
adapterType: record.adapterType ?? "claude_local",
environmentId: record.environmentId ?? "env-1",
leaseId: record.leaseId,
deadline: record.deadline ?? 1_000,
state: record.state ?? "failed",
boundAt: record.boundAt ?? null,
};
rows.set(row.sessionId, row);
return row;
},
async listReapable(now: number): Promise<SetupTokenCleanupRecord[]> {
return [...rows.values()].filter(
(row) => isTerminalSessionState(row.state) || row.deadline <= now || row.boundAt !== null,
);
},
async remove(identity: SetupTokenCleanupIdentity): Promise<void> {
const row = rows.get(identity.sessionId);
if (row && matches(row, identity)) rows.delete(identity.sessionId);
},
};
}
interface FakeLeasesOptions {
releaseImpl?: (leaseId: string) => Promise<void>;
}
function createFakeLeases(opts: FakeLeasesOptions = {}) {
const releaseByIdCalls: string[] = [];
return {
releaseByIdCalls,
async releaseById(leaseId: string): Promise<void> {
releaseByIdCalls.push(leaseId);
if (opts.releaseImpl) await opts.releaseImpl(leaseId);
},
};
}
describe("setup-token reaper", () => {
it("releases the lease and removes the record for a reapable session", async () => {
// A prior process crashed with a live record and a lease.
const store = createMemoryStore();
store.seed({ sessionId: "orphan-1", leaseId: "lease-orphan", state: "awaiting_code", deadline: 1_000 });
const leases = createFakeLeases();
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
const result = await reaper.sweep();
expect(result.released).toBe(1);
expect(result.failed).toBe(0);
expect(leases.releaseByIdCalls).toEqual(["lease-orphan"]);
expect(store.rows.size).toBe(0);
});
it("holds a live, unexpired, unconsumed record and never releases its lease", async () => {
const store = createMemoryStore();
store.seed({
sessionId: "live-1",
leaseId: "lease-live",
state: "awaiting_code",
// The deadline is in the future and the claim is not consumed.
deadline: NOW + 60_000,
boundAt: null,
});
const leases = createFakeLeases();
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
const result = await reaper.sweep();
expect(result.released).toBe(0);
expect(leases.releaseByIdCalls).toHaveLength(0);
expect(store.rows.has("live-1")).toBe(true);
});
it("keeps the record when the release fails, then clears it on the next sweep", async () => {
const store = createMemoryStore();
store.seed({ sessionId: "orphan-2", leaseId: "lease-orphan-2", state: "failed" });
let attempt = 0;
const leases = createFakeLeases({
releaseImpl: async () => {
attempt += 1;
if (attempt === 1) throw new Error("provider down");
},
});
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
// First sweep: the release rejects, so the record stays for a retry.
const first = await reaper.sweep();
expect(first.released).toBe(0);
expect(first.failed).toBe(1);
expect(store.rows.has("orphan-2")).toBe(true);
// Second sweep: the release confirms, so the record clears.
const second = await reaper.sweep();
expect(second.released).toBe(1);
expect(second.failed).toBe(0);
expect(leases.releaseByIdCalls).toEqual(["lease-orphan-2", "lease-orphan-2"]);
expect(store.rows.size).toBe(0);
});
it("reaps every reapable record in one sweep and stays idempotent on the next", async () => {
const store = createMemoryStore();
store.seed({ sessionId: "orphan-a", leaseId: "lease-a", state: "failed" });
store.seed({ sessionId: "orphan-b", leaseId: "lease-b", state: "cancelled" });
// A consumed stored claim is reapable through the bound marker.
store.seed({ sessionId: "orphan-c", leaseId: "lease-c", state: "stored", deadline: NOW + 60_000, boundAt: 4_000 });
const leases = createFakeLeases();
const reaper = createSetupTokenReaper({ store, leases, now: () => NOW });
const first = await reaper.sweep();
expect(first.released).toBe(3);
expect(store.rows.size).toBe(0);
// The interval sweep runs over the now-empty store without an error.
const second = await reaper.sweep();
expect(second.released).toBe(0);
expect(second.failed).toBe(0);
});
});

View File

@ -0,0 +1,87 @@
import type { Db } from "@paperclipai/db";
import type { EnvironmentRuntimeService } from "./environment-runtime.js";
import { environmentService } from "./environments.js";
import {
reapSetupTokenLeases,
type SetupTokenCleanupStore,
type SetupTokenLeaseManager,
type SetupTokenReapResult,
} from "./setup-token-session.js";
import {
createProductionSetupTokenCleanupStore,
createProductionSetupTokenSandboxProvider,
} from "./setup-token-transport-binding.js";
// The restart-safe cleanup backstop for the Claude setup-token login flow.
//
// The in-process five-minute timer in the login-session service stays the
// primary control. This reaper is the backstop. It runs on server startup and on
// a fixed interval, so a sandbox lease survives a server restart and a release
// failure. The reaper reads the durable cleanup store and releases any lease
// whose session is terminal, past its deadline, or already consumed. It uses the
// same convention as the Codex device-login reaper, so both flows use one reaper
// convention.
//
// The reaper never records secret data. The durable cleanup record holds only
// ids, the deadline, the claim marker, and the state. It never holds a URL, a
// code, a token, or a raw process chunk.
/** The dependencies the standalone reaper needs. The reaper only reads reapable
* records and releases a lease by id, so it takes a store subset and a lease
* releaser, not the full session service. */
export interface SetupTokenReaperDeps {
store: Pick<SetupTokenCleanupStore, "listReapable" | "remove">;
leases: Pick<SetupTokenLeaseManager, "releaseById">;
now?: () => number;
log?: (line: string) => void;
}
/**
* Builds the standalone setup-token reaper. One sweep reaps every reapable
* record: it releases the lease and clears the row. A failed release stays
* retryable, so the next sweep retries it.
*/
export function createSetupTokenReaper(deps: SetupTokenReaperDeps) {
const now = deps.now ?? Date.now;
async function sweep(): Promise<SetupTokenReapResult> {
return reapSetupTokenLeases({ store: deps.store, leases: deps.leases, log: deps.log }, now());
}
return { sweep };
}
export type SetupTokenReaper = ReturnType<typeof createSetupTokenReaper>;
// ---------------------------------------------------------------------------
// The production runtime binding.
// ---------------------------------------------------------------------------
export interface ProductionSetupTokenReaperDeps {
db: Db;
environmentRuntime: EnvironmentRuntimeService;
log?: (line: string) => void;
}
/**
* Builds the production setup-token reaper. It reads the durable cleanup store
* and releases a lease by id through the production sandbox provider. The
* provider release resolves the lease and its environment from the database and
* tears down the remote sandbox through the driver, so the release frees the
* remote sandbox and not only the database row. The reaper never acquires a
* lease, so it binds no live pseudo-terminal opener.
*/
export function createProductionSetupTokenReaper(
deps: ProductionSetupTokenReaperDeps,
): SetupTokenReaper {
const environments = environmentService(deps.db);
const sandbox = createProductionSetupTokenSandboxProvider({
environments,
environmentRuntime: deps.environmentRuntime,
log: deps.log,
});
const store = createProductionSetupTokenCleanupStore(deps.db);
return createSetupTokenReaper({
store,
leases: { releaseById: (leaseId) => sandbox.release(leaseId) },
log: deps.log,
});
}

View File

@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { eq, sql } from "drizzle-orm";
import {
claudeSetupTokenSessions,
adapterAuthSessions,
companies,
createDb,
environments,
@ -50,7 +50,6 @@ const SYNTH_TOKEN = "sk-ant-oat01-SYNTHETICSYNTHETICSYNTHETIC01";
const OWNER_SCOPE: SetupTokenSessionScope = {
companyId: "company-1",
ownerUserId: "user-1",
targetAgentId: "agent-1",
adapterType: "claude_local",
environmentId: "env-1",
};
@ -171,7 +170,6 @@ function identityFromScope(scope: SetupTokenSessionScope, sessionId: string): Se
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: scope.adapterType,
environmentId: scope.environmentId,
};
}
@ -179,8 +177,7 @@ function identityMatchesRow(row: SetupTokenCleanupRecord, identity: SetupTokenCl
return (
row.companyId === identity.companyId &&
row.ownerUserId === identity.ownerUserId &&
row.adapterType === identity.adapterType &&
row.environmentId === identity.environmentId
row.adapterType === identity.adapterType
);
}
@ -240,7 +237,7 @@ function buildService<L extends SetupTokenLeaseManager = FakeLeaseManager>(overr
leases?: L;
store?: FakeStore;
rateLimiter?: SetupTokenRateLimiter;
caps?: { perOwner: number; perAgent: number; perCompany: number };
caps?: { perOwner: number; perCompany: number };
ttlMs?: number;
tokenRetentionMs?: number;
now?: () => number;
@ -276,7 +273,7 @@ function buildService<L extends SetupTokenLeaseManager = FakeLeaseManager>(overr
store,
completeCredential,
rateLimiter: overrides.rateLimiter ?? allowAllRateLimiter(),
caps: overrides.caps ?? { perOwner: 5, perAgent: 5, perCompany: 5 },
caps: overrides.caps ?? { perOwner: 5, perCompany: 5 },
ttlMs: overrides.ttlMs ?? 60_000,
tokenRetentionMs: overrides.tokenRetentionMs,
now: overrides.now,
@ -321,10 +318,12 @@ describe("SetupTokenSessionService.start", () => {
});
});
it("rejects a caller over the per-owner cap with 429", async () => {
const { service } = buildService({ caps: { perOwner: 1, perAgent: 5, perCompany: 5 } });
it("rejects a second start on the same company, owner, and adapter slot with 429", async () => {
const { service } = buildService({ caps: { perOwner: 1, perCompany: 5 } });
await service.start(OWNER_SCOPE);
await expect(service.start({ ...OWNER_SCOPE, targetAgentId: "agent-2" })).rejects.toMatchObject({
// The second start holds the same company, owner, and adapter slot, so it
// fails closed with the fixed cap error.
await expect(service.start(OWNER_SCOPE)).rejects.toMatchObject({
status: 429,
message: SETUP_TOKEN_CAP_EXCEEDED,
});
@ -332,7 +331,7 @@ describe("SetupTokenSessionService.start", () => {
});
describe("SetupTokenSessionService.start cap reservation (concurrency)", () => {
const capsPerOwnerOne = { perOwner: 1, perAgent: 5, perCompany: 5 };
const capsPerOwnerOne = { perOwner: 1, perCompany: 5 };
it("reserves the owner slot synchronously, so a concurrent start fails closed with 429", async () => {
const leases = new DeferredLeaseManager();
@ -506,23 +505,19 @@ describe("SetupTokenSessionService.submitCode", () => {
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);
// 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.
// The scope match rejects a mismatch in any of the three identity fields:
// the company, the owner, and the adapter.
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);
@ -539,21 +534,19 @@ 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 COMPANY_SCOPE: SetupTokenSessionScope = OWNER_SCOPE;
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 () => {
it("resolves a 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);
@ -562,16 +555,6 @@ describe("SetupTokenSessionService company-and-environment scope", () => {
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);
@ -1125,7 +1108,6 @@ describe("SetupTokenSessionService durable state scope", () => {
companyId: OWNER_SCOPE.companyId,
ownerUserId: "intruder",
adapterType: OWNER_SCOPE.adapterType,
environmentId: OWNER_SCOPE.environmentId,
},
"submitting",
);
@ -1161,7 +1143,7 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
});
afterEach(async () => {
await db.delete(claudeSetupTokenSessions);
await db.delete(adapterAuthSessions);
await db.delete(environments);
await db.delete(companies);
});
@ -1170,10 +1152,12 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
await stopDb?.();
});
// Seed a company and an environment, then return one fresh record identity.
async function seedScope(): Promise<SetupTokenCleanupIdentity> {
// The seeded scope carries the intrinsic environment. The store record needs
// the environment for the non-null column, but the identity match drops it.
type SeededScope = SetupTokenCleanupIdentity & { environmentId: string };
async function seedCompany(): Promise<string> {
const companyId = randomUUID();
const environmentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Acme",
@ -1183,6 +1167,11 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
createdAt: new Date(),
updatedAt: new Date(),
});
return companyId;
}
async function seedEnvironment(): Promise<string> {
const environmentId = randomUUID();
await db.insert(environments).values({
id: environmentId,
name: `sandbox-${environmentId.slice(0, 8)}`,
@ -1192,6 +1181,13 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
createdAt: new Date(),
updatedAt: new Date(),
});
return environmentId;
}
// Seed a company and an environment, then return one fresh record scope.
async function seedScope(): Promise<SeededScope> {
const companyId = await seedCompany();
const environmentId = await seedEnvironment();
return {
sessionId: randomUUID(),
companyId,
@ -1202,13 +1198,13 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
}
async function insertRecord(
identity: SetupTokenCleanupIdentity,
scope: SeededScope,
state: SetupTokenCleanupRecord["state"],
deadlineMs: number,
): Promise<void> {
const store = createDbSetupTokenCleanupStore(db);
await store.record({
...identity,
...scope,
leaseId: "lease-1",
deadline: deadlineMs,
state,
@ -1219,23 +1215,53 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
async function readRow(sessionId: string) {
const rows = await db
.select()
.from(claudeSetupTokenSessions)
.where(eq(claudeSetupTokenSessions.sessionId, sessionId));
.from(adapterAuthSessions)
.where(eq(adapterAuthSessions.publicSessionId, 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");
it("records the non-secret cleanup record on the unified table and reads it back", async () => {
const scope = await seedScope();
await insertRecord(scope, "starting", Date.now() + 60_000);
const row = await readRow(scope.sessionId);
// The store writes the unified `adapter_auth_sessions` columns. The public
// session id holds the record session id, and the owner maps to
// `started_by_user_id`, the state to `status`.
expect(row?.publicSessionId).toBe(scope.sessionId);
expect(row?.companyId).toBe(scope.companyId);
expect(row?.startedByUserId).toBe(scope.ownerUserId);
expect(row?.adapterType).toBe(scope.adapterType);
expect(row?.environmentId).toBe(scope.environmentId);
expect(row?.status).toBe("starting");
expect(row?.boundAt).toBeNull();
});
it("rejects a second active record on the same company, owner, and adapter slot", async () => {
const scope = await seedScope();
await insertRecord(scope, "awaiting_code", Date.now() + 60_000);
// A second active record for the same company, owner, and adapter conflicts
// on the active-slot unique index, even from a different environment. This
// proves the slot dropped the environment term.
const secondEnvironment = await seedEnvironment();
const sameSlot: SeededScope = {
...scope,
sessionId: randomUUID(),
environmentId: secondEnvironment,
};
await expect(insertRecord(sameSlot, "awaiting_code", Date.now() + 60_000)).rejects.toThrow();
// A different owner in the same company holds an independent slot, so the
// insert succeeds.
const otherOwner: SeededScope = {
...scope,
sessionId: randomUUID(),
ownerUserId: `user-${randomUUID().slice(0, 8)}`,
};
await insertRecord(otherOwner, "awaiting_code", Date.now() + 60_000);
expect((await readRow(otherOwner.sessionId))?.startedByUserId).toBe(otherOwner.ownerUserId);
});
it("consumes a stored claim once with one conditional write", async () => {
const identity = await seedScope();
await insertRecord(identity, "stored", Date.now() + 60_000);
@ -1262,6 +1288,31 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
expect((await readRow(identity.sessionId))?.boundAt).toBeNull();
});
it("returns no row for a cross-company consume and leaves the claim unconsumed", async () => {
const identity = await seedScope();
await insertRecord(identity, "stored", Date.now() + 60_000);
const store = createDbSetupTokenCleanupStore(db);
// A claim from another company does not match the stored row. The predicate
// scopes on company_id, so the consume returns no row and leaves bound_at null.
const otherCompanyId = await seedCompany();
const foreign = await store.consumeStoredClaim({ ...identity, companyId: otherCompanyId });
expect(foreign).toBeNull();
expect((await readRow(identity.sessionId))?.boundAt).toBeNull();
});
it("returns no row for a cross-adapter consume and leaves the claim unconsumed", async () => {
const identity = await seedScope();
await insertRecord(identity, "stored", Date.now() + 60_000);
const store = createDbSetupTokenCleanupStore(db);
// A claim for a different adapter does not match the stored row. The predicate
// scopes on adapter_type, so the consume returns no row and leaves bound_at null.
const foreign = await store.consumeStoredClaim({ ...identity, adapterType: "codex_local" });
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);
@ -1333,12 +1384,12 @@ describeEmbeddedPostgres("durable setup-token cleanup store (embedded postgres)"
});
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`,
sql`SELECT bound_at FROM adapter_auth_sessions WHERE public_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}`,
sql`UPDATE adapter_auth_sessions SET expires_at = clock_timestamp() + interval '300 milliseconds' WHERE public_session_id = ${identity.sessionId}`,
);
signalLocked();
await gate;

View File

@ -9,9 +9,8 @@
// The service gives the harness these operations against the one live session:
// start the session, read the login prompt, submit one browser code, cancel the
// session, expire the session on a timeout, and receive the token. Every
// operation verifies the company, the owner user, and the target agent. A
// missing session and a cross-scope session both return the same not-found
// error.
// operation verifies the company, the owner user, and the adapter. A missing
// session and a cross-scope session both return the same not-found error.
//
// Security controls folded here:
// * SR-1 (no secret in a log): the service keeps the full login URL, the
@ -35,9 +34,15 @@
import { randomBytes } from "node:crypto";
import { and, eq, gt, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { claudeSetupTokenSessions } from "@paperclipai/db";
import { adapterAuthSessions } from "@paperclipai/db";
import type { AgentAdapterType } from "@paperclipai/shared";
// The setup-token login flow supports only the `claude_local` adapter. The
// unified `adapter_auth_sessions` table also holds the Codex device-login rows,
// so every store scan filters by this adapter to reach only the setup-token
// rows.
const SETUP_TOKEN_ADAPTER_TYPE: AgentAdapterType = "claude_local";
/**
* The session states. The four terminal states end the login. The `stored`
* state is not terminal: a session enters `stored` after a successful
@ -68,24 +73,24 @@ export function isTerminalSessionState(state: SetupTokenSessionState): boolean {
}
/**
* The immutable owner scope of a session. Every operation verifies all five
* fields against the stored scope. The service builds the scope once at start
* and never changes it.
* The immutable owner scope of a session. The service builds the scope once at
* start and never changes it. The session identity is the company, the owner,
* and the adapter. Every operation verifies these three fields against the
* stored scope.
*
* The `targetAgentId` is null for a company-and-environment login. That login
* has no agent id: a hire flow starts one session before an agent exists. The
* agent-scoped login sets `targetAgentId` to the agent id. The full-scope match
* treats null as one immutable value, so a company login and an agent login
* never resolve one another.
* The active company credential slot is scoped to the company, the owner, and
* the adapter. Two owners in one company no longer conflict. The scope drops the
* environment term from the slot, so the same owner cannot hold one active login
* per environment.
*/
export interface SetupTokenSessionScope {
companyId: string;
ownerUserId: string;
targetAgentId: string | null;
// The adapter and the environment of the login. The durable cleanup record
// keys on the company, the owner, the adapter, and the environment, so a hire
// flow with no agent id still resolves one record.
// The adapter of the login. It is part of the session identity.
adapterType: string;
// The environment of the login. The store persists it on the row, but it is
// not part of the session identity: a caller never addresses a session by the
// environment, and the active slot no longer includes the environment.
environmentId: string;
// The optional confirmed-overwrite capture. The client sends it when a stored
// token fails the agent test. The secret writer reads it and rotates the
@ -183,9 +188,9 @@ export interface SetupTokenLeaseManager {
/**
* The non-secret cleanup record. It holds only ids, the deadline, the claim
* marker, and the state. It never holds a URL, a code, a token, or a raw process
* chunk. The service persists it at start so a restart can reap the
* lease. The record keys on the company, the owner, the adapter, and the
* environment, not the agent id, so a hire flow with no agent still resolves it.
* chunk. The service persists it at start so a restart can reap the lease. The
* record carries the environment, so the store writes the non-null environment
* column on the row.
*/
export interface SetupTokenCleanupRecord {
sessionId: string;
@ -202,16 +207,15 @@ export interface SetupTokenCleanupRecord {
}
/**
* The immutable identity of a cleanup record. It is the full owner scope plus
* the session id. Every durable write matches on all five fields, so a write
* never updates a row by session id alone.
* The immutable identity of a cleanup record. It is the company, the owner, the
* adapter, and the public session id. Every durable write matches on all four
* fields, so a write never updates a row by the session id alone.
*/
export interface SetupTokenCleanupIdentity {
sessionId: string;
companyId: string;
ownerUserId: string;
adapterType: string;
environmentId: string;
}
/**
@ -249,20 +253,66 @@ export interface SetupTokenCleanupStore {
consumeStoredClaim(identity: SetupTokenCleanupIdentity): Promise<SetupTokenCleanupRecord | null>;
}
/** The counts one reaper sweep produced over the durable cleanup store. */
export interface SetupTokenReapResult {
/** The reapable records whose lease released and whose row cleared. */
released: number;
/** The reapable records whose lease release failed. The row stays for a retry. */
failed: number;
}
/**
* The shared setup-token reap logic. It reads the durable store and releases any
* lease whose session is terminal, past its deadline, or already consumed. It
* runs after a restart and on the scheduler interval, so it frees a lease that a
* crash left behind (SR-4). A release failure stays retryable: the sweep leaves
* the record for a later run. The standalone reaper and the in-flight session
* service both call this function, so both flows use one reap convention.
*/
export async function reapSetupTokenLeases(
deps: {
store: Pick<SetupTokenCleanupStore, "listReapable" | "remove">;
leases: Pick<SetupTokenLeaseManager, "releaseById">;
log?: (line: string) => void;
},
now: number,
): Promise<SetupTokenReapResult> {
const log = deps.log ?? (() => {});
const records = await deps.store.listReapable(now);
let released = 0;
let failed = 0;
for (const record of records) {
try {
await deps.leases.releaseById(record.leaseId);
await deps.store.remove({
sessionId: record.sessionId,
companyId: record.companyId,
ownerUserId: record.ownerUserId,
adapterType: record.adapterType,
});
released += 1;
} catch {
failed += 1;
log("[paperclip] Setup-token reaper: a lease release failed; it stays retryable.");
}
}
return { released, failed };
}
/** A per-key start rate limiter. It matches the invite-rate-limit shape. */
export interface SetupTokenRateLimiter {
consume(key: string): { allowed: boolean; retryAfterSeconds: number };
}
export interface SetupTokenSessionCaps {
// The active-session cap per company, owner, and adapter slot. The slot cap
// mirrors the database active-slot unique index as defense in depth.
perOwner: number;
perAgent: number;
perCompany: number;
}
export const DEFAULT_SETUP_TOKEN_SESSION_CAPS: SetupTokenSessionCaps = {
perOwner: 1,
perAgent: 1,
perCompany: 3,
};
@ -523,8 +573,8 @@ export function assessConfidentialStartup(config: ConfidentialTransportConfig):
/**
* A synchronous capacity hold for the enforced caps. The service reserves the
* capacity for the owner, the agent, and the company before the first `await` in
* {@link SetupTokenSessionService.start}, so two concurrent starts for one owner
* capacity for the slot and the company before the first `await` in
* {@link SetupTokenSessionService.start}, so two concurrent starts for one slot
* never both pass the cap. The service holds the reservation for the whole
* session lifetime and releases it exactly one time: on an early start failure or
* on the terminal cleanup. The `released` flag makes the release idempotent.
@ -532,8 +582,9 @@ export function assessConfidentialStartup(config: ConfidentialTransportConfig):
interface CapReservation {
released: boolean;
companyId: string;
ownerUserId: string;
targetAgentId: string | null;
// The company, owner, and adapter slot key. The reservation counts one active
// session per slot, so it mirrors the database active-slot unique index.
slotKey: string;
}
interface StoredSession {
@ -635,11 +686,11 @@ function defaultSessionId(): string {
*/
export class SetupTokenSessionService {
private readonly sessions = new Map<string, StoredSession>();
// The live reservation counts per owner, per agent, and per company. The start
// path reads and increments these synchronously before the first `await`, so
// the cap decision and the capacity hold are one atomic step on the event loop.
private readonly reservedByOwner = new Map<string, number>();
private readonly reservedByAgent = new Map<string, number>();
// The live reservation counts per slot and per company. The start path reads
// and increments these synchronously before the first `await`, so the cap
// decision and the capacity hold are one atomic step on the event loop. The
// slot key is the company, the owner, and the adapter.
private readonly reservedBySlot = new Map<string, number>();
private readonly reservedByCompany = new Map<string, number>();
private readonly factory: SetupTokenLoginProcessFactory;
private readonly leases: SetupTokenLeaseManager;
@ -669,8 +720,8 @@ export class SetupTokenSessionService {
/**
* Builds the durable-record identity for a session. The store matches every
* write on the full owner scope and the session id, so no write updates a row
* by the session id alone.
* write on the company, the owner, the adapter, and the session id, so no write
* updates a row by the session id alone.
*/
private identityOf(session: StoredSession): SetupTokenCleanupIdentity {
return {
@ -678,7 +729,6 @@ export class SetupTokenSessionService {
companyId: session.scope.companyId,
ownerUserId: session.scope.ownerUserId,
adapterType: session.scope.adapterType,
environmentId: session.scope.environmentId,
};
}
@ -725,40 +775,38 @@ export class SetupTokenSessionService {
}
}
/** Builds the company, owner, and adapter slot key. The reservation and the
* database active-slot unique index share this identity. */
private static slotKey(
scope: Pick<SetupTokenSessionScope, "companyId" | "ownerUserId" | "adapterType">,
): string {
return [scope.companyId, scope.ownerUserId, scope.adapterType].join("\u0000");
}
/**
* Reserves the capacity for one start under every enforced cap. The method is
* synchronous, so it runs to completion before the first `await` in
* {@link start}. Two concurrent starts for one owner cannot interleave inside
* it: the first reserves the owner slot, and the second reads the incremented
* count and fails closed with the fixed 429 cap error. The method holds the
* per-owner, per-agent, and per-company semantics: it counts the agent slot
* only for an agent-scoped login, because a null agent id is not a shared key
* across owners. It increments no counter on a rejection, so a rejected start
* {@link start}. Two concurrent starts for one slot cannot interleave inside
* it: the first reserves the slot, and the second reads the incremented count
* and fails closed with the fixed 429 cap error. The method holds the per-slot
* and per-company semantics; the slot is the company, the owner, and the
* adapter. It increments no counter on a rejection, so a rejected start
* reserves nothing.
*/
private reserveCapacity(scope: SetupTokenSessionScope): CapReservation {
if ((this.reservedByOwner.get(scope.ownerUserId) ?? 0) >= this.caps.perOwner) {
throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED);
}
if (
scope.targetAgentId !== null &&
(this.reservedByAgent.get(scope.targetAgentId) ?? 0) >= this.caps.perAgent
) {
const slotKey = SetupTokenSessionService.slotKey(scope);
if ((this.reservedBySlot.get(slotKey) ?? 0) >= this.caps.perOwner) {
throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED);
}
if ((this.reservedByCompany.get(scope.companyId) ?? 0) >= this.caps.perCompany) {
throw new SetupTokenSessionError(429, SETUP_TOKEN_CAP_EXCEEDED);
}
SetupTokenSessionService.incrementCount(this.reservedByOwner, scope.ownerUserId);
if (scope.targetAgentId !== null) {
SetupTokenSessionService.incrementCount(this.reservedByAgent, scope.targetAgentId);
}
SetupTokenSessionService.incrementCount(this.reservedBySlot, slotKey);
SetupTokenSessionService.incrementCount(this.reservedByCompany, scope.companyId);
return {
released: false,
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
targetAgentId: scope.targetAgentId,
slotKey,
};
}
@ -766,16 +814,12 @@ export class SetupTokenSessionService {
* Releases a capacity reservation exactly one time. The `released` flag makes
* the release idempotent, so an early start failure and the terminal cleanup
* never double-release one reservation. The method decrements the same counters
* that {@link reserveCapacity} incremented, and it drops the agent counter only
* for an agent-scoped login.
* that {@link reserveCapacity} incremented.
*/
private releaseReservation(reservation: CapReservation): void {
if (reservation.released) return;
reservation.released = true;
SetupTokenSessionService.decrementCount(this.reservedByOwner, reservation.ownerUserId);
if (reservation.targetAgentId !== null) {
SetupTokenSessionService.decrementCount(this.reservedByAgent, reservation.targetAgentId);
}
SetupTokenSessionService.decrementCount(this.reservedBySlot, reservation.slotKey);
SetupTokenSessionService.decrementCount(this.reservedByCompany, reservation.companyId);
}
@ -869,7 +913,6 @@ export class SetupTokenSessionService {
companyId: scope.companyId,
ownerUserId: scope.ownerUserId,
adapterType: scope.adapterType,
environmentId: scope.environmentId,
})
.catch(() => {});
this.releaseReservation(reservation);
@ -962,12 +1005,11 @@ export class SetupTokenSessionService {
/**
* Resolves a session for an operation. It returns the session only when the
* id exists and the stored scope equals the caller scope in all five fields:
* the company, the owner, the agent, the adapter, and the environment. A
* missing session and a cross-scope session both throw the same not-found
* error, so a caller cannot tell them apart. The full-scope match makes
* a cross-adapter and a cross-environment session return the same not-found
* error as a missing session.
* id exists and the stored scope equals the caller scope in all three identity
* fields: the company, the owner, and the adapter. A missing session and a
* cross-scope session both throw the same not-found error, so a caller cannot
* tell them apart. The scope match makes a cross-company, a cross-owner, and a
* cross-adapter session return the same not-found error as a missing session.
*/
private resolveOwned(sessionId: string, scope: SetupTokenSessionScope): StoredSession {
const session = this.sessions.get(sessionId);
@ -975,9 +1017,7 @@ export class SetupTokenSessionService {
!session ||
session.scope.companyId !== scope.companyId ||
session.scope.ownerUserId !== scope.ownerUserId ||
session.scope.targetAgentId !== scope.targetAgentId ||
session.scope.adapterType !== scope.adapterType ||
session.scope.environmentId !== scope.environmentId
session.scope.adapterType !== scope.adapterType
) {
throw new SetupTokenSessionError(404, SETUP_TOKEN_SESSION_NOT_FOUND);
}
@ -988,16 +1028,14 @@ export class SetupTokenSessionService {
* Resolves the immutable scope of a company-and-environment session. The
* caller provides the company, the owner, and the adapter it derived from the
* request; the route path gives the company, the actor gives the owner, and
* the route fixes the adapter. The lookup matches these three fields and the
* agentless marker, then returns the full scope, including the intrinsic
* environment.
* the route fixes the adapter. The lookup matches these three fields, then
* returns the full scope, including the intrinsic environment.
*
* A caller never supplies the environment on a read, a submit, a cancel, or a
* completion. The environment is intrinsic to the session, so a foreign
* environment cannot address the session. A missing session and a
* cross-company, cross-owner, or cross-adapter session all throw the same
* not-found error. The agentless marker rejects an
* agent-scoped session, so a company route never resolves an agent session.
* not-found error.
*/
resolveCompanyScope(
sessionId: string,
@ -1006,7 +1044,6 @@ export class SetupTokenSessionService {
const session = this.sessions.get(sessionId);
if (
!session ||
session.scope.targetAgentId !== null ||
session.scope.companyId !== key.companyId ||
session.scope.ownerUserId !== key.ownerUserId ||
session.scope.adapterType !== key.adapterType
@ -1251,29 +1288,11 @@ export class SetupTokenSessionService {
* The startup reaper. It reads the durable store and releases any lease whose
* session is terminal or past its deadline. It runs after a restart, so it
* frees a lease that a crash left behind (SR-4). A release failure stays
* retryable: the reaper leaves the record for a later run.
* retryable: the reaper leaves the record for a later run. The standalone
* scheduled reaper calls the same {@link reapSetupTokenLeases} logic.
*/
async reap(now: number = this.now()): Promise<{ released: number; failed: number }> {
const records = await this.store.listReapable(now);
let released = 0;
let failed = 0;
for (const record of records) {
try {
await this.leases.releaseById(record.leaseId);
await this.store.remove({
sessionId: record.sessionId,
companyId: record.companyId,
ownerUserId: record.ownerUserId,
adapterType: record.adapterType,
environmentId: record.environmentId,
});
released += 1;
} catch {
failed += 1;
this.log("[paperclip] Setup-token reaper: a lease release failed; it stays retryable.");
}
}
return { released, failed };
async reap(now: number = this.now()): Promise<SetupTokenReapResult> {
return reapSetupTokenLeases({ store: this.store, leases: this.leases, log: this.log }, now);
}
/**
@ -1294,35 +1313,48 @@ export class SetupTokenSessionService {
}
// --- The durable, database-backed cleanup store ------------------------------
//
// The store reads and writes the unified `adapter_auth_sessions` table. It maps
// the setup-token record fields to the unified columns:
// - `sessionId` -> `public_session_id`
// - `ownerUserId` -> `started_by_user_id`
// - `leaseId` -> `provider_lease_id`
// - `state` -> `status`
// - `deadline` -> `expires_at`
// The table also holds the Codex device-login rows, so every scan filters by the
// setup-token adapter to reach only the setup-token rows.
type ClaudeSetupTokenSessionRow = typeof claudeSetupTokenSessions.$inferSelect;
type AdapterAuthSessionRow = typeof adapterAuthSessions.$inferSelect;
/** Maps a database row to the non-secret cleanup record. */
function toCleanupRecord(row: ClaudeSetupTokenSessionRow): SetupTokenCleanupRecord {
function toCleanupRecord(row: AdapterAuthSessionRow): SetupTokenCleanupRecord {
return {
sessionId: row.sessionId,
sessionId: row.publicSessionId,
companyId: row.companyId,
ownerUserId: row.ownerUserId,
ownerUserId: row.startedByUserId,
adapterType: row.adapterType,
environmentId: row.environmentId,
leaseId: row.leaseId ?? "",
deadline: row.deadlineAt.getTime(),
state: row.state as SetupTokenSessionState,
leaseId: row.providerLeaseId ?? "",
// The setup-token flow always writes an expiry, so a null value means a
// corrupt row. Treat it as already expired, so the reaper reclaims it.
deadline: row.expiresAt ? row.expiresAt.getTime() : 0,
state: row.status as SetupTokenSessionState,
boundAt: row.boundAt ? row.boundAt.getTime() : null,
};
}
/**
* The database scope predicate. It matches the full owner scope and the session
* id, so no write ever addresses a row by the session id alone.
* The database scope predicate. It matches the company, the owner, the adapter,
* and the public session id, so no write ever addresses a row by the session id
* alone. The `company_id` and `started_by_user_id` terms hold the server-scoped
* lookup: a query never keys on the public session id alone.
*/
function setupTokenScopeMatch(identity: SetupTokenCleanupIdentity) {
return and(
eq(claudeSetupTokenSessions.sessionId, identity.sessionId),
eq(claudeSetupTokenSessions.companyId, identity.companyId),
eq(claudeSetupTokenSessions.ownerUserId, identity.ownerUserId),
eq(claudeSetupTokenSessions.adapterType, identity.adapterType as AgentAdapterType),
eq(claudeSetupTokenSessions.environmentId, identity.environmentId),
eq(adapterAuthSessions.publicSessionId, identity.sessionId),
eq(adapterAuthSessions.companyId, identity.companyId),
eq(adapterAuthSessions.startedByUserId, identity.ownerUserId),
eq(adapterAuthSessions.adapterType, identity.adapterType as AgentAdapterType),
);
}
@ -1355,14 +1387,14 @@ export async function transitionSetupTokenSessionToStored(
identity: SetupTokenCleanupIdentity,
): Promise<boolean> {
const changed = await executor
.update(claudeSetupTokenSessions)
.set({ state: "stored", updatedAt: sql`clock_timestamp()` })
.update(adapterAuthSessions)
.set({ status: "stored", updatedAt: sql`clock_timestamp()` })
.where(
and(
setupTokenScopeMatch(identity),
inArray(claudeSetupTokenSessions.state, [...SETUP_TOKEN_STORED_PREDECESSOR_STATES]),
gt(claudeSetupTokenSessions.deadlineAt, sql`clock_timestamp()`),
isNull(claudeSetupTokenSessions.boundAt),
inArray(adapterAuthSessions.status, [...SETUP_TOKEN_STORED_PREDECESSOR_STATES]),
gt(adapterAuthSessions.expiresAt, sql`clock_timestamp()`),
isNull(adapterAuthSessions.boundAt),
),
)
.returning();
@ -1382,66 +1414,73 @@ export function createDbSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore {
return {
async record(record): Promise<void> {
await db.insert(claudeSetupTokenSessions).values({
sessionId: record.sessionId,
// The database generates `id`. The store fills `public_session_id` with the
// service session id, which the service builds from a CSPRNG at start.
await db.insert(adapterAuthSessions).values({
companyId: record.companyId,
ownerUserId: record.ownerUserId,
adapterType: record.adapterType as AgentAdapterType,
environmentId: record.environmentId,
leaseId: record.leaseId,
state: record.state,
deadlineAt: new Date(record.deadline),
adapterType: record.adapterType as AgentAdapterType,
startedByUserId: record.ownerUserId,
publicSessionId: record.sessionId,
providerLeaseId: record.leaseId,
status: record.state,
expiresAt: new Date(record.deadline),
boundAt: record.boundAt === null ? null : new Date(record.boundAt),
});
},
async markState(identity, state): Promise<void> {
// The compare-and-set predicate matches the full owner scope, so a write
// never updates a row by the session id alone.
// The compare-and-set predicate matches the company, the owner, and the
// adapter, so a write never updates a row by the session id alone.
await db
.update(claudeSetupTokenSessions)
.set({ state, updatedAt: sql`clock_timestamp()` })
.update(adapterAuthSessions)
.set({ status: state, updatedAt: sql`clock_timestamp()` })
.where(scopeMatch(identity));
},
async remove(identity): Promise<void> {
// The delete matches the full owner scope, so it never removes a row by the
// session id alone.
await db.delete(claudeSetupTokenSessions).where(scopeMatch(identity));
// The delete matches the company, the owner, and the adapter, so it never
// removes a row by the session id alone.
await db.delete(adapterAuthSessions).where(scopeMatch(identity));
},
async listReapable(now): Promise<SetupTokenCleanupRecord[]> {
// A record is reapable when its session is terminal, its deadline is past,
// or its claim is already consumed. The deadline index supports the scan.
// or its claim is already consumed. The scan filters by the setup-token
// adapter, so it never reaps a Codex device-login row on the shared table.
// The deadline index supports the scan.
const rows = await db
.select()
.from(claudeSetupTokenSessions)
.from(adapterAuthSessions)
.where(
or(
inArray(claudeSetupTokenSessions.state, [...SETUP_TOKEN_TERMINAL_STATES]),
lte(claudeSetupTokenSessions.deadlineAt, new Date(now)),
isNotNull(claudeSetupTokenSessions.boundAt),
and(
eq(adapterAuthSessions.adapterType, SETUP_TOKEN_ADAPTER_TYPE),
or(
inArray(adapterAuthSessions.status, [...SETUP_TOKEN_TERMINAL_STATES]),
lte(adapterAuthSessions.expiresAt, new Date(now)),
isNotNull(adapterAuthSessions.boundAt),
),
),
);
return rows.map(toCleanupRecord);
},
async consumeStoredClaim(identity): Promise<SetupTokenCleanupRecord | null> {
// One conditional write. The predicate carries the full owner scope, the
// session id, `state = stored`, an unexpired deadline, and an unconsumed
// marker. It checks the deadline with `clock_timestamp()`, so the database
// evaluates the current time after any row-lock wait. An expired claim
// cannot pass the deadline condition. The write sets `bound_at` one time
// and returns the row only on a valid consume.
// One conditional write. The predicate carries the company, the owner, the
// adapter, the session id, `status = stored`, an unexpired deadline, and an
// unconsumed marker. It checks the deadline with `clock_timestamp()`, so the
// database evaluates the current time after any row-lock wait. An expired
// claim cannot pass the deadline condition. The write sets `bound_at` one
// time and returns the row only on a valid consume.
const changed = await db
.update(claudeSetupTokenSessions)
.update(adapterAuthSessions)
.set({ boundAt: sql`clock_timestamp()`, updatedAt: sql`clock_timestamp()` })
.where(
and(
scopeMatch(identity),
eq(claudeSetupTokenSessions.state, "stored"),
gt(claudeSetupTokenSessions.deadlineAt, sql`clock_timestamp()`),
isNull(claudeSetupTokenSessions.boundAt),
eq(adapterAuthSessions.status, "stored"),
gt(adapterAuthSessions.expiresAt, sql`clock_timestamp()`),
isNull(adapterAuthSessions.boundAt),
),
)
.returning();

View File

@ -30,7 +30,6 @@ import {
const SCOPE: SetupTokenSessionScope = {
companyId: "company-1",
ownerUserId: "user-1",
targetAgentId: null,
adapterType: "claude_local",
environmentId: "env-1",
};

View File

@ -36,6 +36,7 @@ import {
import { secretService } from "./secrets.js";
import type { environmentService } from "./environments.js";
import type { environmentRuntimeService } from "./environment-runtime.js";
import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js";
import type { Db } from "@paperclipai/db";
import {
createSetupTokenPtyTransport,
@ -200,7 +201,6 @@ export function createSetupTokenSecretWriter(deps: {
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
@ -219,9 +219,11 @@ export function createSetupTokenSecretWriter(deps: {
};
}
/** The immutable scope key that correlates one acquire with one factory call. */
/** The immutable scope key that correlates one acquire with one factory call. It
* is the company, the owner, and the adapter, so it matches the active-slot
* scope. The per-slot session cap is one, so one scope holds one live acquire. */
function scopeKey(scope: SetupTokenSessionScope): string {
return [scope.companyId, scope.ownerUserId, scope.adapterType, scope.environmentId].join("\u0000");
return [scope.companyId, scope.ownerUserId, scope.adapterType].join("\u0000");
}
/**
@ -407,29 +409,29 @@ export function createProductionSetupTokenSandboxProvider(
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 leaseRecord = await deps.environmentRuntime.acquireRunLease(
buildLoginLeaseAcquireArgs({
metadata: {
companyId: scope.companyId,
environment,
adapterType: scope.adapterType,
},
// The setup-token login is company-and-environment scoped. It carries
// no target agent, so the lease binds no agent.
targetAgentId: null,
// 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);

View File

@ -4,6 +4,17 @@
import { api } from "./client";
/**
* The safe scalar login fields the server projects for an adapter that declares
* an interactive login capability. The projection carries no function member and
* no secret. The form reads it to pick the login flow and the login panel.
*/
export interface AdapterLoginProjection {
panelMode: "displayed_code" | "submitted_browser_code";
sandboxTransport: "streamed_exec" | "pseudo_terminal";
timeoutPolicy: "caller_bounded" | "fixed";
}
export interface AdapterCapabilities {
supportsInstructionsBundle: boolean;
supportsSkills: boolean;
@ -11,6 +22,8 @@ export interface AdapterCapabilities {
requiresMaterializedRuntimeSkills: boolean;
supportsModelProfiles: boolean;
supportsAcp: boolean;
/** Present only when the adapter declares an interactive login capability. */
login?: AdapterLoginProjection;
}
export interface AcpTargetDescriptor {

View File

@ -102,9 +102,27 @@ vi.mock("../adapters", () => ({
}),
}));
// The projected login capability per adapter type. The server projects these
// safe scalar fields. `codex_local` drives the displayed-code panel; `claude_local`
// drives the submitted-browser-code panel. A test overrides this map to add a
// third adapter with a projected login capability.
const mockLoginProjections = vi.hoisted(
() =>
new Map<string, { panelMode: string; sandboxTransport: string; timeoutPolicy: string }>([
["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
// A third adapter, not a built-in, with a projected displayed-code login.
["vendor_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
// A non-built-in adapter with a pseudo-terminal login transport. The gate
// requires the provider pty capability from the transport, not the name.
["pty_vendor_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
]),
);
vi.mock("../adapters/use-adapter-capabilities", () => ({
useAdapterCapabilities: () => (adapterType: string) =>
adapterType === "hermes_gateway"
useAdapterCapabilities: () => (adapterType: string) => {
const login = mockLoginProjections.get(adapterType);
return adapterType === "hermes_gateway"
? {
supportsInstructionsBundle: false,
supportsSkills: false,
@ -120,7 +138,9 @@ vi.mock("../adapters/use-adapter-capabilities", () => ({
requiresMaterializedRuntimeSkills: false,
supportsModelProfiles: true,
supportsAcp: true,
},
...(login ? { login } : {}),
};
},
}));
vi.mock("../adapters/use-disabled-adapters", () => ({
@ -318,6 +338,32 @@ const AUTH_MISSING_RESULT = {
testedAt: new Date(0).toISOString(),
};
const VENDOR_AUTH_MISSING_RESULT = {
adapterType: "vendor_local",
status: "fail",
checks: [
{
code: "adapter_auth_missing",
level: "error",
message: "The sandbox has no ready authentication.",
},
],
testedAt: new Date(0).toISOString(),
};
const PTY_VENDOR_AUTH_MISSING_RESULT = {
adapterType: "pty_vendor_local",
status: "fail",
checks: [
{
code: "adapter_auth_missing",
level: "error",
message: "The sandbox has no ready authentication.",
},
],
testedAt: new Date(0).toISOString(),
};
const CLAUDE_AUTH_MISSING_RESULT = {
adapterType: "claude_local",
status: "warn",
@ -341,8 +387,8 @@ const CLAUDE_AUTH_MISSING_RESULT = {
// for a provider with the capability.
const SANDBOX_CAPABILITIES = getEnvironmentCapabilities(["claude_local", "codex_local"], {
sandboxProviders: {
daytona: { supportsSetupTokenLogin: true, displayName: "Daytona" },
e2b: { supportsSetupTokenLogin: false, displayName: "E2B" },
daytona: { supportsLoginPty: true, displayName: "Daytona" },
e2b: { supportsLoginPty: false, displayName: "E2B" },
},
});
@ -372,6 +418,24 @@ async function renderCodexSandbox(agentOverrides: Partial<Agent> = {}) {
);
}
// A third adapter, not a built-in, in a sandbox environment. Its projected login
// capability drives the login affordance and the displayed-code panel.
async function renderVendorSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
{ adapterType: "vendor_local", defaultEnvironmentId: "sandbox-1", ...agentOverrides },
{ showAdapterTestEnvironmentButton: true },
);
}
async function renderClaudeSandbox(agentOverrides: Partial<Agent> = {}) {
return renderForm(
[
@ -935,6 +999,31 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(result.container, "Log in")).toBeTruthy();
});
it("shows the login affordance and the displayed-code panel for a third adapter with a projected login capability", async () => {
// The adapter is not a built-in. Its projected login capability drives the
// login affordance and the panel, so the form reads the capability, not the
// adapter name. The displayed-code panel shows the server code.
mockAgentsApi.testEnvironment.mockResolvedValue(VENDOR_AUTH_MISSING_RESULT);
const result = await renderVendorSandbox();
roots.push(result.root);
expect(findButton(result.container, "Log in")).toBeFalsy();
await runTest(result.container);
// The projected capability gates the login affordance on for the third
// adapter.
expect(findButton(result.container, "Log in")).toBeTruthy();
await startLogin(result.container);
// The displayed-code panel shows the one-time code and the authentication
// URL. It shows no browser-code input, so the dispatcher picked the panel
// from the projected `displayed_code` mode.
expect(result.container.textContent).toContain("WXYZ-1234");
expect(result.container.querySelector('input[aria-label="Browser code"]')).toBeFalsy();
});
it("hides the Login button before Test and shows it after the adapter_auth_missing check for a Claude sandbox", async () => {
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
const result = await renderClaudeSandbox();
@ -973,7 +1062,7 @@ describe("AgentConfigForm environment selector", () => {
it("hides the Login button for a Daytona sandbox while the capabilities report no setup-token support", async () => {
// Reproduces the reported defect: the Test carries the auth-missing check,
// but the capabilities endpoint reports `supportsSetupTokenLogin: false`
// but the capabilities endpoint reports `supportsLoginPty: false`
// for Daytona (a stale persisted plugin manifest). The gate hides the
// panel. The server-side fix refreshes the persisted manifest so the
// capability reports true and the panel shows (see the companion positive
@ -982,7 +1071,7 @@ describe("AgentConfigForm environment selector", () => {
mockEnvironmentsApi.capabilities.mockResolvedValue(
getEnvironmentCapabilities(["claude_local", "codex_local"], {
sandboxProviders: {
daytona: { supportsSetupTokenLogin: false, displayName: "Daytona" },
daytona: { supportsLoginPty: false, displayName: "Daytona" },
},
}),
);
@ -1006,6 +1095,57 @@ describe("AgentConfigForm environment selector", () => {
expect(findButton(result.container, "Log in")).toBeFalsy();
});
it("gates a pseudo-terminal login on the provider pty capability for a non-Claude adapter", async () => {
// The gate reads the adapter login transport, not the adapter name. This
// adapter is not `claude_local`, but its login runs on a pseudo-terminal.
// The E2B provider reports no pty capability, so the panel stays hidden even
// after the auth-missing check.
mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "E2B",
driver: "sandbox",
config: { provider: "e2b" },
}),
],
{ adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeFalsy();
});
it("shows a pseudo-terminal login for a non-Claude adapter when the provider advertises pty support", async () => {
// The same non-Claude pseudo-terminal adapter on Daytona. Daytona advertises
// the pty capability, so the panel shows. This confirms the gate follows the
// provider capability, not the adapter name.
mockAgentsApi.testEnvironment.mockResolvedValue(PTY_VENDOR_AUTH_MISSING_RESULT);
const result = await renderForm(
[
makeEnvironment({ id: "local-1", name: "Local", driver: "local" }),
makeEnvironment({
id: "sandbox-1",
name: "Daytona",
driver: "sandbox",
config: { provider: "daytona" },
}),
],
{ adapterType: "pty_vendor_local", defaultEnvironmentId: "sandbox-1" },
{ showAdapterTestEnvironmentButton: true },
);
roots.push(result.root);
await runTest(result.container);
expect(findButton(result.container, "Log in")).toBeTruthy();
});
it("shows the Login button when a parent lifts the test feedback and renders the panel from the descriptor", async () => {
// The create page hides the inline feedback branch and renders the test
// result and the login panel itself. This harness mirrors that parent: it

View File

@ -10,7 +10,7 @@ import type {
EnvSecretRefBinding,
Environment,
} from "@paperclipai/shared";
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode } from "@paperclipai/shared";
import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter, isValidBrowserCode, ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/shared";
import type { AdapterModel } from "../api/agents";
import { agentsApi } from "../api/agents";
import { ApiError } from "../api/client";
@ -22,7 +22,6 @@ import { DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX } from "@paperclipai/a
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { DEFAULT_OPENCODE_LOCAL_MODEL } from "@paperclipai/adapter-opencode-local";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "@paperclipai/adapter-codex-local/ui";
import {
Popover,
PopoverContent,
@ -587,17 +586,20 @@ 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.
// Load the sandbox provider capabilities. A login that runs on a real
// pseudo-terminal needs a provider that advertises the login pseudo-terminal
// capability. The login panel gate reads this to hide the panel for a provider
// without the capability. Enable the query from the adapter login transport,
// not the adapter name: only a pseudo-terminal login consults this data. 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",
enabled:
Boolean(selectedCompanyId) &&
adapterCaps.login?.sandboxTransport === "pseudo_terminal",
});
// When the instance forces Kubernetes execution, new agents must default to the
@ -939,35 +941,40 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
clearClaudeLoginClaimRef.current();
}, [adapterType, effectiveLoginEnvironmentId]);
// 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";
// Show the login affordance only for a current sandbox adapter that declares a
// login capability, and whose most recent Test result carries the canonical
// auth-missing check. The form reads the projected capability, not the adapter
// name. The result keeps its own `adapterType`, so the form reads the
// capability for that result adapter; a result from an adapter with no login
// capability never gates the panel.
const adapterSupportsSandboxLogin = adapterCaps.login != null;
const testResult = testEnvironment.data;
const testResultSupportsSandboxLogin =
testResult != null && getCapabilities(testResult.adapterType).login != null;
const authMissingCheck =
testEnvironment.data?.adapterType === "codex_local" ||
testEnvironment.data?.adapterType === "claude_local"
? testEnvironment.data.checks.find((check) => check.code === ADAPTER_AUTH_MISSING_CHECK_CODE) ?? null
testResult && testResultSupportsSandboxLogin
? testResult.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.
// A login that runs on a real pseudo-terminal needs a provider that advertises
// the login pseudo-terminal capability. Read the capability for the effective
// environment provider. The form reads the adapter login transport, not the
// adapter name: a login with a streamed-exec transport does not gate on this
// capability, so the requirement applies to a pseudo-terminal login only.
const effectiveLoginProvider =
typeof effectiveLoginEnvironment?.config?.provider === "string"
? effectiveLoginEnvironment.config.provider
: null;
const providerSupportsSetupTokenLogin =
const providerSupportsLoginPty =
effectiveLoginProvider != null &&
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsSetupTokenLogin === true;
environmentCapabilities?.sandboxProviders?.[effectiveLoginProvider]?.supportsLoginPty === true;
const loginNeedsPty = adapterCaps.login?.sandboxTransport === "pseudo_terminal";
const showAdapterLogin =
adapterSupportsSandboxLogin &&
effectiveLoginEnvironment?.driver === "sandbox" &&
Boolean(effectiveLoginEnvironmentId) &&
Boolean(selectedCompanyId) &&
Boolean(authMissingCheck) &&
(adapterType !== "claude_local" || providerSupportsSetupTokenLogin);
(!loginNeedsPty || providerSupportsLoginPty);
const runEnvironmentTest = useCallback(async () => {
if (!selectedCompanyId) {
throw new Error("Select a company to test adapter environment");
@ -2011,11 +2018,13 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
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.
// The login panel dispatcher. It picks the panel from the projected panel mode,
// not from the adapter name. The `submitted_browser_code` mode shows the
// submitted-browser-code panel; every other mode shows the displayed-code panel.
export function AdapterLoginPanel(props: AdapterLoginPanelProps) {
if (props.adapterType === "claude_local") {
const getCapabilities = useAdapterCapabilities();
const panelMode = getCapabilities(props.adapterType).login?.panelMode;
if (panelMode === "submitted_browser_code") {
return <SubmittedBrowserCodeLoginPanel {...props} />;
}
return <DisplayedCodeLoginPanel {...props} />;

View File

@ -193,7 +193,7 @@ const environmentCapabilities: EnvironmentCapabilities = {
interactiveSetupConnectionTypes: [],
supportsTemplateCapture: false,
supportsTemplateDelete: false,
supportsSetupTokenLogin: false,
supportsLoginPty: false,
displayName: "Fake",
source: "builtin",
},
@ -207,7 +207,7 @@ const environmentCapabilities: EnvironmentCapabilities = {
interactiveSetupConnectionTypes: ["ssh"],
supportsTemplateCapture: true,
supportsTemplateDelete: true,
supportsSetupTokenLogin: true,
supportsLoginPty: true,
displayName: "Daytona",
source: "plugin",
},

View File

@ -76,6 +76,33 @@ vi.mock("../adapters/use-disabled-adapters", () => ({
useDisabledAdaptersSync: () => new Set<string>(),
}));
// The form reads the projected adapter login capability to pick the login flow
// and to gate the login panel. The server projects these safe scalar fields.
// `claude_local` runs on a real pseudo-terminal, so the panel gate requires the
// provider pty capability. Provide the projection so the login panel renders.
const mockLoginProjections = vi.hoisted(
() =>
new Map<string, { panelMode: string; sandboxTransport: string; timeoutPolicy: string }>([
["claude_local", { panelMode: "submitted_browser_code", sandboxTransport: "pseudo_terminal", timeoutPolicy: "fixed" }],
["codex_local", { panelMode: "displayed_code", sandboxTransport: "streamed_exec", timeoutPolicy: "caller_bounded" }],
]),
);
vi.mock("../adapters/use-adapter-capabilities", () => ({
useAdapterCapabilities: () => (adapterType: string) => {
const login = mockLoginProjections.get(adapterType);
return {
supportsInstructionsBundle: true,
supportsSkills: true,
supportsLocalAgentJwt: true,
requiresMaterializedRuntimeSkills: false,
supportsModelProfiles: true,
supportsAcp: true,
...(login ? { login } : {}),
};
},
}));
vi.mock("../components/MarkdownEditor", () => ({
MarkdownEditor: ({
value,
@ -164,8 +191,8 @@ const CLAUDE_AUTH_MISSING_RESULT = {
// 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" },
daytona: { supportsLoginPty: true, displayName: "Daytona" },
e2b: { supportsLoginPty: false, displayName: "E2B" },
},
});