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:
parent
61b4fc02d4
commit
e0b64529b3
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@
|
|||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "^0.66.0",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"dependencies": {
|
||||
"@agentclientprotocol/codex-acp": "^1.2.0",
|
||||
"@paperclipai/adapter-utils": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
);
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ import {
|
|||
const SCOPE: SetupTokenSessionScope = {
|
||||
companyId: "company-1",
|
||||
ownerUserId: "user-1",
|
||||
targetAgentId: null,
|
||||
adapterType: "claude_local",
|
||||
environmentId: "env-1",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue