// The production transport binding for the Claude setup-token login session. // // The session service (`setup-token-session.ts`) drives the login flow over // three injected components: a lease manager, a login-process factory, and a // durable cleanup store. The router (`agents.ts`) accepts these components as an // optional `setupTokenLogin` transport and falls back to a deferred, fail-closed // default when a caller omits it. This module builds the production transport, so // `app.ts` binds it one time at startup. // // The binding passes only the fixed command `CLAUDE_SETUP_TOKEN_COMMAND` to the // login runner. It never accepts a command from a route, a request // body, or an adapter configuration. // // The live pseudo-terminal opener is a separate seam. The Daytona sandbox // provider runs as a plugin worker, so the server process does not hold the raw // sandbox process. The real opener binds inside the worker; this module accepts // it through `openLivePtySession`. A test injects a fake sandbox opener to drive // the full session path. The live opener lands with the characterization // test against a real sandbox. import { SetupTokenSessionError, SETUP_TOKEN_START_FAILED, SETUP_TOKEN_STORAGE_FAILED, createDbSetupTokenCleanupStore, transitionSetupTokenSessionToStored, type SetupTokenCleanupStore, type SetupTokenLease, type SetupTokenLeaseManager, type SetupTokenLoginOutcome, type SetupTokenLoginProcess, type SetupTokenLoginProcessFactory, type SetupTokenSecretWriter, type SetupTokenSessionScope, } from "./setup-token-session.js"; import { secretService } from "./secrets.js"; import type { environmentService } from "./environments.js"; import type { environmentRuntimeService } from "./environment-runtime.js"; import { buildLoginLeaseAcquireArgs } from "./adapter-login-lease.js"; import type { Db } from "@paperclipai/db"; import { createLoginPtyTransport, type LoginPtySession, type LoginPtySessionOpener, } from "@paperclipai/adapter-utils/login-pty-transport"; import { runSetupTokenLogin, CLAUDE_SETUP_TOKEN_COMMAND, } from "@paperclipai/adapter-claude-local/server"; import { randomUUID } from "node:crypto"; import { deriveLoginSessionHome, resolveLoginCommandKey, validateLoginSessionHome, type LoginCommandKey, } from "./login-command.js"; /** * The sandbox provider for one login session. It acquires a fresh sandbox lease * and returns the lease id plus the pseudo-terminal opener bound to that lease's * process. It releases the lease by id. The production provider wraps the * environment runtime. A test injects a fake provider. */ export interface SetupTokenSandboxProvider { /** * Acquires a fresh sandbox lease for a login session. It returns the lease id * and the pseudo-terminal opener bound to the acquired lease's process. It * throws a fail-closed error when it cannot acquire the lease or bind the * opener; the caller then returns the fixed start error. */ acquire(input: { scope: SetupTokenSessionScope; deadline: number; }): Promise<{ leaseId: string; openPtySession: LoginPtySessionOpener }>; /** Releases the lease by id. The service and the startup reaper call it. */ release(leaseId: string): Promise; } /** The dependencies the production transport binding needs. */ export interface SetupTokenLoginTransportDeps { /** The sandbox provider. The production provider wraps the environment runtime. */ sandbox: SetupTokenSandboxProvider; /** The durable cleanup store. Defaults to the database-backed store over `db`. */ store: SetupTokenCleanupStore; /** * The owner-bound secret writer. The binding forwards it to the router, so a * completed login stores the minted token in the secret store. When a caller * omits it, the router keeps its deferred, fail-closed writer. */ completeCredential?: SetupTokenSecretWriter; /** A non-leaking status sink. It receives only fixed status lines. */ log?: (line: string) => void; } /** * The production transport the router binds through `options.setupTokenLogin`. It * carries the live lease manager, the login-process factory, the durable cleanup * store, and the owner-bound secret writer. The router uses the writer for the * final secret write; when the binding omits it, the router keeps its deferred, * fail-closed writer. */ export interface SetupTokenLoginTransportBinding { factory: SetupTokenLoginProcessFactory; leases: SetupTokenLeaseManager; store: SetupTokenCleanupStore; completeCredential?: SetupTokenSecretWriter; } /** * The narrow secret-completion surface the production writer needs. The secrets * service exposes `completeClaudeOAuthUserSecret` as the owner-bound * compare-and-set for the Claude Code OAuth value. The writer forwards only the * scope owner, the session id, the mode, and the token to it. */ export interface SetupTokenSecretCompletion { completeClaudeOAuthUserSecret( companyId: string, ownerUserId: string, input: { sessionId: string; mode: "first_write" | "confirmed_rotation"; value: string; expectedSecretId?: string | null; expectedLatestVersion?: number | null; }, actor?: { userId?: string | null; agentId?: string | null }, ): Promise; } /** The owner-bound compare-and-set input the secrets service consumes. */ export type ClaudeOAuthWriteInput = | { sessionId: string; mode: "first_write"; value: string } | { sessionId: string; mode: "confirmed_rotation"; value: string; expectedSecretId: string; expectedLatestVersion: number; }; /** * Maps the session writer input to the secrets service compare-and-set input. The * session scope selects the mode. A scope with no overwrite capture writes with * `mode: "first_write"`, so the login creates the first owner value only. A scope * that carries the confirmed-overwrite capture writes with * `mode: "confirmed_rotation"`, so the login rotates the stored value under the * captured `expectedSecretId` and `expectedLatestVersion`. The overwrite capture * carries no owner; the owner still comes from the scope. */ export function buildClaudeOAuthWriteInput( scope: SetupTokenSessionScope, sessionId: string, token: string, ): ClaudeOAuthWriteInput { const overwrite = scope.confirmedOverwrite ?? null; return overwrite ? { sessionId, mode: "confirmed_rotation", value: token, expectedSecretId: overwrite.expectedSecretId, expectedLatestVersion: overwrite.expectedLatestVersion, } : { sessionId, mode: "first_write", value: token }; } /** * Builds the production atomic credential-claim writer for the login session. It * runs one control-plane transaction. Inside the transaction it first transitions * the exact durable session row to `stored` with a full-scope conditional update, * then writes or rotates the owner-bound secret on the same transaction handle. * The commit establishes the encrypted secret and the `stored` claim together, so * a crash between the two steps cannot leave a stored token with no valid claim. * * A zero-row transition means no claimable row exists in this scope. The writer * rolls back and rejects with the fixed, non-secret storage error, so the session * marks the login failed. A storage failure rejects and rolls back the whole * transaction, so neither the secret nor the claim commits. A concurrent change * fails the compare-and-set with the fixed stale conflict, so the write leaves no * partial state. * * It reads the company and the owner only from the immutable session scope, so a * request cannot redirect the write to another owner. It never logs * the token and never puts the token in an error; it lets the secrets service * error propagate unchanged, and the session maps a rejection to the fixed, * non-secret storage error. */ export function createSetupTokenSecretWriter(deps: { db: Db; /** * Builds the transaction-bound owner-bound completion. It defaults to * `secretService(tx)`, so the compare-and-set runs on the passed transaction * handle. A test injects a fake to force a storage failure after the transition. */ completionForTx?: (tx: Db) => SetupTokenSecretCompletion; }): SetupTokenSecretWriter { const completionForTx = deps.completionForTx ?? ((tx) => secretService(tx)); return async ({ scope, sessionId, token }) => { const input = buildClaudeOAuthWriteInput(scope, sessionId, token); await deps.db.transaction(async (tx) => { const txDb = tx as unknown as Db; // Step 1: lock and conditionally transition the exact durable row to // `stored`. The predicate matches the full scope, an allowed predecessor // state, an unexpired deadline, and an unconsumed claim. It requires exactly // one returned row. const transitioned = await transitionSetupTokenSessionToStored(txDb, { sessionId, companyId: scope.companyId, ownerUserId: scope.ownerUserId, adapterType: scope.adapterType, }); if (!transitioned) { // Zero-row result: roll back the whole transaction. The session then marks // the login failed and returns the fixed no-secret error. throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED); } // Step 2: write or rotate the secret on the same transaction handle. A // failure here rolls back the transition too, so the commit is all-or-none. await completionForTx(txDb).completeClaudeOAuthUserSecret( scope.companyId, scope.ownerUserId, input, { userId: scope.ownerUserId }, ); }); }; } /** The immutable scope key that correlates one acquire with one factory call. 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].join("\u0000"); } /** * Builds the production setup-token login transport. The lease manager acquires a * sandbox lease through the injected provider and hands the pseudo-terminal * opener to the factory. The factory drives the login runner over the transport * and passes only the fixed command. The lease manager releases the lease on * every terminal path, so the service runs its cleanup against the durable store. */ export function buildSetupTokenLoginTransport( deps: SetupTokenLoginTransportDeps, ): SetupTokenLoginTransportBinding { // The binding fixes the login command. It never reads a command from a route, a // request body, an adapter configuration, or the dependency object, so a // runtime-supplied value cannot change the pseudo-terminal command. const command = CLAUDE_SETUP_TOKEN_COMMAND; const log = deps.log ?? (() => {}); // The pseudo-terminal opener that one acquire hands to the next factory call. // The service calls `leases.acquire` and then `factory` in sequence for one // session, so a per-scope handoff correlates them. The per-owner session cap is // one, so one scope holds at most one live acquire. const pendingOpeners = new Map(); // The reverse map releases a handoff by lease id when the factory never // consumes it (a factory error path). const leaseScopeKeys = new Map(); const leases: SetupTokenLeaseManager = { async acquire({ scope, deadline }): Promise { const acquired = await deps.sandbox.acquire({ scope, deadline }); const key = scopeKey(scope); pendingOpeners.set(key, acquired); leaseScopeKeys.set(acquired.leaseId, key); return { id: acquired.leaseId }; }, async release(lease): Promise { dropHandoff(lease.id); await deps.sandbox.release(lease.id); }, async releaseById(leaseId): Promise { dropHandoff(leaseId); await deps.sandbox.release(leaseId); }, }; function dropHandoff(leaseId: string): void { const key = leaseScopeKeys.get(leaseId); if (key !== undefined) { const pending = pendingOpeners.get(key); if (pending && pending.leaseId === leaseId) pendingOpeners.delete(key); leaseScopeKeys.delete(leaseId); } } const factory: SetupTokenLoginProcessFactory = ({ scope, onPrompt, onCredential, timeoutMs, signal }): SetupTokenLoginProcess => { const key = scopeKey(scope); const pending = pendingOpeners.get(key); if (!pending) { // The lease manager did not hand off an opener for this scope. Fail closed. throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); } pendingOpeners.delete(key); leaseScopeKeys.delete(pending.leaseId); const transport = createLoginPtyTransport(pending.openPtySession); // Bridge the browser code. The service calls `submitCode` one time after the // single `awaiting_code` transition wins. The runner calls `provideCode` one // time after it surfaces the prompt. A resolved promise hands the code from // the service call to the runner call. let deliverCode: ((code: string) => void) | null = null; const codeReady = new Promise((resolve, reject) => { deliverCode = resolve; // A cancel or an expiry aborts the signal. Reject the pending code, so the // runner stops waiting and ends the run. if (signal.aborted) { reject(new SetupTokenSessionError(499, SETUP_TOKEN_START_FAILED)); return; } signal.addEventListener( "abort", () => reject(new SetupTokenSessionError(499, SETUP_TOKEN_START_FAILED)), { once: true }, ); }); // The runner rejects the run when `provideCode` rejects. Consume the pending // rejection here too, so it never becomes an unhandled rejection when the // runner ends on the timeout or the signal before it reads the code. codeReady.catch(() => {}); const done: Promise = runSetupTokenLogin(transport, { command, timeoutMs, signal, onPrompt: (prompt) => onPrompt({ url: prompt.url }), provideCode: () => codeReady, onCredential: async (authBytes) => { // Forward the minted token to the owner-bound secret write. The service // holds the token only for this call and never stores it. await onCredential(authBytes.toString("utf8")); }, log, }).then((result) => result.outcome); return { done, submitCode(code): void { deliverCode?.(code); }, stop(): void { // Stop the direct child. The service releases the lease after this call. transport.stop(); }, }; }; return { factory, leases, store: deps.store, completeCredential: deps.completeCredential }; } /** The dependencies the production sandbox provider needs. */ export interface ProductionSetupTokenSandboxProviderDeps { environments: Pick, "getById" | "getLeaseById" | "releaseLease">; environmentRuntime: Pick, "acquireRunLease" | "getDriver">; /** * Opens the live pseudo-terminal for the acquired lease. The Daytona provider * runs as a plugin worker, so the real opener binds `sandbox.process` inside * the worker. When a caller omits it, the provider fails closed before it * acquires a lease, and the live path lands with the characterization * test. */ openLivePtySession?: (input: { scope: SetupTokenSessionScope; environmentId: string; leaseId: string; }) => Promise; /** A non-leaking status sink. It receives only fixed status lines. */ log?: (line: string) => void; } /** * Builds the production sandbox provider over the environment runtime. It * acquires a fresh sandbox lease for one login session, binds the live * pseudo-terminal opener to that lease, and releases the lease by id. It fails * closed for a local environment, an SSH environment, or a missing live opener. */ export function createProductionSetupTokenSandboxProvider( deps: ProductionSetupTokenSandboxProviderDeps, ): SetupTokenSandboxProvider { const log = deps.log ?? (() => {}); // The acquired lease records, keyed by lease id. The provider releases a lease // through its driver. The startup reaper releases a lease by id after a // restart, when this map is empty; the provider then falls back to a direct // database release. const leaseRecords = new Map< string, Awaited["acquireRunLease"]>> >(); const failClosed = (): never => { throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); }; return { async acquire({ scope, deadline }) { const environment = await deps.environments.getById(scope.environmentId); if (!environment) { log("[paperclip] Setup-token login: the selected environment is not found."); return failClosed(); } if (environment.driver === "local" || environment.driver === "ssh") { // The login pseudo-terminal needs a sandbox. A local or an SSH // environment has no sandbox process to run the login command in. log("[paperclip] Setup-token login: the selected environment has no sandbox."); return failClosed(); } if (!deps.openLivePtySession) { // The live sandbox pseudo-terminal opener is not bound yet. Fail closed // before the acquire, so the login holds no lease. The live opener lands // with the characterization test against a real sandbox. log( "[paperclip] Setup-token login: the live sandbox pseudo-terminal transport is not bound.", ); return failClosed(); } const leaseRecord = await deps.environmentRuntime.acquireRunLease( 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); // Enforce an independent, provider-backed expiry at or before the session // deadline. A crash or an outage stops the in-process cleanup, so the // lease row must carry a durable expiry that a lease reaper acts on. When // the acquired lease has no expiry, an invalid expiry, or an expiry after // the deadline, the server cannot guarantee the hard stop. Release the // remote lease and fail closed, so the login holds no lease and leaves no // durable cleanup row. const expiresAt = leaseRecord.lease.expiresAt; const expiresAtMs = expiresAt instanceof Date ? expiresAt.getTime() : Number.NaN; if (!Number.isFinite(expiresAtMs) || expiresAtMs > deadline) { await this.release(leaseId); log("[paperclip] Setup-token login: the acquired lease expiry does not bound the session deadline."); return failClosed(); } try { const openPtySession = await deps.openLivePtySession({ scope, environmentId: scope.environmentId, leaseId, }); return { leaseId, openPtySession }; } catch (err) { // The opener bind failed. Release the lease and fail closed, so the login // holds no lease. await this.release(leaseId); log("[paperclip] Setup-token login: the live pseudo-terminal bind failed."); void err; return failClosed(); } }, async release(leaseId) { const leaseRecord = leaseRecords.get(leaseId); if (leaseRecord) { leaseRecords.delete(leaseId); const driver = deps.environmentRuntime.getDriver(leaseRecord.environment.driver); if (driver) { await driver.releaseRunLease({ environment: leaseRecord.environment, lease: leaseRecord.lease, status: "released", }); return; } } // A restart cleared the in-memory record. Release the lease through the // provider driver, not only the database row, so the release tears down the // remote sandbox that holds the login state. await releaseLeaseById(leaseId); }, }; /** * Releases a lease by id after a restart, when the in-memory record is gone. It * resolves the stored lease and its environment, then releases the lease through * the provider driver, so the release tears down the remote sandbox and not only * the database row. It fails loud when it resolves a live lease that it cannot * release, so the reaper keeps the durable cleanup record and retries it. It * returns without an error only when the lease row is already gone, because then * no sandbox remains to release. */ async function releaseLeaseById(leaseId: string): Promise { const lease = await deps.environments.getLeaseById(leaseId); if (!lease) { // The lease row is already gone. No remote sandbox remains to release, so // the release is idempotent and the reaper may drop the cleanup record. return; } const environment = lease.environmentId ? await deps.environments.getById(lease.environmentId) : null; if (!environment) { // The lease resolves but its environment is gone, so no driver can tear down // the remote sandbox. Fail loud, so the reaper keeps the cleanup record. log("[paperclip] Setup-token login: the lease environment is not found for a restart release."); return failClosed(); } const driver = deps.environmentRuntime.getDriver(environment.driver); if (!driver) { // No driver resolves the remote sandbox. Fail loud, so the reaper keeps the // cleanup record and retries the release. log("[paperclip] Setup-token login: no driver resolves the lease for a restart release."); return failClosed(); } await driver.releaseRunLease({ environment, lease, status: "released" }); } } /** Builds the durable, database-backed cleanup store for the production binding. */ export function createProductionSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore { return createDbSetupTokenCleanupStore(db); } /** * The narrow plugin worker manager surface the live opener needs. The manager * owns the host route gate: it mints the host route identifier, * reserves one route per worker, drives the open, binds the worker session * identifier one time, routes output, and terminalizes the route. */ export interface LoginPtyWorkerManagerLike { openLoginPtySession( pluginId: string, input: { driverKey: string; companyId: string; environmentId: string; providerLeaseId: string; loginCommandKey: LoginCommandKey; sessionHome: string; }, ): Promise; } /** The narrow lease-lookup surface the live opener needs. */ export interface LoginPtyLeaseLookup { getLeaseById(leaseId: string): Promise< | { providerLeaseId: string | null; metadata: Record | null | undefined; } | null >; } /** The dependencies the worker-bound live pseudo-terminal opener needs. */ export interface WorkerBoundLoginPtyOpenerDeps { /** The plugin worker manager that owns the host route gate. */ workerManager: LoginPtyWorkerManagerLike; /** The environment lease lookup that resolves the worker target for a lease. */ environments: LoginPtyLeaseLookup; /** A non-leaking status sink. It receives only fixed status lines. */ log?: (line: string) => void; } function readLeaseMetaString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } /** * Builds the production `openLivePtySession`. It resolves the server lease to its * `providerLeaseId` and the sandbox worker plugin id, then returns an opener that * drives the worker through the manager route gate. The manager mints the host * route identifier and owns the route lifecycle. The opener fails closed when the * lease carries no sandbox worker binding. * * The opener resolves the host launch descriptor. It reads the closed login * command key from the trusted adapter type, and it derives the server-controlled * session home from a fresh UUID. It validates the home shape before the worker * RPC. It never reads a command from the caller: the incoming opener argument is * unused, so the transport cannot influence the sandbox command. */ export function createWorkerBoundLoginPtyOpener( deps: WorkerBoundLoginPtyOpenerDeps, ): NonNullable { const log = deps.log ?? (() => {}); return async ({ scope, environmentId, leaseId }) => { const lease = await deps.environments.getLeaseById(leaseId); const providerLeaseId = typeof lease?.providerLeaseId === "string" && lease.providerLeaseId.length > 0 ? lease.providerLeaseId : null; const metadata = lease && typeof lease.metadata === "object" && lease.metadata !== null ? (lease.metadata as Record) : {}; const pluginId = readLeaseMetaString(metadata.pluginId); const driverKey = readLeaseMetaString(metadata.provider) ?? readLeaseMetaString(metadata.driver); if (!providerLeaseId || !pluginId || !driverKey) { log("[paperclip] Setup-token login: the lease carries no sandbox worker binding."); throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); } // Resolve the closed command key from the trusted adapter type. An unmapped // adapter fails closed before the worker RPC. let loginCommandKey: LoginCommandKey; try { loginCommandKey = resolveLoginCommandKey(scope.adapterType); } catch { log("[paperclip] Setup-token login: the adapter type has no login command key."); throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED); } // The opener argument is the runner's fixed command string. It confers no // command authority, so the opener ignores it and returns a host-bound opener. return (_command: string) => { // Generate the session UUID, then the home path, then validate the exact // shape before the host sends the worker RPC. const sessionHome = deriveLoginSessionHome(randomUUID()); validateLoginSessionHome(sessionHome); return deps.workerManager.openLoginPtySession(pluginId, { driverKey, companyId: scope.companyId, environmentId, providerLeaseId, loginCommandKey, sessionHome, }); }; }; }