1505 lines
63 KiB
TypeScript
1505 lines
63 KiB
TypeScript
// The Claude setup-token login session service. It owns a company-scoped,
|
|
// owner-bound login session that holds one live setup-token process and one
|
|
// sandbox lease for the whole login. The login is a two-way round-trip: the
|
|
// `claude setup-token` process holds the flow state in memory, so the server
|
|
// keeps one live process and the lease alive inside one long-lived server
|
|
// process. The service never re-spawns the command per request and never splits
|
|
// the round-trip across separate runs.
|
|
//
|
|
// The service gives the harness these operations against the one live session:
|
|
// start the session, read the login prompt, submit one browser code, cancel the
|
|
// session, expire the session on a timeout, and receive the token. Every
|
|
// operation verifies the company, the owner user, and the 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
|
|
// browser code, and the token out of every log, activity detail, error, and
|
|
// telemetry sink. It surfaces the full URL only through the authorized owner
|
|
// read response, and it exposes a sanitized URL form to every other reader.
|
|
// * SR-3 (owner binding, atomic one-code): an opaque random session id, an
|
|
// immutable scope, and a single compare-and-set transition from
|
|
// `awaiting_code` to `submitting`. The service rejects every later submit.
|
|
// * SR-4 (durable cleanup and caps): a non-secret cleanup record, an external
|
|
// lease expiry no later than the session deadline, idempotent cleanup on
|
|
// every terminal path, a startup reaper, per-owner/agent/company caps, and a
|
|
// start rate limit.
|
|
// * SR-5 (two login-URL representations): the full URL is transport-only; every
|
|
// sink receives the sanitized URL form.
|
|
// * SR-6 and SR-7 (fail-closed TLS transport guard): a centralized guard that
|
|
// the route applies to the confidential read-prompt and receive-token
|
|
// responses. The guard is a pure function in this module so it stays unit
|
|
// testable.
|
|
|
|
import { randomBytes } from "node:crypto";
|
|
import { and, eq, gt, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
|
|
import type { Db } 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.
|
|
//
|
|
// Three consumers share this one constant, and they must change together:
|
|
// 1. The start-route guard in `agents.ts`, which rejects a request for any
|
|
// other adapter type before it creates a lease, a durable row, or a
|
|
// pseudo-terminal.
|
|
// 2. The five follow-up routes in `agents.ts`, which build their session
|
|
// lookup key from this constant through `companySetupTokenKey`.
|
|
// 3. The restart reaper scan below, which filters on this constant to reach
|
|
// only the setup-token rows and to leave every Codex device-login row
|
|
// alone.
|
|
// A future change that serves a second adapter through this flow must widen
|
|
// all three consumers, not just one, or a served adapter will create a row
|
|
// that a follow-up route or the reaper cannot find.
|
|
export 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
|
|
* owner-bound secret write, and the durable row then persists as a one-time
|
|
* claim. The create path consumes the claim, or the reaper removes the row after
|
|
* the deadline.
|
|
*/
|
|
export type SetupTokenSessionState =
|
|
| "starting"
|
|
| "awaiting_code"
|
|
| "submitting"
|
|
| "stored"
|
|
| "completed"
|
|
| "failed"
|
|
| "timed_out"
|
|
| "cancelled";
|
|
|
|
/** The four terminal states. Cleanup runs once when a session reaches one. */
|
|
export const SETUP_TOKEN_TERMINAL_STATES: readonly SetupTokenSessionState[] = [
|
|
"completed",
|
|
"failed",
|
|
"timed_out",
|
|
"cancelled",
|
|
];
|
|
|
|
export function isTerminalSessionState(state: SetupTokenSessionState): boolean {
|
|
return SETUP_TOKEN_TERMINAL_STATES.includes(state);
|
|
}
|
|
|
|
/**
|
|
* The immutable owner scope of a session. 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 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;
|
|
// 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
|
|
// stored value under the captured version instead of a first write. It is not
|
|
// part of the session identity: the non-start routes rebuild the scope without
|
|
// it, so the full-scope match ignores it (see `resolveOwned`).
|
|
confirmedOverwrite?: { expectedSecretId: string; expectedLatestVersion: number } | null;
|
|
}
|
|
|
|
/** The terminal outcome of the live login process. */
|
|
export type SetupTokenLoginOutcome = "success" | "failure" | "timeout" | "cancelled";
|
|
|
|
/**
|
|
* The live login process the service drives for one session. A production
|
|
* factory binds this to the setup-token runner over a sandbox pseudo-terminal.
|
|
* A unit test binds it to a fake. The process holds the flow state in memory.
|
|
*/
|
|
export interface SetupTokenLoginProcess {
|
|
/** Resolves with the terminal outcome when the login process ends. */
|
|
readonly done: Promise<SetupTokenLoginOutcome>;
|
|
/**
|
|
* Forwards the one browser code to the live process. The service calls it one
|
|
* time, only after the single `awaiting_code` to `submitting` transition wins.
|
|
*/
|
|
submitCode(code: string): void;
|
|
/**
|
|
* Stops the direct child process. The service calls it before it releases the
|
|
* lease. The method must be safe to call more than one time.
|
|
*/
|
|
stop(): void;
|
|
}
|
|
|
|
/** The prompt sink the factory calls one time when it surfaces the sign-in URL. */
|
|
export type SetupTokenPromptSink = (prompt: { url: string }) => void;
|
|
|
|
/**
|
|
* The credential sink the factory calls one time when the login binds the minted
|
|
* token from the success record. The sink is asynchronous: it awaits the
|
|
* owner-bound secret write and rejects on a storage failure. The factory (or the
|
|
* runner it wraps) awaits this sink before it reports success, so a storage
|
|
* failure ends the login as a failure. The sink never logs the token and never
|
|
* returns it.
|
|
*/
|
|
export type SetupTokenCredentialSink = (token: string) => Promise<void>;
|
|
|
|
/**
|
|
* The atomic credential-claim writer. The service awaits it one time with the
|
|
* minted token, the owner scope, and the durable session id. It runs one
|
|
* control-plane transaction that first transitions the exact durable row to
|
|
* `stored` with a full-scope conditional update, then performs the owner-bound
|
|
* secret compare-and-set on the same transaction handle. The commit establishes
|
|
* the encrypted secret and the `stored` claim together. It resolves on a
|
|
* successful commit. It rejects and rolls back the whole transaction on a
|
|
* zero-row transition or on a storage failure, so neither the secret nor the
|
|
* claim commits. The service holds the token only for this call; it never stores
|
|
* it.
|
|
*/
|
|
export type SetupTokenSecretWriter = (input: {
|
|
scope: SetupTokenSessionScope;
|
|
sessionId: string;
|
|
token: string;
|
|
}) => Promise<void>;
|
|
|
|
/**
|
|
* Builds the live login process for one session. The factory receives the
|
|
* prompt sink, the credential sink, the host timeout, and an abort signal that
|
|
* the service aborts on cancel and on expiry. The factory awaits the credential
|
|
* sink one time before it reports success.
|
|
*/
|
|
export type SetupTokenLoginProcessFactory = (params: {
|
|
scope: SetupTokenSessionScope;
|
|
onPrompt: SetupTokenPromptSink;
|
|
onCredential: SetupTokenCredentialSink;
|
|
timeoutMs: number;
|
|
signal: AbortSignal;
|
|
}) => SetupTokenLoginProcess;
|
|
|
|
/** An opaque sandbox lease handle. The service holds one per session. */
|
|
export interface SetupTokenLease {
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* Acquires and releases the sandbox lease for a login session. The production
|
|
* manager wraps the environment runtime. The service sets an external lease
|
|
* expiry no later than the session deadline through the `deadline` field.
|
|
*/
|
|
export interface SetupTokenLeaseManager {
|
|
acquire(input: { scope: SetupTokenSessionScope; deadline: number }): Promise<SetupTokenLease>;
|
|
release(lease: SetupTokenLease): Promise<void>;
|
|
/** Releases a lease by id. The startup reaper uses this after a restart. */
|
|
releaseById(leaseId: string): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* The non-secret cleanup record. It holds only ids, the deadline, 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 carries the environment, so the store writes the non-null environment
|
|
* column on the row.
|
|
*/
|
|
export interface SetupTokenCleanupRecord {
|
|
sessionId: string;
|
|
companyId: string;
|
|
ownerUserId: string;
|
|
adapterType: string;
|
|
environmentId: string;
|
|
leaseId: string;
|
|
deadline: number;
|
|
state: SetupTokenSessionState;
|
|
// The claim-consumption marker. It is null while a `stored` claim is live. The
|
|
// create path sets it one time when it consumes the claim.
|
|
boundAt: number | null;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* The durable store for the non-secret cleanup record. A restart reads the
|
|
* store and reaps a lease whose session is terminal, past its deadline, or
|
|
* already consumed. The store persists no secret.
|
|
*/
|
|
export interface SetupTokenCleanupStore {
|
|
record(record: SetupTokenCleanupRecord): Promise<void>;
|
|
/**
|
|
* Marks the state only when the full owner scope and the session id match. The
|
|
* write never updates a row by the session id alone.
|
|
*/
|
|
markState(identity: SetupTokenCleanupIdentity, state: SetupTokenSessionState): Promise<void>;
|
|
/**
|
|
* Deletes the record only when the full owner scope and the session id match.
|
|
* The delete never removes a row by the session id alone, so a cross-scope
|
|
* caller cannot delete a foreign row.
|
|
*/
|
|
remove(identity: SetupTokenCleanupIdentity): Promise<void>;
|
|
/**
|
|
* Returns each record whose session is terminal, whose deadline is past, or
|
|
* whose claim is already consumed.
|
|
*/
|
|
listReapable(now: number): Promise<SetupTokenCleanupRecord[]>;
|
|
/**
|
|
* Consumes a `stored` claim with one conditional write. The predicate carries
|
|
* the full owner scope, the session id, `state = stored`, an unexpired
|
|
* deadline that it checks with `clock_timestamp()`, and an unconsumed marker.
|
|
* The write sets `bound_at` one time and returns the row on a valid consume.
|
|
* It returns null for a missing, foreign-scope, expired, non-`stored`, or
|
|
* already-consumed claim. It never reads the state or the expiry in a separate
|
|
* step. The agent-service transaction calls this method.
|
|
*/
|
|
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;
|
|
perCompany: number;
|
|
}
|
|
|
|
export const DEFAULT_SETUP_TOKEN_SESSION_CAPS: SetupTokenSessionCaps = {
|
|
perOwner: 1,
|
|
perCompany: 3,
|
|
};
|
|
|
|
/** The default host timeout for one login session. */
|
|
export const DEFAULT_SETUP_TOKEN_SESSION_TTL_MS = 5 * 60_000;
|
|
|
|
/**
|
|
* The default retention window for a completed token. The service releases the
|
|
* sandbox lease at once on success, but it keeps the token in memory for this
|
|
* window, so the authorized owner can receive it one time. A short window bounds
|
|
* how long the service holds the secret if the owner never receives it (SR-4).
|
|
*/
|
|
export const DEFAULT_SETUP_TOKEN_RETENTION_MS = 60_000;
|
|
|
|
// The fixed, non-secret error texts. The route returns these verbatim and
|
|
// echoes no input. A missing session and a cross-scope session share one text,
|
|
// so a caller cannot tell them apart.
|
|
export const SETUP_TOKEN_SESSION_NOT_FOUND = "Setup-token login session not found.";
|
|
export const SETUP_TOKEN_SUBMIT_CONFLICT = "The setup-token login session cannot accept this code.";
|
|
export const SETUP_TOKEN_RATE_LIMITED = "Too many setup-token login attempts. Try again later.";
|
|
export const SETUP_TOKEN_CAP_EXCEEDED = "Too many active setup-token login sessions.";
|
|
// The fixed error for a completion that is not ready. The session is not
|
|
// `completed` with a stored secret yet. It leaks no session state.
|
|
export const SETUP_TOKEN_TOKEN_UNAVAILABLE = "The setup-token is not available for this session.";
|
|
export const SETUP_TOKEN_START_FAILED = "The setup-token login session could not start.";
|
|
// The fixed error for a sandbox provider that does not advertise the setup-token
|
|
// login capability. Only a provider that implements the setup-token
|
|
// pseudo-terminal methods can host the login. The route returns this specific,
|
|
// typed error and starts no session, so an unsupported provider never reaches a
|
|
// session row, a lease, or a pseudo-terminal.
|
|
export const SETUP_TOKEN_PROVIDER_UNSUPPORTED =
|
|
"The sandbox provider does not support the Claude setup-token login.";
|
|
export const SETUP_TOKEN_PROVIDER_UNSUPPORTED_CODE = "setup_token_provider_unsupported";
|
|
// The fixed error for a failed owner-bound secret write. It carries no token and
|
|
// no storage detail, so it leaks no secret.
|
|
export const SETUP_TOKEN_STORAGE_FAILED = "The setup-token login could not store the credential.";
|
|
|
|
/** A typed error the route maps to a fixed status and the fixed text above. */
|
|
export class SetupTokenSessionError extends Error {
|
|
readonly status: number;
|
|
constructor(status: number, message: string) {
|
|
super(message);
|
|
this.name = "SetupTokenSessionError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Builds the sanitized login-URL form for every non-owner sink (SR-5). The
|
|
* function removes the query, the fragment, and the credentials, so no OAuth
|
|
* parameter and no secret reaches a log, an activity detail, an error, a trace,
|
|
* or client telemetry. It keeps the origin and the path for a useful diagnostic.
|
|
* It returns a fixed placeholder for a value it cannot parse, so it never falls
|
|
* back to the raw input.
|
|
*/
|
|
export function toSanitizedLoginUrl(rawUrl: string): string {
|
|
try {
|
|
const url = new URL(rawUrl);
|
|
url.username = "";
|
|
url.password = "";
|
|
url.search = "";
|
|
url.hash = "";
|
|
return url.toString();
|
|
} catch {
|
|
return "[unparsable-login-url]";
|
|
}
|
|
}
|
|
|
|
// --- SR-6 and SR-7: the confidential-response transport guard ----------------
|
|
|
|
/**
|
|
* The startup transport configuration for the confidential responses. The
|
|
* server resolves it once at startup. `trustedProxies` is the dedicated,
|
|
* explicit proxy IP or CIDR allowlist for the confidential routes. The global
|
|
* `TRUST_PROXY` setting does not appear here, so `TRUST_PROXY=true` and a
|
|
* hop-count value never satisfy the guard (SR-7).
|
|
*/
|
|
export interface ConfidentialTransportConfig {
|
|
deploymentMode: "local_trusted" | "authenticated";
|
|
trustedProxies: string[];
|
|
/**
|
|
* The explicit operator declaration that every client request reaches this
|
|
* server through a platform edge that terminates TLS (a managed PaaS such as
|
|
* Railway, Render, or Fly, where the app socket is always plain HTTP and the
|
|
* edge-proxy peer addresses are not operator-visible, so `trustedProxies`
|
|
* cannot express them). Unlike the global `TRUST_PROXY` setting, which the
|
|
* guard deliberately never reads (SR-7), this is a dedicated, single-purpose
|
|
* statement about the confidential login routes only. When declared, a
|
|
* request is confidential unless the edge itself labels the client hop as
|
|
* plain `http` in `X-Forwarded-Proto`. Defaults to false.
|
|
*/
|
|
edgeTlsTerminated?: boolean;
|
|
}
|
|
|
|
/** The per-request transport signals the guard reads from the raw socket. */
|
|
export interface ConfidentialTransportRequest {
|
|
/** The raw TLS bit on the immediate socket. It ignores `trust proxy`. */
|
|
socketEncrypted: boolean;
|
|
/** The immediate peer address. It ignores `trust proxy`. */
|
|
remoteAddress: string | undefined;
|
|
/** The `X-Forwarded-Proto` header value. The guard reads its first hop only. */
|
|
forwardedProto: string | undefined;
|
|
}
|
|
|
|
export interface ConfidentialTransportDecision {
|
|
allowed: boolean;
|
|
reason: string;
|
|
}
|
|
|
|
function isLoopbackAddress(address: string | undefined): boolean {
|
|
if (!address) return false;
|
|
const normalized = address.trim().toLowerCase();
|
|
if (normalized === "::1" || normalized === "localhost") return true;
|
|
// Express reports an IPv4 loopback peer as `::ffff:127.0.0.1` on a dual stack.
|
|
const withoutV4Prefix = normalized.startsWith("::ffff:")
|
|
? normalized.slice("::ffff:".length)
|
|
: normalized;
|
|
return withoutV4Prefix.startsWith("127.");
|
|
}
|
|
|
|
function ipv4ToInt(address: string): number | null {
|
|
const parts = address.split(".");
|
|
if (parts.length !== 4) return null;
|
|
let value = 0;
|
|
for (const part of parts) {
|
|
if (!/^\d{1,3}$/.test(part)) return null;
|
|
const octet = Number(part);
|
|
if (octet > 255) return null;
|
|
value = value * 256 + octet;
|
|
}
|
|
return value >>> 0;
|
|
}
|
|
|
|
/**
|
|
* Returns true when `address` matches `entry`. `entry` is a single IPv4 or IPv6
|
|
* address, or an IPv4 CIDR range. The function normalizes an IPv4-mapped IPv6
|
|
* peer (`::ffff:a.b.c.d`) to its IPv4 form first. It matches an IPv6 entry only
|
|
* by an exact, case-insensitive string, because the confidential allowlist
|
|
* expects a small set of known proxy addresses.
|
|
*/
|
|
function addressMatchesEntry(address: string, entry: string): boolean {
|
|
const peer = address.trim().toLowerCase();
|
|
const candidate = entry.trim().toLowerCase();
|
|
if (candidate.length === 0) return false;
|
|
|
|
const peerV4 = peer.startsWith("::ffff:") ? peer.slice("::ffff:".length) : peer;
|
|
|
|
if (candidate.includes("/")) {
|
|
const [network, prefixText] = candidate.split("/");
|
|
const prefix = Number(prefixText);
|
|
const networkInt = ipv4ToInt(network);
|
|
const peerInt = ipv4ToInt(peerV4);
|
|
if (networkInt === null || peerInt === null) return false;
|
|
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
|
|
if (prefix === 0) return true;
|
|
const mask = prefix === 32 ? 0xffffffff : (0xffffffff << (32 - prefix)) >>> 0;
|
|
return (networkInt & mask) === (peerInt & mask);
|
|
}
|
|
|
|
if (candidate === peer || candidate === peerV4) return true;
|
|
const candidateInt = ipv4ToInt(candidate);
|
|
const peerInt = ipv4ToInt(peerV4);
|
|
return candidateInt !== null && peerInt !== null && candidateInt === peerInt;
|
|
}
|
|
|
|
function peerMatchesAllowlist(address: string | undefined, allowlist: string[]): boolean {
|
|
if (!address) return false;
|
|
return allowlist.some((entry) => addressMatchesEntry(address, entry));
|
|
}
|
|
|
|
function forwardedProtoFirstHop(forwardedProto: string | undefined): string | null {
|
|
if (!forwardedProto) return null;
|
|
const first = forwardedProto.split(",")[0]?.trim().toLowerCase();
|
|
return first && first.length > 0 ? first : null;
|
|
}
|
|
|
|
/**
|
|
* Decides whether the request may receive a confidential response (the full
|
|
* login URL or the token). The guard fails closed. It never trusts a
|
|
* client-supplied header for the TLS decision unless the immediate peer is on
|
|
* the dedicated proxy allowlist. It never reads the global `trust proxy`
|
|
* setting.
|
|
*
|
|
* The guard allows a confidential response only in these cases:
|
|
* 1. The immediate socket is TLS. A direct TLS request is always valid (SR-6).
|
|
* 2. The deployment is `local_trusted` and the peer is loopback. This is the
|
|
* only local exception (SR-6).
|
|
* 3. The operator declared platform edge TLS termination
|
|
* (`edgeTlsTerminated`) and the edge does not label the client hop as
|
|
* plain `http`. The declaration is a deliberate, single-purpose operator
|
|
* statement about these routes; it is never derived from `TRUST_PROXY`
|
|
* (SR-7).
|
|
* 4. The peer is on the dedicated proxy allowlist and the forwarded protocol's
|
|
* first hop is `https`. A `TRUST_PROXY=true` or hop-count value does not
|
|
* reach this branch, because the guard never reads it (SR-7).
|
|
*
|
|
* Every other request fails closed and the route returns the fixed no-secret
|
|
* error.
|
|
*/
|
|
export function evaluateConfidentialTransport(
|
|
config: ConfidentialTransportConfig,
|
|
request: ConfidentialTransportRequest,
|
|
): ConfidentialTransportDecision {
|
|
if (request.socketEncrypted) {
|
|
return { allowed: true, reason: "direct_tls" };
|
|
}
|
|
if (config.deploymentMode === "local_trusted" && isLoopbackAddress(request.remoteAddress)) {
|
|
return { allowed: true, reason: "local_trusted_loopback" };
|
|
}
|
|
if (config.edgeTlsTerminated === true) {
|
|
// The declaration asserts the client hop is TLS for every request the
|
|
// platform admits. Believe the edge when it explicitly says otherwise: a
|
|
// first-hop `http` label means the platform accepted a plain-HTTP client
|
|
// connection, so that request still fails closed.
|
|
if (forwardedProtoFirstHop(request.forwardedProto) !== "http") {
|
|
return { allowed: true, reason: "operator_edge_tls_termination" };
|
|
}
|
|
return { allowed: false, reason: "edge_labeled_plain_http" };
|
|
}
|
|
if (
|
|
config.trustedProxies.length > 0 &&
|
|
peerMatchesAllowlist(request.remoteAddress, config.trustedProxies) &&
|
|
forwardedProtoFirstHop(request.forwardedProto) === "https"
|
|
) {
|
|
return { allowed: true, reason: "allowlisted_proxy_tls" };
|
|
}
|
|
return { allowed: false, reason: "insecure_transport" };
|
|
}
|
|
|
|
/**
|
|
* Assesses the confidential transport at startup (SR-7). The server disables
|
|
* proxy-forwarded confidential responses when the dedicated allowlist is empty
|
|
* and the operator has not declared platform edge TLS termination.
|
|
* A direct TLS request and a `local_trusted` loopback request still pass at
|
|
* runtime, because the runtime guard checks them first. The server logs the
|
|
* returned reason so an operator can see why forwarded requests fail closed.
|
|
*/
|
|
export function assessConfidentialStartup(config: ConfidentialTransportConfig): {
|
|
proxyForwardingEnabled: boolean;
|
|
reason: string;
|
|
} {
|
|
if (config.edgeTlsTerminated === true) {
|
|
return { proxyForwardingEnabled: true, reason: "edge_tls_termination_declared" };
|
|
}
|
|
if (config.trustedProxies.length > 0) {
|
|
return { proxyForwardingEnabled: true, reason: "proxy_allowlist_configured" };
|
|
}
|
|
return {
|
|
proxyForwardingEnabled: false,
|
|
reason:
|
|
config.deploymentMode === "local_trusted"
|
|
? "no_proxy_allowlist_local_trusted_loopback_only"
|
|
: "no_proxy_allowlist_direct_tls_only",
|
|
};
|
|
}
|
|
|
|
// --- The session service -----------------------------------------------------
|
|
|
|
/**
|
|
* A synchronous capacity hold for the enforced caps. The service reserves the
|
|
* 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.
|
|
*/
|
|
interface CapReservation {
|
|
released: boolean;
|
|
companyId: string;
|
|
// 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 {
|
|
id: string;
|
|
scope: SetupTokenSessionScope;
|
|
state: SetupTokenSessionState;
|
|
deadline: number;
|
|
lease: SetupTokenLease;
|
|
process: SetupTokenLoginProcess;
|
|
abort: AbortController;
|
|
// The full login URL. The service holds it in memory only and returns it only
|
|
// through the authorized owner read response (SR-5).
|
|
loginUrl: string | null;
|
|
// True after the owner-bound secret write succeeds. The service never holds the
|
|
// token: the credential sink writes it to the secret store and the service
|
|
// records only this non-secret marker.
|
|
secretStored: boolean;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
// The retention timer for a completed, stored session. The service arms it
|
|
// after a successful secret write and clears it when the owner reads the
|
|
// completion, so the in-memory session drops even if the owner never reads it.
|
|
retentionTimer: ReturnType<typeof setTimeout> | null;
|
|
cleanupDone: boolean;
|
|
// The per-session mutex tail. It serializes the owner-bound secret write
|
|
// against a cancel, an expiry, and the process-done transition, so a terminal
|
|
// transition and the write never interleave (complete mediation). The service
|
|
// holds this lock across the awaited secret write, so a cancel or an expiry
|
|
// that arrives during the write waits for the write to settle first.
|
|
lock: Promise<void>;
|
|
// The synchronous capacity hold for the enforced caps. The cleanup releases it
|
|
// exactly one time when the session reaches a terminal state.
|
|
reservation: CapReservation;
|
|
}
|
|
|
|
/** The public read view of a session prompt. */
|
|
export interface SetupTokenPromptView {
|
|
state: SetupTokenSessionState;
|
|
/** The full login URL, present only when the prompt has surfaced (SR-5). */
|
|
loginUrl: string | null;
|
|
}
|
|
|
|
/**
|
|
* The owner descriptor of a session. The company-and-environment routes read it
|
|
* to build the response contract. It carries the immutable environment and the
|
|
* session deadline, plus the current state and the full login URL. The route
|
|
* projects it: the public status response drops the login URL; the owner prompt
|
|
* response returns the login URL through the confidential transport guard.
|
|
*/
|
|
export interface SetupTokenSessionDescriptor {
|
|
sessionId: string;
|
|
state: SetupTokenSessionState;
|
|
environmentId: string;
|
|
/** The session deadline in epoch milliseconds. */
|
|
deadline: number;
|
|
/** The full login URL, present only after the prompt surfaces. */
|
|
loginUrl: string | null;
|
|
}
|
|
|
|
/**
|
|
* The completion contract for the authorized owner. It carries the non-secret
|
|
* `storedSessionId` claim and no token. The `storedSessionId` is the
|
|
* durable session id; the agent-create transaction consumes it as the one-time
|
|
* stored-session claim.
|
|
*/
|
|
export interface SetupTokenCompletionView {
|
|
storedSessionId: string;
|
|
}
|
|
|
|
export interface SetupTokenSessionServiceOptions {
|
|
factory: SetupTokenLoginProcessFactory;
|
|
leases: SetupTokenLeaseManager;
|
|
store: SetupTokenCleanupStore;
|
|
/**
|
|
* The owner-bound secret writer. The service awaits it one time when the login
|
|
* binds the token. It performs the compare-and-set. The service fails
|
|
* closed on a rejection.
|
|
*/
|
|
completeCredential: SetupTokenSecretWriter;
|
|
rateLimiter: SetupTokenRateLimiter;
|
|
caps?: SetupTokenSessionCaps;
|
|
ttlMs?: number;
|
|
/** The retention window for a completed token. Defaults to the constant. */
|
|
tokenRetentionMs?: number;
|
|
now?: () => number;
|
|
/** Returns an opaque, cryptographically random session id. */
|
|
generateSessionId?: () => string;
|
|
/** A non-leaking diagnostic sink. It receives only fixed status lines. */
|
|
log?: (line: string) => void;
|
|
}
|
|
|
|
function defaultSessionId(): string {
|
|
return randomBytes(32).toString("base64url");
|
|
}
|
|
|
|
/**
|
|
* The setup-token login session service. It holds every live session in memory
|
|
* and persists a non-secret cleanup record for each. It bounds active sessions
|
|
* per owner, per agent, and per company, and it rate-limits the start path.
|
|
*/
|
|
export class SetupTokenSessionService {
|
|
private readonly sessions = new Map<string, StoredSession>();
|
|
// 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;
|
|
private readonly store: SetupTokenCleanupStore;
|
|
private readonly completeCredential: SetupTokenSecretWriter;
|
|
private readonly rateLimiter: SetupTokenRateLimiter;
|
|
private readonly caps: SetupTokenSessionCaps;
|
|
private readonly ttlMs: number;
|
|
private readonly tokenRetentionMs: number;
|
|
private readonly now: () => number;
|
|
private readonly generateSessionId: () => string;
|
|
private readonly log: (line: string) => void;
|
|
|
|
constructor(options: SetupTokenSessionServiceOptions) {
|
|
this.factory = options.factory;
|
|
this.leases = options.leases;
|
|
this.store = options.store;
|
|
this.completeCredential = options.completeCredential;
|
|
this.rateLimiter = options.rateLimiter;
|
|
this.caps = options.caps ?? DEFAULT_SETUP_TOKEN_SESSION_CAPS;
|
|
this.ttlMs = options.ttlMs ?? DEFAULT_SETUP_TOKEN_SESSION_TTL_MS;
|
|
this.tokenRetentionMs = options.tokenRetentionMs ?? DEFAULT_SETUP_TOKEN_RETENTION_MS;
|
|
this.now = options.now ?? Date.now;
|
|
this.generateSessionId = options.generateSessionId ?? defaultSessionId;
|
|
this.log = options.log ?? (() => {});
|
|
}
|
|
|
|
/**
|
|
* Builds the durable-record identity for a session. The store matches every
|
|
* 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 {
|
|
sessionId: session.id,
|
|
companyId: session.scope.companyId,
|
|
ownerUserId: session.scope.ownerUserId,
|
|
adapterType: session.scope.adapterType,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Runs `fn` under the per-session lock. The lock serializes the owner-bound
|
|
* secret write against a cancel, an expiry, and the process-done transition, so
|
|
* a terminal transition and the write never interleave (complete mediation).
|
|
* The caller inside `fn` must not call another locked method, or it deadlocks;
|
|
* the failure path of {@link onCredential} calls {@link terminateLocked}, the
|
|
* lock-free variant, for this reason.
|
|
*/
|
|
private async withSessionLock<T>(session: StoredSession, fn: () => Promise<T>): Promise<T> {
|
|
const prior = session.lock;
|
|
let release: () => void = () => {};
|
|
session.lock = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
await prior;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
private countActive(predicate: (session: StoredSession) => boolean): number {
|
|
let count = 0;
|
|
for (const session of this.sessions.values()) {
|
|
if (!isTerminalSessionState(session.state) && predicate(session)) count += 1;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
private static incrementCount(counts: Map<string, number>, key: string): void {
|
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
}
|
|
|
|
private static decrementCount(counts: Map<string, number>, key: string): void {
|
|
const next = (counts.get(key) ?? 0) - 1;
|
|
if (next > 0) {
|
|
counts.set(key, next);
|
|
} else {
|
|
counts.delete(key);
|
|
}
|
|
}
|
|
|
|
/** 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 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 {
|
|
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.reservedBySlot, slotKey);
|
|
SetupTokenSessionService.incrementCount(this.reservedByCompany, scope.companyId);
|
|
return {
|
|
released: false,
|
|
companyId: scope.companyId,
|
|
slotKey,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
private releaseReservation(reservation: CapReservation): void {
|
|
if (reservation.released) return;
|
|
reservation.released = true;
|
|
SetupTokenSessionService.decrementCount(this.reservedBySlot, reservation.slotKey);
|
|
SetupTokenSessionService.decrementCount(this.reservedByCompany, reservation.companyId);
|
|
}
|
|
|
|
/**
|
|
* Starts a login session. It rate-limits the start, enforces the caps,
|
|
* acquires the lease with an external expiry no later than the deadline,
|
|
* persists the non-secret cleanup record, and starts the one live process.
|
|
* It returns the opaque session id and no token.
|
|
*/
|
|
async start(scope: SetupTokenSessionScope): Promise<{ sessionId: string; state: SetupTokenSessionState }> {
|
|
const rate = this.rateLimiter.consume(`${scope.companyId}:${scope.ownerUserId}`);
|
|
if (!rate.allowed) {
|
|
throw new SetupTokenSessionError(429, SETUP_TOKEN_RATE_LIMITED);
|
|
}
|
|
// Reserve the capacity synchronously before the first `await`. This closes
|
|
// the time-of-check/time-of-use window: the lease acquire, the durable write,
|
|
// and the factory creation all run after the reservation, so two concurrent
|
|
// starts for one owner cannot both pass the cap. The service releases the
|
|
// reservation on every early failure below and on the terminal cleanup.
|
|
const reservation = this.reserveCapacity(scope);
|
|
|
|
const sessionId = this.generateSessionId();
|
|
const deadline = this.now() + this.ttlMs;
|
|
|
|
let lease: SetupTokenLease;
|
|
try {
|
|
lease = await this.leases.acquire({ scope, deadline });
|
|
} catch (error) {
|
|
// The lease acquire failed before any durable state exists. Roll back the
|
|
// reservation and rethrow the original error.
|
|
this.releaseReservation(reservation);
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
await this.store.record({
|
|
sessionId,
|
|
companyId: scope.companyId,
|
|
ownerUserId: scope.ownerUserId,
|
|
adapterType: scope.adapterType,
|
|
environmentId: scope.environmentId,
|
|
leaseId: lease.id,
|
|
deadline,
|
|
state: "starting",
|
|
boundAt: null,
|
|
});
|
|
} catch (error) {
|
|
// The durable write failed, so no record exists for the reaper to read.
|
|
// Release the lease at once, roll back the reservation, and rethrow.
|
|
await this.releaseLeaseSafely(lease);
|
|
this.releaseReservation(reservation);
|
|
throw error;
|
|
}
|
|
|
|
const abort = new AbortController();
|
|
const session: StoredSession = {
|
|
id: sessionId,
|
|
scope,
|
|
state: "starting",
|
|
deadline,
|
|
lease,
|
|
// The factory replaces this placeholder synchronously below.
|
|
process: undefined as unknown as SetupTokenLoginProcess,
|
|
abort,
|
|
loginUrl: null,
|
|
secretStored: false,
|
|
timer: null,
|
|
retentionTimer: null,
|
|
cleanupDone: false,
|
|
lock: Promise.resolve(),
|
|
reservation,
|
|
};
|
|
|
|
let process: SetupTokenLoginProcess;
|
|
try {
|
|
process = this.factory({
|
|
scope,
|
|
onPrompt: (prompt) => this.onPrompt(session, prompt),
|
|
onCredential: (token) => this.onCredential(session, token),
|
|
timeoutMs: this.ttlMs,
|
|
signal: abort.signal,
|
|
});
|
|
} catch {
|
|
// The factory could not start the process. Release the lease, drop the
|
|
// durable record, roll back the reservation, then return a fixed,
|
|
// non-secret error.
|
|
await this.releaseLeaseSafely(lease);
|
|
await this.store
|
|
.remove({
|
|
sessionId,
|
|
companyId: scope.companyId,
|
|
ownerUserId: scope.ownerUserId,
|
|
adapterType: scope.adapterType,
|
|
})
|
|
.catch(() => {});
|
|
this.releaseReservation(reservation);
|
|
throw new SetupTokenSessionError(503, SETUP_TOKEN_START_FAILED);
|
|
}
|
|
|
|
session.process = process;
|
|
session.timer = setTimeout(() => {
|
|
void this.expireInternal(sessionId);
|
|
}, this.ttlMs);
|
|
// A timer must never keep the process alive on its own.
|
|
if (typeof session.timer.unref === "function") session.timer.unref();
|
|
|
|
this.sessions.set(sessionId, session);
|
|
// The process outcome drives the terminal state and the cleanup.
|
|
void process.done.then(
|
|
(outcome) => this.onProcessDone(session, outcome),
|
|
() => this.onProcessDone(session, "failure"),
|
|
);
|
|
|
|
return { sessionId, state: session.state };
|
|
}
|
|
|
|
private onPrompt(session: StoredSession, prompt: { url: string }): void {
|
|
if (isTerminalSessionState(session.state)) return;
|
|
// Hold the full URL in memory only. Never log it (SR-1, SR-5).
|
|
session.loginUrl = prompt.url;
|
|
if (session.state === "starting") {
|
|
session.state = "awaiting_code";
|
|
void this.store.markState(this.identityOf(session), "awaiting_code").catch(() => {});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Receives the minted token from the login process and writes it to the
|
|
* owner-bound secret. The service awaits the compare-and-set. The
|
|
* service never stores the token: it passes the token to the writer, then it
|
|
* drops the reference.
|
|
*
|
|
* The service runs the whole sink under the per-session lock, so a cancel or an
|
|
* expiry cannot interleave with the write. A cancel or an expiry that wins the
|
|
* lock first sets a terminal state; the service then writes no secret and fails
|
|
* closed with the fixed, non-secret error. This is complete mediation: a
|
|
* terminal session never commits a secret write.
|
|
*
|
|
* The writer runs one control-plane transaction. Inside it, the writer first
|
|
* transitions the exact durable row to `stored` with a full-scope conditional
|
|
* update, then writes or rotates the 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.
|
|
* The service does not mark the durable row separately; the writer owns the
|
|
* transition.
|
|
*
|
|
* On a successful write the service records the non-secret `secretStored`
|
|
* marker. The process outcome then moves the session to `completed`. A cancel or
|
|
* an expiry that arrives after the write committed sees the `secretStored`
|
|
* marker and reports the completed state; it does not erase the claim (see
|
|
* {@link terminateLocked}).
|
|
*
|
|
* On a zero-row transition or on a storage failure the writer rolls back the
|
|
* whole transaction and rejects. The service then fails closed: it moves the
|
|
* session to `failed`, keeps no secret and no claim, and rejects with a fixed,
|
|
* non-secret error. The factory (or the runner it wraps) awaits this sink, so
|
|
* the rejection ends the login as a failure and the process never reports
|
|
* success.
|
|
*/
|
|
private async onCredential(session: StoredSession, token: string): Promise<void> {
|
|
await this.withSessionLock(session, async () => {
|
|
// A cancel or an expiry won the lock first and set a terminal state. Do not
|
|
// write the secret for a terminal session. Fail closed with the fixed,
|
|
// non-secret error, so the runner ends the run as a failure.
|
|
if (isTerminalSessionState(session.state)) {
|
|
throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED);
|
|
}
|
|
try {
|
|
// The writer transitions the durable row to `stored` and writes the
|
|
// secret in one transaction. A zero-row transition or a storage failure
|
|
// rejects and rolls back both.
|
|
await this.completeCredential({ scope: session.scope, sessionId: session.id, token });
|
|
} catch {
|
|
await this.terminateLocked(session, "failed");
|
|
throw new SetupTokenSessionError(500, SETUP_TOKEN_STORAGE_FAILED);
|
|
}
|
|
// The transaction committed while the service held the lock, so a cancel or
|
|
// an expiry could not interleave. Record the non-secret marker. The durable
|
|
// row is already `stored` from the committed transition.
|
|
session.secretStored = true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Resolves a session for an operation. It returns the session only when the
|
|
* id exists and the stored scope equals the caller scope in all three 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);
|
|
if (
|
|
!session ||
|
|
session.scope.companyId !== scope.companyId ||
|
|
session.scope.ownerUserId !== scope.ownerUserId ||
|
|
session.scope.adapterType !== scope.adapterType
|
|
) {
|
|
throw new SetupTokenSessionError(404, SETUP_TOKEN_SESSION_NOT_FOUND);
|
|
}
|
|
return session;
|
|
}
|
|
|
|
/**
|
|
* 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, 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.
|
|
*/
|
|
resolveCompanyScope(
|
|
sessionId: string,
|
|
key: { companyId: string; ownerUserId: string; adapterType: string },
|
|
): SetupTokenSessionScope {
|
|
const session = this.sessions.get(sessionId);
|
|
if (
|
|
!session ||
|
|
session.scope.companyId !== key.companyId ||
|
|
session.scope.ownerUserId !== key.ownerUserId ||
|
|
session.scope.adapterType !== key.adapterType
|
|
) {
|
|
throw new SetupTokenSessionError(404, SETUP_TOKEN_SESSION_NOT_FOUND);
|
|
}
|
|
return session.scope;
|
|
}
|
|
|
|
/**
|
|
* Returns the owner descriptor for a session. The company-and-environment
|
|
* routes read it to build the response contract. The scope check runs first,
|
|
* so a cross-scope caller gets the same not-found error. The route
|
|
* projects the descriptor: the public status response drops the login URL; the
|
|
* owner prompt response returns the login URL behind the transport guard.
|
|
*/
|
|
describeOwned(sessionId: string, scope: SetupTokenSessionScope): SetupTokenSessionDescriptor {
|
|
const session = this.resolveOwned(sessionId, scope);
|
|
return {
|
|
sessionId: session.id,
|
|
state: session.state,
|
|
environmentId: session.scope.environmentId,
|
|
deadline: session.deadline,
|
|
loginUrl: session.loginUrl,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the prompt view for the authorized owner. The full login URL rides
|
|
* only in this response (SR-5). The route sets `Cache-Control: no-store` and
|
|
* applies the confidential transport guard before it calls this method.
|
|
*/
|
|
readPrompt(sessionId: string, scope: SetupTokenSessionScope): SetupTokenPromptView {
|
|
const session = this.resolveOwned(sessionId, scope);
|
|
return { state: session.state, loginUrl: session.loginUrl };
|
|
}
|
|
|
|
/**
|
|
* Submits the one browser code. It performs the single compare-and-set from
|
|
* `awaiting_code` to `submitting`, then forwards the code to the live process
|
|
* one time. It rejects every later submit, including one after an
|
|
* invalid-code retry (SR-3).
|
|
*/
|
|
submitCode(sessionId: string, scope: SetupTokenSessionScope, code: string): { state: SetupTokenSessionState } {
|
|
const session = this.resolveOwned(sessionId, scope);
|
|
if (session.state !== "awaiting_code") {
|
|
throw new SetupTokenSessionError(409, SETUP_TOKEN_SUBMIT_CONFLICT);
|
|
}
|
|
session.state = "submitting";
|
|
void this.store.markState(this.identityOf(session), "submitting").catch(() => {});
|
|
session.process.submitCode(code);
|
|
return { state: session.state };
|
|
}
|
|
|
|
/**
|
|
* Cancels a session. It stops the direct child before it releases the lease.
|
|
* It is idempotent: a cancel on a terminal session returns the terminal state.
|
|
*/
|
|
async cancel(sessionId: string, scope: SetupTokenSessionScope): Promise<{ state: SetupTokenSessionState }> {
|
|
const session = this.resolveOwned(sessionId, scope);
|
|
if (isTerminalSessionState(session.state)) {
|
|
return { state: session.state };
|
|
}
|
|
await this.terminate(session, "cancelled");
|
|
return { state: session.state };
|
|
}
|
|
|
|
/**
|
|
* Expires a session on a timeout. It stops the direct child before it releases
|
|
* the lease. The harness can call it, and the deadline timer calls the same
|
|
* path internally.
|
|
*/
|
|
async expire(sessionId: string, scope: SetupTokenSessionScope): Promise<{ state: SetupTokenSessionState }> {
|
|
const session = this.resolveOwned(sessionId, scope);
|
|
if (isTerminalSessionState(session.state)) {
|
|
return { state: session.state };
|
|
}
|
|
await this.terminate(session, "timed_out");
|
|
return { state: session.state };
|
|
}
|
|
|
|
private async expireInternal(sessionId: string): Promise<void> {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session || isTerminalSessionState(session.state)) return;
|
|
await this.terminate(session, "timed_out");
|
|
}
|
|
|
|
/**
|
|
* Returns the completion contract for the authorized owner. It returns the
|
|
* non-secret `storedSessionId` claim only from a completed session whose
|
|
* owner-bound secret write succeeded. It returns no token. It
|
|
* returns the fixed unavailable error when the session is not completed with a
|
|
* stored secret. The scope check runs first, so a cross-scope caller gets the
|
|
* same not-found error, not the unavailable error.
|
|
*
|
|
* The read leaves the durable row in place as the one-time stored-session
|
|
* claim; the agent-create transaction consumes it. The route sets
|
|
* `Cache-Control: no-store` before it calls this method.
|
|
*/
|
|
completeSession(sessionId: string, scope: SetupTokenSessionScope): SetupTokenCompletionView {
|
|
const session = this.resolveOwned(sessionId, scope);
|
|
if (session.state !== "completed" || !session.secretStored) {
|
|
throw new SetupTokenSessionError(409, SETUP_TOKEN_TOKEN_UNAVAILABLE);
|
|
}
|
|
return { storedSessionId: session.id };
|
|
}
|
|
|
|
/**
|
|
* Stops the direct child, then runs the idempotent cleanup under the
|
|
* per-session lock. The lock serializes the terminal transition against the
|
|
* owner-bound secret write, so a cancel or an expiry never interleaves with the
|
|
* write (complete mediation). The order stops the child before the lease
|
|
* release on every terminal path.
|
|
*/
|
|
private async terminate(session: StoredSession, state: SetupTokenSessionState): Promise<void> {
|
|
await this.withSessionLock(session, () => this.terminateLocked(session, state));
|
|
}
|
|
|
|
/**
|
|
* The lock-free terminal transition. The caller must already hold the
|
|
* per-session lock. The failure path of {@link onCredential} calls it directly,
|
|
* because that path already holds the lock.
|
|
*
|
|
* If the owner-bound secret write already committed, the delivery won the
|
|
* serialized ordering. The service completes the session and keeps the stored
|
|
* claim; it does not cancel, expire, or erase the claim. This makes the
|
|
* terminal API report the completed state after a successful write, rather than
|
|
* erase it.
|
|
*/
|
|
private async terminateLocked(session: StoredSession, state: SetupTokenSessionState): Promise<void> {
|
|
if (isTerminalSessionState(session.state)) return;
|
|
const resolved: SetupTokenSessionState = session.secretStored ? "completed" : state;
|
|
session.state = resolved;
|
|
session.abort.abort();
|
|
try {
|
|
session.process.stop();
|
|
} catch {
|
|
this.log("[paperclip] Setup-token session: the process stop step errored.");
|
|
}
|
|
await this.runCleanup(session, resolved);
|
|
}
|
|
|
|
/**
|
|
* Maps the live process outcome to the terminal state and runs the cleanup
|
|
* under the per-session lock. A cancel or an expire already set the state, so
|
|
* this call is a no-op then.
|
|
*/
|
|
private async onProcessDone(session: StoredSession, outcome: SetupTokenLoginOutcome): Promise<void> {
|
|
await this.withSessionLock(session, async () => {
|
|
if (isTerminalSessionState(session.state) && session.cleanupDone) return;
|
|
const state: SetupTokenSessionState =
|
|
outcome === "success"
|
|
? "completed"
|
|
: outcome === "timeout"
|
|
? "timed_out"
|
|
: outcome === "cancelled"
|
|
? "cancelled"
|
|
: "failed";
|
|
if (!isTerminalSessionState(session.state)) {
|
|
session.state = state;
|
|
}
|
|
await this.runCleanup(session, session.state);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Runs the idempotent cleanup for a terminal session: clear the timer, mark
|
|
* the durable record terminal, release the lease, and drop the in-memory
|
|
* session. It clears the in-memory login URL reference promptly (SR-3, SR-5).
|
|
* A cleanup failure stays retryable and records no process output (SR-4).
|
|
*
|
|
* A completed session with a stored secret keeps its durable row as the
|
|
* one-time stored-session claim: the cleanup marks no terminal state and does
|
|
* not remove the row. The agent-create transaction consumes the claim, or the
|
|
* reaper removes the row after the deadline. The cleanup still releases the
|
|
* sandbox lease at once. It retains the in-memory session for the retention
|
|
* window, so the owner can read the completion once.
|
|
*/
|
|
private async runCleanup(session: StoredSession, state: SetupTokenSessionState): Promise<void> {
|
|
if (session.cleanupDone) return;
|
|
session.cleanupDone = true;
|
|
// Release the capacity reservation as the session reaches a terminal state.
|
|
// A terminal session no longer counts against a cap, so the owner regains the
|
|
// slot at once, even while a completed session lingers for the retention read.
|
|
this.releaseReservation(session.reservation);
|
|
if (session.timer) {
|
|
clearTimeout(session.timer);
|
|
session.timer = null;
|
|
}
|
|
// Clear the secret-bearing reference promptly. Best-effort only (SR-5).
|
|
session.loginUrl = null;
|
|
const storedClaim = state === "completed" && session.secretStored;
|
|
if (!storedClaim) {
|
|
try {
|
|
await this.store.markState(this.identityOf(session), state);
|
|
} catch {
|
|
this.log("[paperclip] Setup-token session: the cleanup record update failed; it stays retryable.");
|
|
}
|
|
}
|
|
await this.releaseLeaseSafely(session.lease);
|
|
if (storedClaim) {
|
|
// Keep the durable row as the stored-session claim. Retain the in-memory
|
|
// session for the owner to read the completion one time.
|
|
session.retentionTimer = setTimeout(() => this.purgeRetained(session.id), this.tokenRetentionMs);
|
|
if (typeof session.retentionTimer.unref === "function") session.retentionTimer.unref();
|
|
return;
|
|
}
|
|
try {
|
|
await this.store.remove(this.identityOf(session));
|
|
} catch {
|
|
this.log("[paperclip] Setup-token session: the cleanup record removal failed; it stays retryable.");
|
|
}
|
|
this.sessions.delete(session.id);
|
|
}
|
|
|
|
/**
|
|
* Purges a retained completed session from memory. It clears the retention
|
|
* timer and drops the in-memory session. The durable stored-session claim
|
|
* stays in the store for the create flow or the reaper.
|
|
*/
|
|
private purgeRetained(sessionId: string): void {
|
|
const session = this.sessions.get(sessionId);
|
|
if (!session) return;
|
|
if (session.retentionTimer) {
|
|
clearTimeout(session.retentionTimer);
|
|
session.retentionTimer = null;
|
|
}
|
|
this.sessions.delete(sessionId);
|
|
}
|
|
|
|
private async releaseLeaseSafely(lease: SetupTokenLease): Promise<void> {
|
|
try {
|
|
await this.leases.release(lease);
|
|
} catch {
|
|
// The lease release stays retryable and alertable. The startup reaper
|
|
// releases any lease that a crash or a failure left behind.
|
|
this.log("[paperclip] Setup-token session: the lease release failed; the reaper retries it.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The startup reaper. It reads the durable store and releases any lease whose
|
|
* session is terminal or past its deadline. It runs after a restart, so it
|
|
* frees a lease that a crash left behind (SR-4). A release failure stays
|
|
* retryable: the reaper leaves the record for a later run. The standalone
|
|
* scheduled reaper calls the same {@link reapSetupTokenLeases} logic.
|
|
*/
|
|
async reap(now: number = this.now()): Promise<SetupTokenReapResult> {
|
|
return reapSetupTokenLeases({ store: this.store, leases: this.leases, log: this.log }, now);
|
|
}
|
|
|
|
/**
|
|
* The graceful-shutdown cleanup. It cancels every live session, so the server
|
|
* stops each direct child before it releases each lease (SR-4).
|
|
*/
|
|
async shutdown(): Promise<void> {
|
|
const live = [...this.sessions.values()].filter((s) => !isTerminalSessionState(s.state));
|
|
for (const session of live) {
|
|
await this.terminate(session, "cancelled");
|
|
}
|
|
}
|
|
|
|
/** Returns the count of live sessions. The route and the tests use it. */
|
|
activeSessionCount(): number {
|
|
return this.countActive(() => true);
|
|
}
|
|
}
|
|
|
|
// --- 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 AdapterAuthSessionRow = typeof adapterAuthSessions.$inferSelect;
|
|
|
|
/** Maps a database row to the non-secret cleanup record. */
|
|
function toCleanupRecord(row: AdapterAuthSessionRow): SetupTokenCleanupRecord {
|
|
return {
|
|
sessionId: row.publicSessionId,
|
|
companyId: row.companyId,
|
|
ownerUserId: row.startedByUserId,
|
|
adapterType: row.adapterType,
|
|
environmentId: row.environmentId,
|
|
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 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(adapterAuthSessions.publicSessionId, identity.sessionId),
|
|
eq(adapterAuthSessions.companyId, identity.companyId),
|
|
eq(adapterAuthSessions.startedByUserId, identity.ownerUserId),
|
|
eq(adapterAuthSessions.adapterType, identity.adapterType as AgentAdapterType),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The live pre-`stored` states a session may hold when the credential arrives.
|
|
* The atomic claim writer transitions a row to `stored` only from one of these
|
|
* states. It never transitions a terminal, an already-`stored`, or a foreign row.
|
|
*/
|
|
export const SETUP_TOKEN_STORED_PREDECESSOR_STATES: readonly SetupTokenSessionState[] = [
|
|
"starting",
|
|
"awaiting_code",
|
|
"submitting",
|
|
];
|
|
|
|
/**
|
|
* Transitions the exact session row to `stored` with one conditional update. The
|
|
* predicate matches the full owner scope and the session id, an allowed
|
|
* predecessor state, an unexpired deadline against `clock_timestamp()`, and an
|
|
* unconsumed `bound_at`. It uses `UPDATE … RETURNING` and returns true only when
|
|
* exactly one row transitions.
|
|
*
|
|
* The atomic claim writer runs this update on the same transaction handle as the
|
|
* secret write, so the commit establishes the `stored` claim and the encrypted
|
|
* secret together. A zero-row result rolls back the whole transaction, so a
|
|
* stale, expired, terminal, foreign-scope, or already-bound row writes no
|
|
* secret.
|
|
*/
|
|
export async function transitionSetupTokenSessionToStored(
|
|
executor: Db,
|
|
identity: SetupTokenCleanupIdentity,
|
|
): Promise<boolean> {
|
|
const changed = await executor
|
|
.update(adapterAuthSessions)
|
|
.set({ status: "stored", updatedAt: sql`clock_timestamp()` })
|
|
.where(
|
|
and(
|
|
setupTokenScopeMatch(identity),
|
|
inArray(adapterAuthSessions.status, [...SETUP_TOKEN_STORED_PREDECESSOR_STATES]),
|
|
gt(adapterAuthSessions.expiresAt, sql`clock_timestamp()`),
|
|
isNull(adapterAuthSessions.boundAt),
|
|
),
|
|
)
|
|
.returning();
|
|
return changed.length === 1;
|
|
}
|
|
|
|
/**
|
|
* Builds the durable, database-backed cleanup store. It persists only the
|
|
* non-secret record. Every write matches the full owner scope and the session
|
|
* id, so no write updates a row by the session id alone. The
|
|
* claim-consumption write folds the state check, the deadline check, and the
|
|
* consumption into one conditional write.
|
|
*/
|
|
export function createDbSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore {
|
|
// The store keys every scoped write on the full owner scope plus the session id.
|
|
const scopeMatch = setupTokenScopeMatch;
|
|
|
|
return {
|
|
async record(record): Promise<void> {
|
|
// 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,
|
|
environmentId: record.environmentId,
|
|
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 company, the owner, and the
|
|
// adapter, so a write never updates a row by the session id alone.
|
|
await db
|
|
.update(adapterAuthSessions)
|
|
.set({ status: state, updatedAt: sql`clock_timestamp()` })
|
|
.where(scopeMatch(identity));
|
|
},
|
|
|
|
async remove(identity): Promise<void> {
|
|
// 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 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(adapterAuthSessions)
|
|
.where(
|
|
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 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(adapterAuthSessions)
|
|
.set({ boundAt: sql`clock_timestamp()`, updatedAt: sql`clock_timestamp()` })
|
|
.where(
|
|
and(
|
|
scopeMatch(identity),
|
|
eq(adapterAuthSessions.status, "stored"),
|
|
gt(adapterAuthSessions.expiresAt, sql`clock_timestamp()`),
|
|
isNull(adapterAuthSessions.boundAt),
|
|
),
|
|
)
|
|
.returning();
|
|
const row = changed[0];
|
|
return row ? toCleanupRecord(row) : null;
|
|
},
|
|
};
|
|
}
|