1154 lines
44 KiB
TypeScript
1154 lines
44 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { adapterAuthSessions, companies, createDb, environments } from "@paperclipai/db";
|
|
import type { AgentAdapterType } from "@paperclipai/shared";
|
|
import { DEVICE_LOGIN_URL } from "@paperclipai/adapter-codex-local/server";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import {
|
|
AdapterAuthSessionConflictError,
|
|
buildSandboxLoginDriver,
|
|
CODEX_DEVICE_LOGIN_TIMEOUT_MS,
|
|
createCodexDeviceLoginService,
|
|
createDbAdapterAuthSessionStore,
|
|
sessionCodexHomePath,
|
|
sessionCredentialPath,
|
|
type AcquireLoginLeaseInput,
|
|
type AdapterAuthSessionRow,
|
|
type AdapterAuthSessionStore,
|
|
type CredentialPromotion,
|
|
type LoginSessionActivityEvent,
|
|
type LoginSessionLease,
|
|
type LoginSessionRuntime,
|
|
type SandboxDeleteResult,
|
|
} from "../services/codex-device-login-service.ts";
|
|
import {
|
|
createCodexDeviceLoginReaper,
|
|
type LoginSessionCleanupRuntime,
|
|
} from "../services/codex-device-login-reaper.ts";
|
|
|
|
// A cleanup runtime for the reaper. It confirms every delete and reports no
|
|
// tagged lease, so the reaper only reclaims the seeded session rows.
|
|
function createReaperRuntime() {
|
|
const deletes: string[] = [];
|
|
const runtime: LoginSessionCleanupRuntime = {
|
|
async deleteSandbox(ref) {
|
|
deletes.push(ref.providerLeaseId);
|
|
return { outcome: "deleted" };
|
|
},
|
|
async listTaggedLeases() {
|
|
return [];
|
|
},
|
|
};
|
|
return { runtime, deletes };
|
|
}
|
|
|
|
// A passing promotion for the lifecycle tests. The mandatory promotion is a
|
|
// required dependency, so a test that does not exercise the credential write
|
|
// still supplies a promotion that accepts the credential.
|
|
const passingPromotion: CredentialPromotion = { promote: () => {} };
|
|
|
|
// Build the service with the passing promotion by default. A test that checks
|
|
// the promotion path passes its own `promotion` to override the default.
|
|
type ServiceDeps = Parameters<typeof createCodexDeviceLoginService>[0];
|
|
function makeService(deps: Omit<ServiceDeps, "promotion"> & { promotion?: CredentialPromotion }) {
|
|
return createCodexDeviceLoginService({ promotion: passingPromotion, ...deps });
|
|
}
|
|
|
|
const ADAPTER_TYPE: AgentAdapterType = "codex_local";
|
|
const OWNER_A = "user-a";
|
|
const OWNER_B = "user-b";
|
|
|
|
// Poll the store until the row reaches the wanted status. The login run writes
|
|
// 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,
|
|
status: AdapterAuthSessionRow["status"],
|
|
): Promise<void> {
|
|
for (let attempt = 0; attempt < 1000; attempt += 1) {
|
|
const row = await store.get(sessionId);
|
|
if (row?.status === status) return;
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
throw new Error(`the session did not reach status ${status}`);
|
|
}
|
|
|
|
// A valid device-login output. The parser accepts the exact URL and a code of
|
|
// four characters, a hyphen, and five characters, on a dedicated line after the
|
|
// "one-time code" preamble.
|
|
const PROMPT_OUTPUT = `Open ${DEVICE_LOGIN_URL} in your browser.\nEnter the one-time code below:\nABCD-EFGHI\n`;
|
|
const PROMPT_CODE = "ABCD-EFGHI";
|
|
|
|
type ExecController = { onStdout: (chunk: string) => void; input: AcquireLoginLeaseInput };
|
|
type ExecBehavior = (c: ExecController) => Promise<{ exitCode: number | null }>;
|
|
|
|
const execSuccess: ExecBehavior = async ({ onStdout }) => {
|
|
onStdout(PROMPT_OUTPUT);
|
|
return { exitCode: 0 };
|
|
};
|
|
|
|
const execFailure: ExecBehavior = async ({ onStdout }) => {
|
|
onStdout(PROMPT_OUTPUT);
|
|
return { exitCode: 1 };
|
|
};
|
|
|
|
const execDriverError: ExecBehavior = async ({ onStdout }) => {
|
|
onStdout(PROMPT_OUTPUT);
|
|
throw new Error("driver stream errored");
|
|
};
|
|
|
|
// Emit the prompt, then never resolve. The host timeout or the cancellation
|
|
// signal ends the run.
|
|
const execHang: ExecBehavior = ({ onStdout }) => {
|
|
onStdout(PROMPT_OUTPUT);
|
|
return new Promise<{ exitCode: number | null }>(() => {});
|
|
};
|
|
|
|
interface FakeRuntimeOptions {
|
|
exec: ExecBehavior;
|
|
authBytes?: Buffer;
|
|
delete?: () => Promise<SandboxDeleteResult>;
|
|
}
|
|
|
|
function createFakeRuntime(opts: FakeRuntimeOptions) {
|
|
const acquisitions: AcquireLoginLeaseInput[] = [];
|
|
const deleteCalls: string[] = [];
|
|
const releaseCalls: string[] = [];
|
|
const deleteImpl = opts.delete ?? (async (): Promise<SandboxDeleteResult> => ({ outcome: "deleted" }));
|
|
const runtime: LoginSessionRuntime = {
|
|
async acquireLoginLease(input) {
|
|
acquisitions.push(input);
|
|
const lease: LoginSessionLease = {
|
|
providerLeaseId: `lease-${input.sessionId}`,
|
|
authPath: sessionCredentialPath(input.sessionId),
|
|
driver: {
|
|
execStreaming: (_command, onStdout) => opts.exec({ onStdout, input }),
|
|
readFile: async () => opts.authBytes ?? Buffer.from("{}"),
|
|
dispose: async () => {},
|
|
},
|
|
deleteSandbox: async () => {
|
|
deleteCalls.push(input.sessionId);
|
|
return await deleteImpl();
|
|
},
|
|
release: async () => {
|
|
releaseCalls.push(input.sessionId);
|
|
},
|
|
};
|
|
return lease;
|
|
},
|
|
};
|
|
return { runtime, acquisitions, deleteCalls, releaseCalls };
|
|
}
|
|
|
|
// An in-memory store. It mimics the active company-adapter slot, so most tests
|
|
// run with no database. The concurrency test uses the database-backed store, so
|
|
// the real partial unique index maps the conflict to a 409.
|
|
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}`;
|
|
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);
|
|
if (activeSlots.has(key)) throw new AdapterAuthSessionConflictError();
|
|
activeSlots.add(key);
|
|
rows.set(input.id, {
|
|
id: input.id,
|
|
companyId: input.companyId,
|
|
environmentId: input.environmentId,
|
|
adapterType: input.adapterType,
|
|
startedByUserId: input.startedByUserId,
|
|
providerLeaseId: null,
|
|
status: "starting",
|
|
expiresAt: input.expiresAt,
|
|
promotionExpiresAt: null,
|
|
finishedAt: null,
|
|
failureReason: null,
|
|
});
|
|
},
|
|
async recordLeaseAcquired(input) {
|
|
const row = rows.get(input.sessionId);
|
|
if (row) row.providerLeaseId = input.providerLeaseId;
|
|
},
|
|
async setStatus(input) {
|
|
const row = rows.get(input.sessionId);
|
|
if (!row) return;
|
|
row.status = input.status;
|
|
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));
|
|
},
|
|
async compareAndSetStatus(input) {
|
|
const row = rows.get(input.sessionId);
|
|
if (!row || !input.expectedStatuses.includes(row.status)) return false;
|
|
row.status = input.status;
|
|
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));
|
|
return true;
|
|
},
|
|
async get(sessionId) {
|
|
const row = rows.get(sessionId);
|
|
return row ? { ...row } : null;
|
|
},
|
|
async withCompanyAdapterPromotionLock(_companyId, _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();
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("codex device login service", () => {
|
|
it("inserts the session row before it acquires the lease", async () => {
|
|
const store = createMemoryStore();
|
|
let rowPresentAtAcquire = false;
|
|
const runtime: LoginSessionRuntime = {
|
|
async acquireLoginLease(input) {
|
|
rowPresentAtAcquire = (await store.get(input.sessionId)) !== null;
|
|
return {
|
|
providerLeaseId: `lease-${input.sessionId}`,
|
|
authPath: sessionCredentialPath(input.sessionId),
|
|
driver: {
|
|
execStreaming: (_command, onStdout) => execSuccess({ onStdout, input }),
|
|
readFile: async () => Buffer.from("{}"),
|
|
dispose: async () => {},
|
|
},
|
|
deleteSandbox: async () => ({ outcome: "deleted" }),
|
|
release: async () => {},
|
|
};
|
|
},
|
|
};
|
|
const service = makeService({ store, runtime });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
expect(rowPresentAtAcquire).toBe(true);
|
|
expect(session.status).toBe("starting");
|
|
expect(session.expiresAt).not.toBeNull();
|
|
await completed;
|
|
});
|
|
|
|
it("delivers the prompt to the owner, promotes, deletes the sandbox, and authenticates", async () => {
|
|
const store = createMemoryStore();
|
|
const activity: LoginSessionActivityEvent[] = [];
|
|
const promoted: Buffer[] = [];
|
|
const promotionContexts: { sessionId: string; companyId: string }[] = [];
|
|
const { runtime, deleteCalls } = createFakeRuntime({
|
|
exec: execSuccess,
|
|
authBytes: Buffer.from('{"token":"secret"}'),
|
|
});
|
|
const companyId = randomUUID();
|
|
const service = makeService({
|
|
store,
|
|
runtime,
|
|
recordActivity: (event) => activity.push(event),
|
|
promotion: {
|
|
promote: (bytes, context) => {
|
|
promoted.push(bytes);
|
|
promotionContexts.push({ sessionId: context.sessionId, companyId: context.companyId });
|
|
},
|
|
},
|
|
});
|
|
const { session, completed } = await service.start({
|
|
companyId,
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
|
|
// 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 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);
|
|
expect(owner?.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
|
|
|
// A non-owner never reads the prompt.
|
|
const other = await service.readOwnerSession(session.sessionId, OWNER_B);
|
|
expect(other?.prompt).toBeNull();
|
|
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("authenticated");
|
|
expect(outcome.cleanupPending).toBe(false);
|
|
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 prompt is one-time: a second owner read returns null.
|
|
const secondRead = await service.readOwnerSession(session.sessionId, OWNER_A);
|
|
expect(secondRead?.prompt).toBeNull();
|
|
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("authenticated");
|
|
expect(row?.finishedAt).not.toBeNull();
|
|
|
|
// Every activity record carries only non-secret fields.
|
|
expect(activity.length).toBeGreaterThan(0);
|
|
for (const event of activity) {
|
|
expect(Object.keys(event).sort()).toEqual([
|
|
"adapterType",
|
|
"companyId",
|
|
"environmentId",
|
|
"phase",
|
|
"sessionId",
|
|
]);
|
|
}
|
|
// The prompt phase records the surface, not the URL or the code.
|
|
expect(activity.map((event) => event.phase)).toContain("prompt_surfaced");
|
|
expect(JSON.stringify(activity)).not.toContain(DEVICE_LOGIN_URL);
|
|
expect(JSON.stringify(activity)).not.toContain(PROMPT_CODE);
|
|
});
|
|
|
|
it("fails closed and never promotes when a success outcome carries no credential", async () => {
|
|
const store = createMemoryStore();
|
|
let promoteCalls = 0;
|
|
const { runtime, deleteCalls } = createFakeRuntime({
|
|
// The login command exits 0, but the sandbox produced no credential bytes.
|
|
exec: execSuccess,
|
|
authBytes: Buffer.alloc(0),
|
|
});
|
|
const service = makeService({
|
|
store,
|
|
runtime,
|
|
promotion: {
|
|
promote: () => {
|
|
promoteCalls += 1;
|
|
},
|
|
},
|
|
});
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
const outcome = await completed;
|
|
// A success outcome with no credential never authenticates and never runs
|
|
// the promotion write. The service still deletes the sandbox.
|
|
expect(outcome.status).toBe("failed");
|
|
expect(promoteCalls).toBe(0);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("failed");
|
|
const failed = await service.readOwnerSession(session.sessionId, OWNER_A);
|
|
expect(failed?.failure?.reason).toBe("promotion_failed");
|
|
});
|
|
|
|
it("deletes the sandbox and records a failed terminal on a non-zero exit", async () => {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({ exec: execFailure });
|
|
const service = makeService({ store, runtime });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("failed");
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const failed = await service.readOwnerSession(session.sessionId, OWNER_A);
|
|
expect(failed?.status).toBe("failed");
|
|
expect(failed?.failure?.reason).toBe("login_command_failed");
|
|
});
|
|
|
|
it("deletes the sandbox and records a failed terminal on a driver error", async () => {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({ exec: execDriverError });
|
|
const service = makeService({ store, runtime });
|
|
const { completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("failed");
|
|
expect(deleteCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("deletes the sandbox and records a cancelled terminal on a cancellation", async () => {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
|
|
const service = makeService({ store, runtime });
|
|
const controller = new AbortController();
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
signal: controller.signal,
|
|
});
|
|
controller.abort();
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("cancelled");
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("cancelled");
|
|
});
|
|
|
|
it("releases the lease when a transition fails after acquisition", async () => {
|
|
const store = createMemoryStore();
|
|
// A rejecting `recordLeaseAcquired` forces a transition failure right after
|
|
// the acquisition.
|
|
const failingStore: AdapterAuthSessionStore = {
|
|
...store,
|
|
async recordLeaseAcquired() {
|
|
throw new Error("transition write failed");
|
|
},
|
|
};
|
|
const { runtime, releaseCalls, deleteCalls } = createFakeRuntime({ exec: execSuccess });
|
|
const service = makeService({ store: failingStore, runtime });
|
|
const companyId = randomUUID();
|
|
await expect(
|
|
service.start({
|
|
companyId,
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
}),
|
|
).rejects.toThrow("transition write failed");
|
|
expect(releaseCalls).toHaveLength(1);
|
|
// The service never runs the login command, so it never deletes a sandbox.
|
|
expect(deleteCalls).toHaveLength(0);
|
|
const rows = [...store.rows.values()].filter((row) => row.companyId === companyId);
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0]?.status).toBe("failed");
|
|
});
|
|
|
|
// The rejecting-delete matrix. A delete failure on every terminal path records
|
|
// the durable internal `cleanup_pending` state and never returns a false-clean
|
|
// terminal outcome.
|
|
const rejectingDelete = async (): Promise<SandboxDeleteResult> => {
|
|
throw new Error("provider delete rejected");
|
|
};
|
|
|
|
it.each([
|
|
{ name: "success", exec: execSuccess, terminal: "authenticated" as const, cancel: false },
|
|
{ name: "failure", exec: execFailure, terminal: "failed" as const, cancel: false },
|
|
{ name: "cancellation", exec: execHang, terminal: "cancelled" as const, cancel: true },
|
|
])(
|
|
"records cleanup_pending on a delete failure for the $name path",
|
|
async ({ exec, terminal, cancel }) => {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({
|
|
exec,
|
|
authBytes: Buffer.from("{}"),
|
|
delete: rejectingDelete,
|
|
});
|
|
const service = makeService({ store, runtime });
|
|
const controller = new AbortController();
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
signal: controller.signal,
|
|
});
|
|
if (cancel) controller.abort();
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe(terminal);
|
|
expect(outcome.cleanupPending).toBe(true);
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const row = await store.get(session.sessionId);
|
|
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);
|
|
expect(publicRead?.status).toBe(terminal);
|
|
},
|
|
);
|
|
|
|
it("records cleanup_pending on a delete failure after a promotion failure", async () => {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({
|
|
exec: execSuccess,
|
|
authBytes: Buffer.from("{}"),
|
|
delete: rejectingDelete,
|
|
});
|
|
const service = makeService({
|
|
store,
|
|
runtime,
|
|
promotion: {
|
|
promote: () => {
|
|
throw new Error("promotion write failed");
|
|
},
|
|
},
|
|
});
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
const outcome = await completed;
|
|
// The promotion write failed, so the terminal is `failed`, not
|
|
// `authenticated`. The delete also failed, so the row holds cleanup_pending.
|
|
expect(outcome.status).toBe("failed");
|
|
expect(outcome.cleanupPending).toBe(true);
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("cleanup_pending");
|
|
const publicRead = await service.readOwnerSession(session.sessionId, OWNER_A);
|
|
expect(publicRead?.status).toBe("failed");
|
|
expect(publicRead?.failure?.reason).toBe("promotion_failed");
|
|
});
|
|
|
|
it("treats a provider not_found result as an idempotent confirmed delete", async () => {
|
|
const store = createMemoryStore();
|
|
const { runtime } = createFakeRuntime({
|
|
exec: execSuccess,
|
|
authBytes: Buffer.from("{}"),
|
|
delete: async () => ({ outcome: "not_found" }),
|
|
});
|
|
const service = makeService({ store, runtime });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("authenticated");
|
|
expect(outcome.cleanupPending).toBe(false);
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("authenticated");
|
|
});
|
|
|
|
it("never promotes or authenticates when the reaper wins the slot before the claim", async () => {
|
|
const store = createMemoryStore();
|
|
let releaseGate!: () => void;
|
|
const gate = new Promise<void>((resolve) => {
|
|
releaseGate = resolve;
|
|
});
|
|
// Emit the prompt, then hold the login until the test releases the gate. The
|
|
// test uses the hold to terminate the row before the promotion claim runs.
|
|
const execGatedSuccess: ExecBehavior = async ({ onStdout }) => {
|
|
onStdout(PROMPT_OUTPUT);
|
|
await gate;
|
|
return { exitCode: 0 };
|
|
};
|
|
const { runtime, deleteCalls } = createFakeRuntime({
|
|
exec: execGatedSuccess,
|
|
authBytes: Buffer.from("{}"),
|
|
});
|
|
const promote = vi.fn(() => {});
|
|
const service = makeService({ store, runtime, promotion: { promote } });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
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");
|
|
|
|
// The reaper wins the slot: it terminates the row before the promotion claim.
|
|
await store.setStatus({
|
|
sessionId: session.sessionId,
|
|
status: "timed_out",
|
|
at: new Date(),
|
|
finishedAt: new Date(),
|
|
});
|
|
|
|
// Release the login. The success outcome now reaches the promotion claim, but
|
|
// the row no longer holds an active pre-promotion status.
|
|
releaseGate();
|
|
const outcome = await completed;
|
|
|
|
// 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);
|
|
expect(row?.status).toBe("timed_out");
|
|
// The service still deletes its own sandbox on the lost-claim path.
|
|
expect(deleteCalls).toHaveLength(1);
|
|
});
|
|
|
|
describe("durable cancel", () => {
|
|
it("cancels a waiting session, releases the slot, and hands cleanup to the reaper", async () => {
|
|
const store = createMemoryStore();
|
|
let releaseGate!: () => void;
|
|
const gate = new Promise<void>((resolve) => {
|
|
releaseGate = resolve;
|
|
});
|
|
// Emit the prompt, then hold the login. The hold keeps the row in the active
|
|
// `waiting_for_user` state while the test cancels it.
|
|
const execGated: ExecBehavior = async ({ onStdout }) => {
|
|
onStdout(PROMPT_OUTPUT);
|
|
await gate;
|
|
return { exitCode: 0 };
|
|
};
|
|
const { runtime } = createFakeRuntime({ exec: execGated, authBytes: Buffer.from("{}") });
|
|
const service = makeService({ store, runtime });
|
|
const companyId = randomUUID();
|
|
const environmentId = randomUUID();
|
|
const { session, completed } = await service.start({
|
|
companyId,
|
|
environmentId,
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
await waitForStatus(store, session.sessionId, "waiting_for_user");
|
|
|
|
// A non-owner cannot cancel the session.
|
|
expect(await service.cancelOwnerSession(session.sessionId, OWNER_B)).toBeNull();
|
|
|
|
// The owner cancel resolves the public terminal status at once.
|
|
const cancelled = await service.cancelOwnerSession(session.sessionId, 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);
|
|
expect(row?.status).toBe("cleanup_pending");
|
|
expect(row?.finishedAt).not.toBeNull();
|
|
|
|
// The durable write released the company slot, so a fresh start for the same
|
|
// company and adapter wins. This proves the cancel does not depend on the
|
|
// in-flight run ending.
|
|
const second = await service.start({
|
|
companyId,
|
|
environmentId,
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
expect(second.session.sessionId).not.toBe(session.sessionId);
|
|
|
|
// Release the held runs, so both `completed` promises settle and clear their
|
|
// timers.
|
|
releaseGate();
|
|
await completed;
|
|
await second.completed;
|
|
});
|
|
|
|
it("does not cancel a session whose promotion is in flight", async () => {
|
|
const store = createMemoryStore();
|
|
let releasePromotion!: () => void;
|
|
const promotionGate = new Promise<void>((resolve) => {
|
|
releasePromotion = resolve;
|
|
});
|
|
// A promotion that resolves only when the test releases it, so the row stays
|
|
// in the `promoting` state while the test tries to cancel it.
|
|
const promote = vi.fn(async () => {
|
|
await promotionGate;
|
|
});
|
|
const { runtime } = createFakeRuntime({ exec: execSuccess, authBytes: Buffer.from("{}") });
|
|
const service = makeService({ store, runtime, promotion: { promote } });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
await waitForStatus(store, session.sessionId, "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);
|
|
expect(result?.status).toBe("waiting_for_user");
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("promoting");
|
|
|
|
// Release the promotion, so the run reaches its own terminal.
|
|
releasePromotion();
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("authenticated");
|
|
});
|
|
});
|
|
|
|
describe("five-minute host timeout", () => {
|
|
it("holds the session active until exactly five minutes, then times out and deletes", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({ exec: execHang });
|
|
const service = makeService({ store, runtime });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
let settled = false;
|
|
void completed.then(() => {
|
|
settled = true;
|
|
});
|
|
|
|
// One millisecond before five minutes: the run still holds the active
|
|
// claim.
|
|
await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS - 1);
|
|
expect(settled).toBe(false);
|
|
const midRow = await store.get(session.sessionId);
|
|
expect(["starting", "waiting_for_user"]).toContain(midRow?.status);
|
|
|
|
// The last millisecond fires the timeout.
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("timed_out");
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("timed_out");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("records cleanup_pending on a delete failure at the five-minute timeout", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const store = createMemoryStore();
|
|
const { runtime, deleteCalls } = createFakeRuntime({
|
|
exec: execHang,
|
|
delete: async () => {
|
|
throw new Error("provider delete rejected");
|
|
},
|
|
});
|
|
const service = makeService({ store, runtime });
|
|
const { session, completed } = await service.start({
|
|
companyId: randomUUID(),
|
|
environmentId: randomUUID(),
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
await vi.advanceTimersByTimeAsync(CODEX_DEVICE_LOGIN_TIMEOUT_MS);
|
|
const outcome = await completed;
|
|
expect(outcome.status).toBe("timed_out");
|
|
expect(outcome.cleanupPending).toBe(true);
|
|
expect(outcome.sandboxDeleteObserved).toBe(true);
|
|
expect(deleteCalls).toHaveLength(1);
|
|
const row = await store.get(session.sessionId);
|
|
expect(row?.status).toBe("cleanup_pending");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|
|
|
|
it("builds a production driver that sets an empty session home and reads the fixed credential path", async () => {
|
|
// Record the program and the argument vector for each call. The provider
|
|
// quotes each element as one shell token, so the driver must pass a program
|
|
// plus its arguments, never one compound string. A one-shot exec streams no
|
|
// output, so the login command must set `forceSession` to open the session.
|
|
const calls: {
|
|
command: string;
|
|
args?: string[];
|
|
forceSession?: boolean;
|
|
bypassSession?: boolean;
|
|
}[] = [];
|
|
const sessionId = randomUUID();
|
|
const sessionHome = sessionCodexHomePath(sessionId);
|
|
const authPath = sessionCredentialPath(sessionId);
|
|
const environmentRuntime = {
|
|
execute: async (input: {
|
|
command: string;
|
|
args?: string[];
|
|
forceSession?: boolean;
|
|
bypassSession?: boolean;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => void | Promise<void>;
|
|
}) => {
|
|
calls.push({
|
|
command: input.command,
|
|
args: input.args,
|
|
forceSession: input.forceSession,
|
|
bypassSession: input.bypassSession,
|
|
});
|
|
const script = input.args?.join(" ") ?? "";
|
|
if (script.includes("codex login")) {
|
|
await input.onLog?.("stdout", PROMPT_OUTPUT);
|
|
return { exitCode: 0, stdout: "", stderr: "" };
|
|
}
|
|
// The credential read.
|
|
return { exitCode: 0, stdout: '{"token":"secret"}', stderr: "" };
|
|
},
|
|
};
|
|
const driver = buildSandboxLoginDriver({
|
|
// The helper only calls `execute`; a partial runtime is enough here.
|
|
environmentRuntime: environmentRuntime as never,
|
|
environment: { id: "env", driver: "sandbox" } as never,
|
|
lease: { id: "lease" } as never,
|
|
sessionHome,
|
|
timeoutMs: CODEX_DEVICE_LOGIN_TIMEOUT_MS,
|
|
});
|
|
|
|
const chunks: string[] = [];
|
|
const result = await driver.execStreaming("codex login --device-auth", (chunk) => chunks.push(chunk));
|
|
expect(result.exitCode).toBe(0);
|
|
expect(chunks.join("")).toContain(DEVICE_LOGIN_URL);
|
|
// The login command runs through `sh -c`, so the runtime runs the whole
|
|
// compound command in one shell. It sets an empty session-specific Codex
|
|
// home before the login. It opens the session, so the prompt streams.
|
|
expect(calls[0].command).toBe("sh");
|
|
expect(calls[0].args).toEqual([
|
|
"-c",
|
|
`rm -rf ${sessionHome} && mkdir -p ${sessionHome} && CODEX_HOME=${sessionHome} codex login --device-auth`,
|
|
]);
|
|
expect(calls[0].forceSession).toBe(true);
|
|
|
|
const authBytes = await driver.readFile(authPath);
|
|
expect(authBytes.toString("utf8")).toBe('{"token":"secret"}');
|
|
// The credential read runs `cat` with the path as one argument, one-shot.
|
|
expect(calls[1].command).toBe("cat");
|
|
expect(calls[1].args).toEqual([authPath]);
|
|
expect(calls[1].bypassSession).toBe(true);
|
|
});
|
|
});
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping codex device login concurrency tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("codex device login service concurrency (embedded postgres)", () => {
|
|
let stopDb: (() => Promise<void>) | undefined;
|
|
let db!: ReturnType<typeof createDb>;
|
|
|
|
beforeAll(async () => {
|
|
const started = await startEmbeddedPostgresTestDatabase("codex-device-login");
|
|
stopDb = started.stop;
|
|
db = createDb(started.connectionString);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.delete(adapterAuthSessions);
|
|
await db.delete(environments);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await stopDb?.();
|
|
});
|
|
|
|
async function seedCompanyEnvironment(): Promise<{ companyId: string; environmentId: string }> {
|
|
const companyId = randomUUID();
|
|
const environmentId = randomUUID();
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Acme",
|
|
status: "active",
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
await db.insert(environments).values({
|
|
id: environmentId,
|
|
name: `sandbox-${environmentId.slice(0, 8)}`,
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "fake" },
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
return { companyId, environmentId };
|
|
}
|
|
|
|
async function seedEnvironment(companyId: string): Promise<string> {
|
|
const environmentId = randomUUID();
|
|
await db.insert(environments).values({
|
|
id: environmentId,
|
|
name: `sandbox-${environmentId.slice(0, 8)}`,
|
|
driver: "sandbox",
|
|
status: "active",
|
|
config: { provider: "fake" },
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
});
|
|
return 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;
|
|
|
|
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: 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",
|
|
);
|
|
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 a second start with a 409 while the first login 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.
|
|
let releasePromotion!: () => void;
|
|
const promotionGate = new Promise<void>((resolve) => {
|
|
releasePromotion = resolve;
|
|
});
|
|
const service = makeService({
|
|
store,
|
|
runtime,
|
|
promotion: { promote: () => promotionGate },
|
|
});
|
|
|
|
const first = await service.start({
|
|
companyId,
|
|
environmentId,
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
});
|
|
|
|
// The first login reaches the `promoting` claim and holds it.
|
|
await waitForStatus(store, first.session.sessionId, "promoting");
|
|
|
|
// A second start for the same company 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,
|
|
}),
|
|
).rejects.toBeInstanceOf(AdapterAuthSessionConflictError);
|
|
|
|
// Release the first promotion so the run ends and no timer survives.
|
|
releasePromotion();
|
|
const outcome = await first.completed;
|
|
expect(outcome.status).toBe("authenticated");
|
|
});
|
|
|
|
// Insert a `promoting` row whose promotion claim already expired, so the reaper
|
|
// scan selects it. The tests below race the reaper reclaim against the
|
|
// promotion critical section through the real advisory lock.
|
|
async function seedStalePromotingRow(companyId: string, environmentId: string): Promise<string> {
|
|
const sessionId = randomUUID();
|
|
const past = new Date(Date.now() - 5 * 60_000);
|
|
await db.insert(adapterAuthSessions).values({
|
|
id: sessionId,
|
|
companyId,
|
|
environmentId,
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
providerLeaseId: `lease-${sessionId}`,
|
|
status: "promoting",
|
|
expiresAt: past,
|
|
promotionExpiresAt: past,
|
|
createdAt: past,
|
|
updatedAt: past,
|
|
});
|
|
return sessionId;
|
|
}
|
|
|
|
it("holds the promotion lock so the reaper cannot reclaim a live credential write", async () => {
|
|
const { companyId, environmentId } = await seedCompanyEnvironment();
|
|
const store = createDbAdapterAuthSessionStore(db);
|
|
const sessionId = await seedStalePromotingRow(companyId, environmentId);
|
|
|
|
const { runtime } = createReaperRuntime();
|
|
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => new Date() });
|
|
|
|
// Run the ownership check and the credential write inside the promotion lock.
|
|
// The write flag stands in for the filesystem credential write. A gate holds
|
|
// the section open, so the test can start the reaper while the lock is held.
|
|
let wroteCredential = false;
|
|
let releaseSection!: () => void;
|
|
const sectionGate = new Promise<void>((resolve) => {
|
|
releaseSection = resolve;
|
|
});
|
|
let markSectionActive!: () => void;
|
|
const sectionActive = new Promise<void>((resolve) => {
|
|
markSectionActive = resolve;
|
|
});
|
|
const promotion = store.withCompanyAdapterPromotionLock(companyId, ADAPTER_TYPE, async () => {
|
|
markSectionActive();
|
|
await sectionGate;
|
|
// Decision H: the write proceeds only while the session still owns the slot.
|
|
const row = await store.get(sessionId);
|
|
if (row?.status === "promoting" && row.companyId === companyId) {
|
|
wroteCredential = true;
|
|
}
|
|
});
|
|
|
|
// The promotion holds the lock now. Start the reaper. It must block on the
|
|
// advisory lock, so it cannot reclaim the row while the write section runs.
|
|
await sectionActive;
|
|
const reaped = reaper.sweep();
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect((await store.get(sessionId))?.status).toBe("promoting");
|
|
|
|
// Release the section. The ownership check sees the live claim and writes.
|
|
releaseSection();
|
|
await promotion;
|
|
const sweep = await reaped;
|
|
|
|
// The credential write stood, because the session owned the slot at the
|
|
// write. The reaper reclaimed the row only after the write finished.
|
|
expect(wroteCredential).toBe(true);
|
|
expect(sweep.expiredTimedOut).toBe(1);
|
|
expect((await store.get(sessionId))?.status).toBe("timed_out");
|
|
});
|
|
|
|
it("skips the credential write when the reaper reclaimed the stale slot first", async () => {
|
|
const { companyId, environmentId } = await seedCompanyEnvironment();
|
|
const store = createDbAdapterAuthSessionStore(db);
|
|
const sessionId = await seedStalePromotingRow(companyId, environmentId);
|
|
|
|
const { runtime } = createReaperRuntime();
|
|
const reaper = createCodexDeviceLoginReaper({ store, runtime, now: () => new Date() });
|
|
|
|
// The reaper reclaims the stale row first and times it out.
|
|
const sweep = await reaper.sweep();
|
|
expect(sweep.expiredTimedOut).toBe(1);
|
|
expect((await store.get(sessionId))?.status).toBe("timed_out");
|
|
|
|
// 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 () => {
|
|
const row = await store.get(sessionId);
|
|
if (row?.status === "promoting" && row.companyId === companyId) {
|
|
wroteCredential = true;
|
|
}
|
|
});
|
|
expect(wroteCredential).toBe(false);
|
|
|
|
const authenticated = await store.compareAndSetStatus({
|
|
sessionId,
|
|
expectedStatuses: ["promoting"],
|
|
status: "authenticated",
|
|
at: new Date(),
|
|
});
|
|
expect(authenticated).toBe(false);
|
|
expect((await store.get(sessionId))?.status).toBe("timed_out");
|
|
});
|
|
|
|
it("does not resurrect a reaped starting row when a delayed prompt arrives", async () => {
|
|
const { companyId, environmentId } = await seedCompanyEnvironment();
|
|
const store = createDbAdapterAuthSessionStore(db);
|
|
|
|
// The login emits the prompt only after the test opens the gate, so the
|
|
// reaper reclaims the expired `starting` row before the prompt arrives.
|
|
let releasePrompt!: () => void;
|
|
const promptGate = new Promise<void>((resolve) => {
|
|
releasePrompt = resolve;
|
|
});
|
|
const execGatedPrompt: ExecBehavior = async ({ onStdout }) => {
|
|
await promptGate;
|
|
onStdout(PROMPT_OUTPUT);
|
|
return new Promise<{ exitCode: number | null }>(() => {});
|
|
};
|
|
const { runtime } = createFakeRuntime({ exec: execGatedPrompt });
|
|
const service = makeService({ store, runtime });
|
|
|
|
const controller = new AbortController();
|
|
const started = await service.start({
|
|
companyId,
|
|
environmentId,
|
|
adapterType: ADAPTER_TYPE,
|
|
startedByUserId: OWNER_A,
|
|
signal: controller.signal,
|
|
});
|
|
const sessionId = started.session.sessionId;
|
|
await waitForStatus(store, sessionId, "starting");
|
|
|
|
// The reaper reclaims the expired row and times it out.
|
|
const { runtime: reaperRuntime } = createReaperRuntime();
|
|
const reaper = createCodexDeviceLoginReaper({
|
|
store,
|
|
runtime: reaperRuntime,
|
|
now: () => new Date(Date.now() + CODEX_DEVICE_LOGIN_TIMEOUT_MS + 60_000),
|
|
});
|
|
const sweep = await reaper.sweep();
|
|
expect(sweep.expiredTimedOut).toBe(1);
|
|
expect((await store.get(sessionId))?.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.
|
|
releasePrompt();
|
|
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");
|
|
expect(owner?.prompt ?? null).toBeNull();
|
|
|
|
// End the run so no timer survives.
|
|
controller.abort();
|
|
await started.completed;
|
|
});
|
|
});
|