feat(agent-login): resume an active login session and permit concurrent login terminals (#12861)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent authentication uses server sessions, plugin workers, and browser login panels. > - A page reload loses an active login session, and one worker permits only one login terminal. > - These limits cause lost work and prevent two owners from logging in through one worker. > - This pull request lets the browser resume active sessions and lets workers serve concurrent login terminals. > - The benefit is reliable login recovery with a bounded process-wide route limit. ## Linked Issues or Issue Description **What existing behavior does this improve?** It improves agent credential login recovery and concurrent login terminal handling. **Subsystem affected** Cross-cutting (multiple of the above). **Current behavior** A page reload loses the active login session. A shared plugin worker rejects a second login terminal. **Proposed behavior** The browser reads and resumes the owner's active session. A worker supports multiple login terminal routes under a process-wide ceiling. **Reason and benefit** Owners keep login progress after a reload. Two owners can log in through one worker without removing the route limit. **Breaking changes** None. The change adds owner-scoped read routes and changes login terminal concurrency. ## What Changed - Replace the single worker login route with maps keyed by host route and worker session identifiers. - Add a process-wide login route ceiling and release each reserved slot on every exit path. - Add owner-scoped active-session reads with consistent negative responses and private cache control. - Keep the device-login prompt while the session has an active public status. - Add a durable setup-token cancel fallback for a lost in-memory session. - Resume active sessions when the agent configuration or onboarding panel mounts. - Remove routine unmount cancellation and keep explicit Cancel behavior. ## Verification - `pnpm --filter @paperclip/server test` — server route, service, and plugin-worker-manager suites. - `pnpm --filter @paperclip/plugin-sdk test` — worker RPC host suite. - `cd ui && npx vitest run src/components/AgentConfigForm.render.test.tsx src/components/OnboardingWizard.test.tsx`. - `cd ui && npx tsc -b`. - `tests/e2e/onboarding.spec.ts` — reload during login. - CI must pass on this pull request. ## Risks The change affects agent authentication and the sandbox-to-host boundary. Route cleanup must release every reserved slot. Owner checks must prevent cross-owner session access. Tests cover route cleanup, owner scope, reload recovery, and concurrent worker routes. ## Model Used Codex, OpenAI GPT-5, tool use and code review support. The implementation author owns the exact model details for the code changes. ## 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 described the issue in-PR with the relevant issue-template fields - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [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 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
3ed5b7c5c8
commit
60469a08e0
|
|
@ -2870,15 +2870,16 @@ const plugin = definePlugin({
|
|||
daytonaLoginPtyByRoute.set(params.hostRouteId, entry);
|
||||
daytonaLoginPtyBySession.set(workerSessionId, entry);
|
||||
// Register the output listener before the first input, so no early output
|
||||
// chunk is lost. The client stamps the worker session id, so the host binds
|
||||
// the output to the open route.
|
||||
// chunk is lost. The client stamps the host route identifier and the worker
|
||||
// session identifier, so the host can hold more than one concurrent login
|
||||
// pseudo-terminal on this worker and binds the output to its own route.
|
||||
session.onData((chunk) => {
|
||||
pluginContext?.loginPty.output(workerSessionId, chunk);
|
||||
pluginContext?.loginPty.output(params.hostRouteId, workerSessionId, chunk);
|
||||
});
|
||||
// Forward the child exit one time. The host resolves the login run on it.
|
||||
void session.wait().then(
|
||||
(result) => pluginContext?.loginPty.exit(workerSessionId, result.exitCode),
|
||||
() => pluginContext?.loginPty.exit(workerSessionId, null),
|
||||
(result) => pluginContext?.loginPty.exit(params.hostRouteId, workerSessionId, result.exitCode),
|
||||
() => pluginContext?.loginPty.exit(params.hostRouteId, workerSessionId, null),
|
||||
);
|
||||
return { workerSessionId };
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1086,6 +1086,12 @@ export interface PluginLoginPtyCloseResult {
|
|||
|
||||
/** The worker→host pseudo-terminal output notification parameters. Modeled on `execute.log`. */
|
||||
export interface PluginLoginPtyOutputParams {
|
||||
/**
|
||||
* The host route identifier the open request carried. The worker echoes it,
|
||||
* so the host can hold more than one concurrent login pseudo-terminal per
|
||||
* worker and route each chunk to its own route.
|
||||
*/
|
||||
hostRouteId: string;
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The raw terminal output bytes. */
|
||||
|
|
@ -1094,6 +1100,12 @@ export interface PluginLoginPtyOutputParams {
|
|||
|
||||
/** The worker→host pseudo-terminal exit notification parameters. */
|
||||
export interface PluginLoginPtyExitParams {
|
||||
/**
|
||||
* The host route identifier the open request carried. The worker echoes it,
|
||||
* so the host can hold more than one concurrent login pseudo-terminal per
|
||||
* worker and resolve the exit against its own route.
|
||||
*/
|
||||
hostRouteId: string;
|
||||
/** The worker session identifier that the open reply returned. */
|
||||
workerSessionId: string;
|
||||
/** The child exit code, or null when the child ended with no code. */
|
||||
|
|
|
|||
|
|
@ -2479,10 +2479,10 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
|||
},
|
||||
},
|
||||
loginPty: {
|
||||
output(_workerSessionId: string, _chunk: string) {
|
||||
output(_hostRouteId: string, _workerSessionId: string, _chunk: string) {
|
||||
// No-op in test harness — the host login route is not wired here.
|
||||
},
|
||||
exit(_workerSessionId: string, _exitCode: number | null) {
|
||||
exit(_hostRouteId: string, _workerSessionId: string, _exitCode: number | null) {
|
||||
// No-op in test harness — the host login route is not wired here.
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2025,28 +2025,32 @@ export interface PluginExecutionClient {
|
|||
* from a sandbox provider worker to the host.
|
||||
*
|
||||
* The worker opener registers the output listener on the session and forwards
|
||||
* each raw chunk through `output(workerSessionId, chunk)`. It forwards the child
|
||||
* exit through `exit(workerSessionId, exitCode)`. Each call carries the worker
|
||||
* session identifier the open reply returned, so the host binds the output to
|
||||
* the open route by that identifier while the route is open. The host drops a
|
||||
* chunk or an exit that carries an unknown or a mismatched identifier, and it
|
||||
* never logs the raw bytes. The default is a no-op that never throws.
|
||||
* each raw chunk through `output(hostRouteId, workerSessionId, chunk)`. It
|
||||
* forwards the child exit through `exit(hostRouteId, workerSessionId,
|
||||
* exitCode)`. Each call carries the host route identifier the open request
|
||||
* carried and the worker session identifier the open reply returned, so the
|
||||
* host can hold more than one concurrent login pseudo-terminal per worker and
|
||||
* bind each chunk to its own route. The host drops a chunk or an exit that
|
||||
* carries an unknown, a stale, or a mismatched identifier, and it never logs
|
||||
* the raw bytes. The default is a no-op that never throws.
|
||||
*/
|
||||
export interface PluginLoginPtyClient {
|
||||
/**
|
||||
* Deliver one raw output chunk of a live login pseudo-terminal.
|
||||
*
|
||||
* @param hostRouteId - The host route identifier the open request carried. The worker echoes it, so the host routes the chunk to its own route.
|
||||
* @param workerSessionId - The worker session identifier the open reply returned.
|
||||
* @param chunk - The raw terminal output text.
|
||||
*/
|
||||
output(workerSessionId: string, chunk: string): void;
|
||||
output(hostRouteId: string, workerSessionId: string, chunk: string): void;
|
||||
/**
|
||||
* Deliver the child exit of a live login pseudo-terminal.
|
||||
*
|
||||
* @param hostRouteId - The host route identifier the open request carried. The worker echoes it, so the host resolves the exit against its own route.
|
||||
* @param workerSessionId - The worker session identifier the open reply returned.
|
||||
* @param exitCode - The child exit code, or null when the child ended with no code.
|
||||
*/
|
||||
exit(workerSessionId: string, exitCode: number | null): void;
|
||||
exit(hostRouteId: string, workerSessionId: string, exitCode: number | null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1381,23 +1381,28 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
|
|||
},
|
||||
|
||||
loginPty: {
|
||||
output(workerSessionId: string, chunk: string): void {
|
||||
output(hostRouteId: string, workerSessionId: string, chunk: string): void {
|
||||
// Forward one raw output chunk of a live login pseudo-terminal. The
|
||||
// notification carries the worker session identifier, so the host binds
|
||||
// the chunk to the open route by that identifier while the route is
|
||||
// open. The host drops an unknown or a mismatched identifier and never
|
||||
// logs the raw bytes. This notification carries no invocation id,
|
||||
// because it fires after the open reply returns.
|
||||
// notification echoes the host route identifier and the worker session
|
||||
// identifier, so the host can hold more than one concurrent login
|
||||
// pseudo-terminal per worker and binds the chunk to its own route
|
||||
// while that route is open. The host drops an unknown, a stale, or a
|
||||
// mismatched identifier and never logs the raw bytes. This
|
||||
// notification carries no invocation id, because it fires after the
|
||||
// open reply returns.
|
||||
if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return;
|
||||
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
|
||||
if (typeof chunk !== "string" || chunk.length === 0) return;
|
||||
notifyHost(LOGIN_PTY_OUTPUT_NOTIFICATION, { workerSessionId, chunk });
|
||||
notifyHost(LOGIN_PTY_OUTPUT_NOTIFICATION, { hostRouteId, workerSessionId, chunk });
|
||||
},
|
||||
exit(workerSessionId: string, exitCode: number | null): void {
|
||||
exit(hostRouteId: string, workerSessionId: string, exitCode: number | null): void {
|
||||
// Forward the child exit of a live login pseudo-terminal. The host
|
||||
// resolves the open route's wait promise by the worker session
|
||||
// identifier while the route is open.
|
||||
// resolves its own route's wait promise by the host route identifier
|
||||
// and the bound worker session identifier while that route is open.
|
||||
if (typeof hostRouteId !== "string" || hostRouteId.length === 0) return;
|
||||
if (typeof workerSessionId !== "string" || workerSessionId.length === 0) return;
|
||||
notifyHost(LOGIN_PTY_EXIT_NOTIFICATION, {
|
||||
hostRouteId,
|
||||
workerSessionId,
|
||||
exitCode: typeof exitCode === "number" ? exitCode : null,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -778,8 +778,8 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
|
|||
// worker session id. The test drives them through the captured emitters.
|
||||
const controllablePlugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
emitOutput = (chunk: string) => ctx.loginPty.output("ws-1", chunk);
|
||||
resolveWait = (value) => ctx.loginPty.exit("ws-1", value.exitCode);
|
||||
emitOutput = (chunk: string) => ctx.loginPty.output("route-1", "ws-1", chunk);
|
||||
resolveWait = (value) => ctx.loginPty.exit("route-1", "ws-1", value.exitCode);
|
||||
},
|
||||
async onLoginPtyOpen(params) {
|
||||
// The open carries the host route id, the closed command key, and the
|
||||
|
|
@ -896,13 +896,13 @@ describe("worker setup-token pseudo-terminal dispatch", () => {
|
|||
(note) => note.method === "loginPty.output",
|
||||
);
|
||||
expect(outputNotes.map((note) => note.params)).toEqual([
|
||||
{ workerSessionId: "ws-1", chunk: "prompt output" },
|
||||
{ hostRouteId: "route-1", workerSessionId: "ws-1", chunk: "prompt output" },
|
||||
]);
|
||||
const exitNotes = notifications.filter(
|
||||
(note) => note.method === "loginPty.exit",
|
||||
);
|
||||
expect(exitNotes.map((note) => note.params)).toEqual([
|
||||
{ workerSessionId: "ws-1", exitCode: 0 },
|
||||
{ hostRouteId: "route-1", workerSessionId: "ws-1", exitCode: 0 },
|
||||
]);
|
||||
} finally {
|
||||
worker.stop();
|
||||
|
|
|
|||
|
|
@ -294,6 +294,19 @@ function createMemoryStore(): AdapterAuthSessionStore & { rows: Map<string, Adap
|
|||
}
|
||||
return null;
|
||||
},
|
||||
async getActiveByOwner(companyId, startedByUserId, adapterType) {
|
||||
for (const row of rows.values()) {
|
||||
if (
|
||||
row.companyId === companyId &&
|
||||
row.startedByUserId === startedByUserId &&
|
||||
row.adapterType === adapterType &&
|
||||
isActive(row.status)
|
||||
) {
|
||||
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.
|
||||
|
|
@ -647,7 +660,7 @@ describe("adapter device-login routes", () => {
|
|||
expect(harness.acquisitions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("delivers the one-time prompt to the owner on the first read only", async () => {
|
||||
it("delivers the prompt to the owner on every read while the session is active, and clears it on a terminal transition", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
|
|
@ -656,17 +669,30 @@ describe("adapter device-login routes", () => {
|
|||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
const sessionId = start.body.sessionId as string;
|
||||
|
||||
// The first authorized owner read receives the one-time prompt.
|
||||
// The first authorized owner read receives the prompt. The response
|
||||
// repeats the live prompt on every read, so it carries the same private
|
||||
// no-store policy as the `.../login-sessions/active` route (see the tests
|
||||
// below).
|
||||
const first = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(first.status, JSON.stringify(first.body)).toBe(200);
|
||||
expect(first.body.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
||||
expect(first.headers["cache-control"]).toBe("no-store, private");
|
||||
|
||||
// A second authorized owner read no longer carries the prompt. The status
|
||||
// stays available, so the owner still tracks the session.
|
||||
// A second authorized owner read still carries the prompt while the
|
||||
// session is active. The status stays available too, so the owner still
|
||||
// tracks the session across a page reload.
|
||||
const second = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(second.status, JSON.stringify(second.body)).toBe(200);
|
||||
expect(second.body.prompt).toBeNull();
|
||||
expect(second.body.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
||||
expect(second.body.status).toBe(first.body.status);
|
||||
|
||||
// Once the login reaches a terminal state, the prompt is gone.
|
||||
harness.releaseGate();
|
||||
await vi.waitFor(async () => {
|
||||
const afterTerminal = await request(app).get(`${loginPath(COMPANY_1)}/${sessionId}`);
|
||||
expect(["authenticated", "failed"]).toContain(afterTerminal.body.status);
|
||||
expect(afterTerminal.body.prompt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("starts a Grok session, delivers the Grok prompt once, and a codex_local read finds no row", async () => {
|
||||
|
|
@ -739,6 +765,52 @@ describe("adapter device-login routes", () => {
|
|||
expect(status.body.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the caller's active login session with no session id in the URL", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
|
||||
const active = await request(app).get(`${loginPath(COMPANY_1)}/active`);
|
||||
expect(active.status, JSON.stringify(active.body)).toBe(200);
|
||||
expect(active.body.sessionId).toBe(start.body.sessionId);
|
||||
expect(active.body.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
||||
expect(active.headers["cache-control"]).toBe("no-store, private");
|
||||
});
|
||||
|
||||
it("returns the identical 404 on the active route for no active session, another owner, another company, and another adapter", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
// No active session exists yet.
|
||||
const none = await request(app).get(`${loginPath(COMPANY_1)}/active`);
|
||||
expect(none.status, JSON.stringify(none.body)).toBe(404);
|
||||
expect(none.body).toEqual({ error: "Adapter login session not found" });
|
||||
|
||||
const start = await request(app)
|
||||
.post(loginPath(COMPANY_1))
|
||||
.send({ environmentId: SANDBOX_ENV_1 });
|
||||
expect(start.status, JSON.stringify(start.body)).toBe(201);
|
||||
|
||||
// A different board user in the same company holds no active session.
|
||||
currentActor = boardActor(OWNER_B);
|
||||
const otherOwner = await request(app).get(`${loginPath(COMPANY_1)}/active`);
|
||||
expect(otherOwner.status, JSON.stringify(otherOwner.body)).toBe(404);
|
||||
expect(otherOwner.body).toEqual(none.body);
|
||||
currentActor = boardActor(OWNER_A);
|
||||
|
||||
// The same owner under a different company holds no active session there.
|
||||
const otherCompany = await request(app).get(`${loginPath(COMPANY_2)}/active`);
|
||||
expect(otherCompany.status, JSON.stringify(otherCompany.body)).toBe(404);
|
||||
expect(otherCompany.body).toEqual(none.body);
|
||||
|
||||
// The owner's active session belongs to `codex_local`, not `grok_local`.
|
||||
const otherAdapter = await request(app).get(`${loginPath(COMPANY_1, "grok_local")}/active`);
|
||||
expect(otherAdapter.status, JSON.stringify(otherAdapter.body)).toBe(404);
|
||||
expect(otherAdapter.body).toEqual(none.body);
|
||||
});
|
||||
|
||||
it("durably cancels a login for the owner and releases the company slot", async () => {
|
||||
const app = await createApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -259,6 +259,19 @@ function createMemoryStore(): AdapterAuthSessionStore & {
|
|||
}
|
||||
return null;
|
||||
},
|
||||
async getActiveByOwner(companyId, startedByUserId, adapterType) {
|
||||
for (const row of rows.values()) {
|
||||
if (
|
||||
row.companyId === companyId &&
|
||||
row.startedByUserId === startedByUserId &&
|
||||
row.adapterType === adapterType &&
|
||||
isActive(row.status)
|
||||
) {
|
||||
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.
|
||||
|
|
@ -305,8 +318,20 @@ describe("device login service", () => {
|
|||
const activity: LoginSessionActivityEvent[] = [];
|
||||
const promoted: Buffer[] = [];
|
||||
const promotionContexts: { sessionId: string; companyId: string }[] = [];
|
||||
// Gate the login after the prompt surfaces, so the row stays in
|
||||
// `waiting_for_user` long enough for the test to read the prompt (twice)
|
||||
// before it releases the gate and lets the login race to `authenticated`.
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
const execGated: ExecBehavior = async ({ onStdout }) => {
|
||||
onStdout(PROMPT_OUTPUT);
|
||||
await gate;
|
||||
return { exitCode: 0 };
|
||||
};
|
||||
const { runtime, deleteCalls } = createFakeRuntime({
|
||||
exec: execSuccess,
|
||||
exec: execGated,
|
||||
authBytes: Buffer.from('{"token":"secret"}'),
|
||||
});
|
||||
const companyId = randomUUID();
|
||||
|
|
@ -344,6 +369,14 @@ describe("device login service", () => {
|
|||
const other = await service.readOwnerSession(session.sessionId, companyId, OWNER_B);
|
||||
expect(other?.prompt).toBeNull();
|
||||
|
||||
// The prompt survives a repeated read while the session is still active. The
|
||||
// read never consumes it, so a page reload does not lose the code.
|
||||
const repeatWhileActive = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(repeatWhileActive?.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
||||
|
||||
// Release the gate, so the login proceeds to the promotion and the
|
||||
// authenticated terminal.
|
||||
releaseGate();
|
||||
const outcome = await completed;
|
||||
expect(outcome.status).toBe("authenticated");
|
||||
expect(outcome.cleanupPending).toBe(false);
|
||||
|
|
@ -357,9 +390,11 @@ describe("device login service", () => {
|
|||
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, companyId, OWNER_A);
|
||||
expect(secondRead?.prompt).toBeNull();
|
||||
// The session is now terminal (authenticated). A read after a terminal
|
||||
// transition returns a null prompt, and it carries `Cache-Control:
|
||||
// no-store, private` at the route layer (see agent-device-login-routes.test.ts).
|
||||
const afterTerminal = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(afterTerminal?.prompt).toBeNull();
|
||||
|
||||
const row = await store.getByPublicId(session.sessionId, companyId);
|
||||
expect(row?.status).toBe("authenticated");
|
||||
|
|
@ -465,11 +500,19 @@ describe("device login service", () => {
|
|||
// the trusted adapter type, so a `grok_local` session runs the Grok parser,
|
||||
// not the Codex parser.
|
||||
const store = createMemoryStore();
|
||||
const execGrokSuccess: ExecBehavior = async ({ onStdout }) => {
|
||||
// Gate the login after the prompt surfaces, so the row stays in
|
||||
// `waiting_for_user` long enough for the test to read the prompt before the
|
||||
// login races to its own terminal.
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
const execGrokGatedSuccess: ExecBehavior = async ({ onStdout }) => {
|
||||
onStdout(GROK_PROMPT_OUTPUT);
|
||||
await gate;
|
||||
return { exitCode: 0 };
|
||||
};
|
||||
const { runtime } = createFakeRuntime({ exec: execGrokSuccess, authBytes: Buffer.from("{}") });
|
||||
const { runtime } = createFakeRuntime({ exec: execGrokGatedSuccess, authBytes: Buffer.from("{}") });
|
||||
const companyId = randomUUID();
|
||||
const service = makeService({ store, runtime });
|
||||
const { session, completed } = await service.start({
|
||||
|
|
@ -485,6 +528,7 @@ describe("device login service", () => {
|
|||
const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(owner?.prompt).toEqual({ url: GROK_DEVICE_LOGIN_URL, code: GROK_CODE });
|
||||
|
||||
releaseGate();
|
||||
await completed;
|
||||
});
|
||||
|
||||
|
|
@ -605,9 +649,17 @@ describe("device login service", () => {
|
|||
|
||||
const store = createMemoryStore();
|
||||
const activity: LoginSessionActivityEvent[] = [];
|
||||
// Gate the login after the prompt surfaces, so the row stays in
|
||||
// `waiting_for_user` long enough for the test to read the prompt before the
|
||||
// login races to its own terminal.
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
releaseGate = resolve;
|
||||
});
|
||||
const { runtime } = createFakeRuntime({
|
||||
exec: async ({ onStdout }) => {
|
||||
onStdout(GROK_PROMPT_OUTPUT);
|
||||
await gate;
|
||||
return { exitCode: 0 };
|
||||
},
|
||||
authBytes: grokAuthBytes,
|
||||
|
|
@ -630,6 +682,7 @@ describe("device login service", () => {
|
|||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const owner = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
releaseGate();
|
||||
const outcome = await completed;
|
||||
expect(outcome.status).toBe("authenticated");
|
||||
|
||||
|
|
@ -811,6 +864,36 @@ describe("device login service", () => {
|
|||
expect(row?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("clears the prompt on a cancellation, so the owner read returns a null prompt with Cache-Control set at the route layer", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime } = createFakeRuntime({ exec: execHang });
|
||||
const service = makeService({ store, runtime });
|
||||
const controller = new AbortController();
|
||||
const companyId = randomUUID();
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// The prompt is present while the session is active.
|
||||
const whileActive = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(whileActive?.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
||||
|
||||
controller.abort();
|
||||
await completed;
|
||||
|
||||
// The session is now terminal (cancelled). The prompt is gone.
|
||||
const afterCancel = await service.readOwnerSession(session.sessionId, companyId, OWNER_A);
|
||||
expect(afterCancel?.status).toBe("cancelled");
|
||||
expect(afterCancel?.prompt).toBeNull();
|
||||
});
|
||||
|
||||
it("releases the lease when a transition fails after acquisition", async () => {
|
||||
const store = createMemoryStore();
|
||||
// A rejecting `recordLeaseAcquired` forces a transition failure right after
|
||||
|
|
@ -1091,6 +1174,79 @@ describe("device login service", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("active session lookup", () => {
|
||||
it("finds the caller's active session for a company and adapter, with no session id", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime } = createFakeRuntime({ exec: execHang });
|
||||
const service = makeService({ store, runtime });
|
||||
const controller = new AbortController();
|
||||
const companyId = randomUUID();
|
||||
const { session, completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
signal: controller.signal,
|
||||
});
|
||||
await waitForStatus(store, session.sessionId, companyId, "waiting_for_user");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
const active = await service.readActiveOwnerSession(companyId, ADAPTER_TYPE, OWNER_A);
|
||||
expect(active?.sessionId).toBe(session.sessionId);
|
||||
expect(active?.prompt).toEqual({ url: DEVICE_LOGIN_URL, code: PROMPT_CODE });
|
||||
|
||||
controller.abort();
|
||||
await completed;
|
||||
});
|
||||
|
||||
it("returns null for another owner, another company, and another adapter", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime } = createFakeRuntime({ exec: execHang });
|
||||
const service = makeService({ store, runtime });
|
||||
const controller = new AbortController();
|
||||
const companyId = randomUUID();
|
||||
await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
expect(await service.readActiveOwnerSession(companyId, ADAPTER_TYPE, OWNER_B)).toBeNull();
|
||||
expect(
|
||||
await service.readActiveOwnerSession(randomUUID(), ADAPTER_TYPE, OWNER_A),
|
||||
).toBeNull();
|
||||
expect(await service.readActiveOwnerSession(companyId, "grok_local", OWNER_A)).toBeNull();
|
||||
|
||||
controller.abort();
|
||||
});
|
||||
|
||||
it("returns null once the caller's session has no active row", async () => {
|
||||
const store = createMemoryStore();
|
||||
const { runtime } = createFakeRuntime({ exec: execHang });
|
||||
const service = makeService({ store, runtime });
|
||||
const controller = new AbortController();
|
||||
const companyId = randomUUID();
|
||||
const { completed } = await service.start({
|
||||
companyId,
|
||||
environmentId: randomUUID(),
|
||||
adapterType: ADAPTER_TYPE,
|
||||
startedByUserId: OWNER_A,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
// No active session for this owner before any start ever ran.
|
||||
expect(await service.readActiveOwnerSession(companyId, ADAPTER_TYPE, OWNER_A)).not.toBeNull();
|
||||
|
||||
controller.abort();
|
||||
await completed;
|
||||
|
||||
// The session is now terminal, so it no longer counts as active.
|
||||
expect(await service.readActiveOwnerSession(companyId, ADAPTER_TYPE, OWNER_A)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("five-minute host timeout", () => {
|
||||
it("holds the session active until exactly five minutes, then times out and deletes", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -9,11 +9,20 @@
|
|||
// - `mode`: "normal" | "malformed-open" | "no-open-reply" | "duplicate-open-reply" |
|
||||
// "exit-before-open-reply"
|
||||
// - `workerSessionId`: the worker session id the open reply returns (default "ws-1")
|
||||
// - `outputs`: an array of `{ chunk, sid? }`. The fixture emits each as an output
|
||||
// notification after the open reply. `sid` defaults to the real worker session
|
||||
// id; a test sets a wrong `sid` to prove the host drops a mismatched
|
||||
// notification.
|
||||
// - `outputs`: an array of `{ chunk, sid?, crossRoute?, omitHostRouteId? }`. The
|
||||
// fixture emits each as an output notification after the open reply. `sid`
|
||||
// defaults to the real worker session id; a test sets a wrong `sid` to prove
|
||||
// the host drops a mismatched notification. `crossRoute: true` sends this
|
||||
// route's own worker session id under a DIFFERENT, already-open route's host
|
||||
// route identifier (any other entry currently registered), so a test proves
|
||||
// the host drops a swapped `(hostRouteId, workerSessionId)` pair instead of
|
||||
// misdelivering it. `omitHostRouteId: true` sends the notification with no
|
||||
// `hostRouteId` field at all, so a test proves the host warns about a plugin
|
||||
// build old enough to omit the field, instead of silently dropping it.
|
||||
// - `exitCode`: when set, the fixture emits an exit notification after the outputs.
|
||||
// - `omitHostRouteIdOnExit`: when true, the main `exitCode` exit notification
|
||||
// carries no `hostRouteId` field, so a test proves the host still resolves
|
||||
// a legacy worker's exit by the worker session id.
|
||||
// - `extraExits`: an array of `{ exitCode, sid? }`. The fixture emits each as a
|
||||
// further exit notification, after the main `exitCode` exit. `sid` defaults
|
||||
// to the real worker session id; a test sets a wrong `sid` to script a worker
|
||||
|
|
@ -43,30 +52,55 @@ function send(message) {
|
|||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||
}
|
||||
|
||||
// Serialize one array of `{ chunk, sid? }` entries as newline-delimited output
|
||||
// notification lines. A test sets a wrong `sid` on one entry to force a
|
||||
// mismatch.
|
||||
function outputLines(entries, workerSessionId) {
|
||||
// Find some other host route identifier the fixture already registered, for
|
||||
// a `crossRoute` output entry. Returns null when no other route is open,
|
||||
// which a well-formed test never triggers.
|
||||
function pickOtherHostRouteId(ownHostRouteId) {
|
||||
for (const key of routes.keys()) {
|
||||
if (key !== ownHostRouteId) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Serialize one array of `{ chunk, sid?, crossRoute?, omitHostRouteId? }`
|
||||
// entries as newline-delimited output notification lines. A test sets a
|
||||
// wrong `sid` on one entry to force a mismatch. `crossRoute: true` stamps
|
||||
// some OTHER already-open route's host route identifier on this route's own
|
||||
// worker session id, so a test proves the host drops a swapped pair. Every
|
||||
// other line echoes this route's own host route identifier, so the host can
|
||||
// route each chunk to its own route when the worker holds more than one.
|
||||
// `omitHostRouteId: true` sends the notification with no `hostRouteId` field
|
||||
// at all, so a test proves the host warns instead of silently dropping it.
|
||||
function outputLines(entries, hostRouteId, workerSessionId) {
|
||||
let lines = "";
|
||||
for (const entry of entries) {
|
||||
const effectiveHostRouteId = entry.crossRoute
|
||||
? pickOtherHostRouteId(hostRouteId) ?? hostRouteId
|
||||
: entry.hostRouteId ?? hostRouteId;
|
||||
const params = {
|
||||
workerSessionId: entry.sid ?? workerSessionId,
|
||||
chunk: entry.chunk,
|
||||
};
|
||||
if (!entry.omitHostRouteId) params.hostRouteId = effectiveHostRouteId;
|
||||
lines += `${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "loginPty.output",
|
||||
params: {
|
||||
workerSessionId: entry.sid ?? workerSessionId,
|
||||
chunk: entry.chunk,
|
||||
},
|
||||
params,
|
||||
})}\n`;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Serialize one exit notification line for the given worker session id.
|
||||
function exitLine(workerSessionId, exitCode) {
|
||||
// Serialize one exit notification line for the given host route and worker
|
||||
// session id. `omit: true` sends the notification with no `hostRouteId`
|
||||
// field at all, matching a plugin build old enough to predate it.
|
||||
function exitLine(hostRouteId, workerSessionId, exitCode, omit) {
|
||||
const params = { workerSessionId, exitCode };
|
||||
if (!omit) params.hostRouteId = hostRouteId;
|
||||
return `${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
method: "loginPty.exit",
|
||||
params: { workerSessionId, exitCode },
|
||||
params,
|
||||
})}\n`;
|
||||
}
|
||||
|
||||
|
|
@ -75,14 +109,18 @@ function exitLine(workerSessionId, exitCode) {
|
|||
// fixed composition otherwise emits, in order: the pre-exit outputs, then
|
||||
// the main exit, then every extra exit, then the post-exit outputs. The
|
||||
// batch mode writes these together with the open reply in one stdout write.
|
||||
function scriptedOutputLines(directive, workerSessionId) {
|
||||
function scriptedOutputLines(directive, hostRouteId, workerSessionId) {
|
||||
if (Array.isArray(directive.sequence)) {
|
||||
let lines = "";
|
||||
for (const entry of directive.sequence) {
|
||||
if (entry.type === "output") {
|
||||
lines += outputLines([{ chunk: entry.chunk, sid: entry.sid }], workerSessionId);
|
||||
lines += outputLines(
|
||||
[{ chunk: entry.chunk, sid: entry.sid, hostRouteId: entry.hostRouteId, crossRoute: entry.crossRoute }],
|
||||
hostRouteId,
|
||||
workerSessionId,
|
||||
);
|
||||
} else if (entry.type === "exit") {
|
||||
lines += exitLine(entry.sid ?? workerSessionId, entry.exitCode);
|
||||
lines += exitLine(entry.hostRouteId ?? hostRouteId, entry.sid ?? workerSessionId, entry.exitCode);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
|
|
@ -92,14 +130,14 @@ function scriptedOutputLines(directive, workerSessionId) {
|
|||
? directive.outputsAfterExit
|
||||
: [];
|
||||
const extraExits = Array.isArray(directive.extraExits) ? directive.extraExits : [];
|
||||
let lines = outputLines(outputs, workerSessionId);
|
||||
let lines = outputLines(outputs, hostRouteId, workerSessionId);
|
||||
if (typeof directive.exitCode === "number") {
|
||||
lines += exitLine(workerSessionId, directive.exitCode);
|
||||
lines += exitLine(hostRouteId, workerSessionId, directive.exitCode, directive.omitHostRouteIdOnExit);
|
||||
}
|
||||
for (const exit of extraExits) {
|
||||
lines += exitLine(exit.sid ?? workerSessionId, exit.exitCode);
|
||||
lines += exitLine(exit.hostRouteId ?? hostRouteId, exit.sid ?? workerSessionId, exit.exitCode);
|
||||
}
|
||||
lines += outputLines(outputsAfterExit, workerSessionId);
|
||||
lines += outputLines(outputsAfterExit, hostRouteId, workerSessionId);
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +194,7 @@ rl.on("line", (line) => {
|
|||
// Emit the scripted pre-bind outputs, then exit with no open reply. The
|
||||
// route never binds, so a test proves the worker-exit path clears the
|
||||
// pre-bind queue.
|
||||
process.stdout.write(scriptedOutputLines(directive, workerSessionId));
|
||||
process.stdout.write(scriptedOutputLines(directive, params.hostRouteId, workerSessionId));
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
|
@ -176,7 +214,9 @@ rl.on("line", (line) => {
|
|||
// notification arrives before the route binds — even a malformed reply
|
||||
// that never binds. The host must queue and, on a bind, replay it; on a
|
||||
// malformed reply, it must clear the queue instead.
|
||||
process.stdout.write(openReplyLine + scriptedOutputLines(directive, workerSessionId));
|
||||
process.stdout.write(
|
||||
openReplyLine + scriptedOutputLines(directive, params.hostRouteId, workerSessionId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -193,7 +233,7 @@ rl.on("line", (line) => {
|
|||
// Emit the scripted output and the exit after the open reply, so the host
|
||||
// binds the route first.
|
||||
setImmediate(() => {
|
||||
process.stdout.write(scriptedOutputLines(directive, workerSessionId));
|
||||
process.stdout.write(scriptedOutputLines(directive, params.hostRouteId, workerSessionId));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -201,12 +241,16 @@ rl.on("line", (line) => {
|
|||
if (method === "loginPtyInput") {
|
||||
// Echo the input back as one output notification for the bound session, so a
|
||||
// test proves the input reaches the worker and the output routes back.
|
||||
for (const entry of routes.values()) {
|
||||
for (const [hostRouteId, entry] of routes.entries()) {
|
||||
if (entry.workerSessionId === params.workerSessionId) {
|
||||
send({
|
||||
jsonrpc: "2.0",
|
||||
method: "loginPty.output",
|
||||
params: { workerSessionId: entry.workerSessionId, chunk: `echo:${params.data}` },
|
||||
params: {
|
||||
hostRouteId,
|
||||
workerSessionId: entry.workerSessionId,
|
||||
chunk: `echo:${params.data}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ vi.mock("../middleware/logger.js", () => {
|
|||
import { logger } from "../middleware/logger.js";
|
||||
import {
|
||||
appendStderrExcerpt,
|
||||
createDuplexRouteSlotController,
|
||||
createPluginWorkerHandle,
|
||||
formatWorkerFailureMessage,
|
||||
resolveRpcCallTimeoutMs,
|
||||
|
|
@ -1169,24 +1170,21 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("permits one active credential pseudo-terminal per worker", async () => {
|
||||
it("permits two concurrent credential pseudo-terminals on one worker", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// Neither open waits on the other. Both return a live route on the same
|
||||
// worker, so a second owner is never blocked by a first owner's session.
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
ptyOpenInput({ mode: "normal", workerSessionId: "ws-A" }),
|
||||
);
|
||||
// A second open while the first route is not closed rejects with one fixed
|
||||
// non-secret error before it reaches the worker.
|
||||
await expect(
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow("LOGIN_PTY_ROUTE_BUSY");
|
||||
await first.close();
|
||||
// After the first route closes and the worker acknowledges the close, a new
|
||||
// open is admitted.
|
||||
const second = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ mode: "normal" }),
|
||||
ptyOpenInput({ mode: "normal", workerSessionId: "ws-B" }),
|
||||
);
|
||||
expect(first).toBeDefined();
|
||||
expect(second).toBeDefined();
|
||||
await first.close();
|
||||
await second.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
|
|
@ -1365,7 +1363,526 @@ describe("plugin worker manager setup-token pty route gate", () => {
|
|||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login pseudo-terminal pre-bind queue (GH #12122 / PAP-5132)
|
||||
// Login pseudo-terminal concurrency
|
||||
// ---------------------------------------------------------------------------
|
||||
// One shared plugin worker now holds more than one live login pseudo-terminal
|
||||
// route at once, so a second owner is never blocked by a first owner's
|
||||
// session. The host resolves an output or an exit notification by the host
|
||||
// route identifier first, then checks the bound worker session identifier,
|
||||
// so each route receives only its own data even while another route on the
|
||||
// same worker is live.
|
||||
|
||||
describe("plugin worker manager login pseudo-terminal concurrency", () => {
|
||||
it("delivers output to each concurrent route only, with no cross-talk", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [{ chunk: "a-1" }],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-B",
|
||||
outputs: [{ chunk: "b-1" }],
|
||||
exitCode: 0,
|
||||
}),
|
||||
);
|
||||
const aChunks: string[] = [];
|
||||
const bChunks: string[] = [];
|
||||
routeA.onData((chunk) => aChunks.push(chunk));
|
||||
routeB.onData((chunk) => bChunks.push(chunk));
|
||||
await expect(routeA.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
await expect(routeB.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
// The host resolves each notification by its own host route identifier, so
|
||||
// neither route ever sees the other's chunk.
|
||||
expect(aChunks).toEqual(["a-1"]);
|
||||
expect(bChunks).toEqual(["b-1"]);
|
||||
await routeA.close();
|
||||
await routeB.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles the exit of one route only, while a second concurrent route stays live", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B", exitCode: 0 }),
|
||||
);
|
||||
await expect(routeB.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
// Route A never received an exit, so its wait is still pending. Race it
|
||||
// against a short delay to prove it has not settled.
|
||||
const stillPending = await Promise.race([
|
||||
routeA.wait().then(() => "settled"),
|
||||
new Promise((resolve) => setTimeout(() => resolve("pending"), 50)),
|
||||
]);
|
||||
expect(stillPending).toBe("pending");
|
||||
await routeA.close();
|
||||
await routeB.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a swapped (hostRouteId, workerSessionId) notification and delivers it to neither route", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
// Route B emits one output that carries route A's host route identifier
|
||||
// but route B's own worker session identifier — a swapped pair. The host
|
||||
// resolves route A by the host route identifier, then finds route B's
|
||||
// worker session identifier does not match route A's bound identifier,
|
||||
// so it drops the notification. It never reaches route A, and route B
|
||||
// never sent it under its own host route identifier, so it never
|
||||
// reaches route B either.
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-B",
|
||||
outputs: [{ chunk: "swapped", crossRoute: true }],
|
||||
}),
|
||||
);
|
||||
const aChunks: string[] = [];
|
||||
const bChunks: string[] = [];
|
||||
routeA.onData((chunk) => aChunks.push(chunk));
|
||||
routeB.onData((chunk) => bChunks.push(chunk));
|
||||
// Give the swapped notification time to arrive and be dropped.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
expect(aChunks).toEqual([]);
|
||||
expect(bChunks).toEqual([]);
|
||||
await routeA.close();
|
||||
await routeB.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops an output notification for an unknown host route identifier", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const session = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
sequence: [
|
||||
{ type: "output", chunk: "ghost", hostRouteId: "totally-unknown-route" },
|
||||
{ type: "output", chunk: "good" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const chunks: string[] = [];
|
||||
session.onData((chunk) => chunks.push(chunk));
|
||||
await vi.waitFor(() => expect(chunks).toContain("good"));
|
||||
// The unknown host route identifier resolves to no route on this worker,
|
||||
// so the host drops it before it can reach any listener.
|
||||
expect(chunks).toEqual(["good"]);
|
||||
await session.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("replays a pre-bind output record to its own route while a second route is already open", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
// Route B's fixture writes its open reply and its output notification in
|
||||
// one stdout write, so the host reads both before route B's bind, while
|
||||
// route A is already open. The bind replays the held record into route
|
||||
// B's own queue; it never reaches route A.
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
batchWithOpenReply: true,
|
||||
workerSessionId: "ws-B",
|
||||
outputs: [{ chunk: "b-batched" }],
|
||||
}),
|
||||
);
|
||||
const aChunks: string[] = [];
|
||||
const bChunks: string[] = [];
|
||||
routeA.onData((chunk) => aChunks.push(chunk));
|
||||
routeB.onData((chunk) => bChunks.push(chunk));
|
||||
expect(aChunks).toEqual([]);
|
||||
expect(bChunks).toEqual(["b-batched"]);
|
||||
await routeA.close();
|
||||
await routeB.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("closes one route without affecting a second concurrent route on the same worker", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B" }),
|
||||
);
|
||||
await routeA.close();
|
||||
// Route B is still live: writing to it still round-trips through the
|
||||
// worker and back to the listener.
|
||||
const bChunks: string[] = [];
|
||||
routeB.onData((chunk) => bChunks.push(chunk));
|
||||
routeB.write("still-here");
|
||||
await vi.waitFor(() => expect(bChunks).toContain("echo:still-here"));
|
||||
await routeB.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles both concurrent routes exactly once when the worker exits", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B" }),
|
||||
);
|
||||
const waitA = routeA.wait();
|
||||
const waitB = routeB.wait();
|
||||
await handle.stop();
|
||||
// The worker exit closes every route it still holds and resolves each
|
||||
// login wait with the fixed non-secret exit, in one sweep.
|
||||
await expect(waitA).resolves.toEqual({ exitCode: null });
|
||||
await expect(waitB).resolves.toEqual({ exitCode: null });
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("retires the worker on an unconfirmed close, settling every concurrent route and logging no raw output", async () => {
|
||||
const handle = makeLoginPtyHandle({
|
||||
loginPtyLimits: { closeTimeoutMs: 200 },
|
||||
});
|
||||
const secretMarker = "super-secret-login-code-must-never-reach-a-log-line";
|
||||
vi.mocked(logger.warn).mockClear();
|
||||
vi.mocked(logger.error).mockClear();
|
||||
vi.mocked(logger.info).mockClear();
|
||||
vi.mocked(logger.debug).mockClear();
|
||||
try {
|
||||
await handle.start();
|
||||
const exited = new Promise<void>((resolve) => {
|
||||
handle.on("exit", () => resolve());
|
||||
});
|
||||
const routeA = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A", closeMode: "bad-ack" }),
|
||||
);
|
||||
const routeB = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B", outputs: [{ chunk: secretMarker }] }),
|
||||
);
|
||||
const waitB = routeB.wait();
|
||||
await routeA.close();
|
||||
// Route A's close acknowledgement carried a mismatched host route id, so
|
||||
// the host fails closed and retires the whole worker before any reuse.
|
||||
// That retirement settles route B too, even though B's own close was
|
||||
// never called.
|
||||
await exited;
|
||||
await expect(waitB).resolves.toEqual({ exitCode: null });
|
||||
await expect(
|
||||
handle.openLoginPtySession(ptyOpenInput({ mode: "normal" })),
|
||||
).rejects.toThrow();
|
||||
const loggedText = [
|
||||
...vi.mocked(logger.warn).mock.calls,
|
||||
...vi.mocked(logger.error).mock.calls,
|
||||
...vi.mocked(logger.info).mock.calls,
|
||||
...vi.mocked(logger.debug).mock.calls,
|
||||
]
|
||||
.flat()
|
||||
.map((arg) => JSON.stringify(arg))
|
||||
.join("\n");
|
||||
expect(loggedText).not.toContain(secretMarker);
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The missing-hostRouteId diagnostic. `hostRouteId` is the routing key the
|
||||
// current protocol tags every login pseudo-terminal notification with, but a
|
||||
// plugin build old enough to predate it still tags the same notification with
|
||||
// the worker session identifier, the sole routing key the previous protocol
|
||||
// used. The host warns once about the old build, then still resolves the
|
||||
// route by that identifier, so a legacy worker keeps its login output and its
|
||||
// exit notification instead of losing them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("plugin worker manager login pseudo-terminal missing hostRouteId diagnostic", () => {
|
||||
it("delivers output from a legacy worker with no hostRouteId, resolved by the worker session id, and warns once", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
vi.mocked(logger.warn).mockClear();
|
||||
try {
|
||||
await handle.start();
|
||||
const chunks: string[] = [];
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [{ chunk: "legacy-output", omitHostRouteId: true }],
|
||||
}),
|
||||
);
|
||||
route.onData((chunk) => chunks.push(chunk));
|
||||
await vi.waitFor(() => expect(chunks).toContain("legacy-output"));
|
||||
|
||||
const warnCalls = vi.mocked(logger.warn).mock.calls.flat().map((arg) => JSON.stringify(arg));
|
||||
const matches = warnCalls.filter((call) => call.includes("hostRouteId"));
|
||||
expect(matches).toHaveLength(1);
|
||||
expect(matches[0]).toContain("plugin build is too old");
|
||||
// No identifier and no raw output in the warning.
|
||||
expect(matches[0]).not.toContain("legacy-output");
|
||||
expect(matches[0]).not.toContain("ws-A");
|
||||
|
||||
await route.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("settles the login wait on a legacy worker's exit notification with no hostRouteId", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0, omitHostRouteIdOnExit: true }),
|
||||
);
|
||||
await expect(route.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("repeats the warning no more than one time for one worker", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
vi.mocked(logger.warn).mockClear();
|
||||
try {
|
||||
await handle.start();
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [
|
||||
{ chunk: "one", omitHostRouteId: true },
|
||||
{ chunk: "two", omitHostRouteId: true },
|
||||
{ chunk: "three", omitHostRouteId: true },
|
||||
],
|
||||
}),
|
||||
);
|
||||
// Give every notification time to arrive.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
|
||||
const warnCalls = vi.mocked(logger.warn).mock.calls.flat().map((arg) => JSON.stringify(arg));
|
||||
const matches = warnCalls.filter((call) => call.includes("hostRouteId"));
|
||||
expect(matches).toHaveLength(1);
|
||||
|
||||
await route.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a message with no hostRouteId and no worker session id", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
const chunks: string[] = [];
|
||||
const route = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-A",
|
||||
outputs: [
|
||||
{ chunk: "unroutable", omitHostRouteId: true, sid: "" },
|
||||
{ chunk: "good" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
route.onData((chunk) => chunks.push(chunk));
|
||||
await vi.waitFor(() => expect(chunks).toContain("good"));
|
||||
expect(chunks).toEqual(["good"]);
|
||||
await route.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a no-hostRouteId message naming a concurrent route's session instead of cross-delivering it", async () => {
|
||||
const handle = makeLoginPtyHandle();
|
||||
try {
|
||||
await handle.start();
|
||||
// Two live routes share this worker. A worker session id alone cannot
|
||||
// prove which route a hostRouteId-less message belongs to once a
|
||||
// second route is live, so the fallback must refuse to guess.
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const second = await handle.openLoginPtySession(
|
||||
ptyOpenInput({
|
||||
workerSessionId: "ws-B",
|
||||
outputs: [{ chunk: "cross-route", sid: "ws-A", omitHostRouteId: true }],
|
||||
}),
|
||||
);
|
||||
const firstChunks: string[] = [];
|
||||
const secondChunks: string[] = [];
|
||||
first.onData((chunk) => firstChunks.push(chunk));
|
||||
second.onData((chunk) => secondChunks.push(chunk));
|
||||
// Give the ambiguous notification time to arrive and be dropped.
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
expect(firstChunks).toEqual([]);
|
||||
expect(secondChunks).toEqual([]);
|
||||
|
||||
await first.close();
|
||||
await second.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The process-wide login pseudo-terminal route ceiling. A host process can
|
||||
// run only so many concurrent login pseudo-terminal routes before the
|
||||
// underlying resources are exhausted, so the ceiling protects the host
|
||||
// process itself, not one user or one worker.
|
||||
// ---------------------------------------------------------------------------
|
||||
// Every login pseudo-terminal route reserves one slot from the same
|
||||
// process-wide aggregate route-slot controller the duplex channel route
|
||||
// uses (`createDuplexRouteSlotController`), so the two route types share one
|
||||
// ceiling. This is a host-process safety ceiling across every worker in the
|
||||
// process, not a per-user or a per-worker quota.
|
||||
|
||||
describe("plugin worker manager login pseudo-terminal route ceiling", () => {
|
||||
it("rejects the second open with the fixed capacity error before the worker call, when the process-wide ceiling is full", async () => {
|
||||
// A process-scoped ceiling of one slot. The manager injects one shared
|
||||
// controller into every worker; the test injects a small one directly —
|
||||
// the same controller shape the duplex channel route shares.
|
||||
const handle = makeLoginPtyHandle({
|
||||
duplexRouteSlots: createDuplexRouteSlotController(1),
|
||||
loginPtyLimits: { openTimeoutMs: 300 },
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
// The refused open scripts `no-open-reply`: if it wrongly reached the
|
||||
// worker, the promise would only settle after the 300ms open timeout,
|
||||
// and with a different, timeout-shaped error — not the capacity error.
|
||||
// A fast rejection with the exact capacity error proves the worker
|
||||
// never received this open's request.
|
||||
await expect(
|
||||
handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B", mode: "no-open-reply" }),
|
||||
),
|
||||
).rejects.toThrow("LOGIN_PTY_ROUTES_AT_CAPACITY");
|
||||
expect(Date.now() - startedAt).toBeLessThan(150);
|
||||
await first.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("admits a later open after the first route closes and releases its slot", async () => {
|
||||
const handle = makeLoginPtyHandle({
|
||||
duplexRouteSlots: createDuplexRouteSlotController(1),
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
await expect(
|
||||
handle.openLoginPtySession(ptyOpenInput({ workerSessionId: "ws-B" })),
|
||||
).rejects.toThrow("LOGIN_PTY_ROUTES_AT_CAPACITY");
|
||||
// Closing the first route releases its slot, so a later open is admitted.
|
||||
await first.close();
|
||||
const second = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-C" }),
|
||||
);
|
||||
await second.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("admits a later open on a different worker after a worker exit releases the shared slot", async () => {
|
||||
// One shared controller instance, the same shape the manager injects into
|
||||
// every worker handle, so both handles below draw from the SAME ceiling.
|
||||
const sharedSlots = createDuplexRouteSlotController(1);
|
||||
const handle = makeLoginPtyHandle({ duplexRouteSlots: sharedSlots });
|
||||
const secondHandle = makeLoginPtyHandle({ duplexRouteSlots: sharedSlots });
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A" }),
|
||||
);
|
||||
const waitResult = first.wait();
|
||||
await handle.stop();
|
||||
// The worker exit settles the route and releases its slot.
|
||||
await expect(waitResult).resolves.toEqual({ exitCode: null });
|
||||
// Before the release, a second worker's open against the same shared
|
||||
// ceiling would have rejected with the capacity error. After the
|
||||
// release, the shared ceiling of one admits a fresh open on a
|
||||
// different worker.
|
||||
await secondHandle.start();
|
||||
const second = await secondHandle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B" }),
|
||||
);
|
||||
await second.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
await secondHandle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
it("releases exactly one slot when a route's terminal exit is followed by an explicit close", async () => {
|
||||
// A double release (once on the terminal exit, once on the later close)
|
||||
// would let a third open through a ceiling of one before its true
|
||||
// capacity. This test proves the ceiling still holds after both events.
|
||||
const handle = makeLoginPtyHandle({
|
||||
duplexRouteSlots: createDuplexRouteSlotController(1),
|
||||
});
|
||||
try {
|
||||
await handle.start();
|
||||
const first = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-A", exitCode: 0 }),
|
||||
);
|
||||
await expect(first.wait()).resolves.toEqual({ exitCode: 0 });
|
||||
// The terminal exit already released the one slot. A second open now
|
||||
// succeeds, consuming that same slot.
|
||||
const second = await handle.openLoginPtySession(
|
||||
ptyOpenInput({ workerSessionId: "ws-B" }),
|
||||
);
|
||||
// The explicit close on the first (already-exited) route must not
|
||||
// release a second slot it no longer holds.
|
||||
await first.close();
|
||||
await expect(
|
||||
handle.openLoginPtySession(ptyOpenInput({ workerSessionId: "ws-C" })),
|
||||
).rejects.toThrow("LOGIN_PTY_ROUTES_AT_CAPACITY");
|
||||
await second.close();
|
||||
} finally {
|
||||
await handle.stop().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login pseudo-terminal pre-bind queue
|
||||
// ---------------------------------------------------------------------------
|
||||
// The host reads the worker pipe and `readline` dispatches every line of one
|
||||
// chunk synchronously. The route only becomes `open` inside the `await`
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ import {
|
|||
SetupTokenSessionError,
|
||||
assessConfidentialStartup,
|
||||
evaluateConfidentialTransport,
|
||||
isTerminalSessionState,
|
||||
SETUP_TOKEN_START_FAILED,
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
SETUP_TOKEN_PROVIDER_UNSUPPORTED,
|
||||
|
|
@ -571,6 +572,28 @@ export function agentRoutes(
|
|||
row.boundAt = Date.now();
|
||||
return { ...row };
|
||||
},
|
||||
async cancelDurable(identity, cancellableStates): Promise<SetupTokenCleanupRecord | null> {
|
||||
const row = setupTokenCleanupRows.get(identity.sessionId);
|
||||
if (!row || !scopeMatchesRow(row, identity) || !cancellableStates.includes(row.state)) {
|
||||
return null;
|
||||
}
|
||||
row.state = "cancelled";
|
||||
return { ...row };
|
||||
},
|
||||
async findActiveDurable(key, now): Promise<SetupTokenCleanupRecord | null> {
|
||||
for (const row of setupTokenCleanupRows.values()) {
|
||||
if (
|
||||
row.companyId === key.companyId &&
|
||||
row.ownerUserId === key.ownerUserId &&
|
||||
row.adapterType === key.adapterType &&
|
||||
!isTerminalSessionState(row.state) &&
|
||||
row.deadline > now
|
||||
) {
|
||||
return { ...row };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const deferredSetupTokenLoginFactory: SetupTokenLoginProcessFactory = () => {
|
||||
|
|
@ -3328,8 +3351,37 @@ export function agentRoutes(
|
|||
},
|
||||
);
|
||||
|
||||
// Read the caller's active login session for one company and adapter, with no
|
||||
// session id. The browser rediscovers its own session after a reload with no
|
||||
// local state. A non-owner, a foreign company, an unknown adapter, and no
|
||||
// active session all receive the same 404.
|
||||
//
|
||||
// This route registers before the `:sessionId` route below, so Express
|
||||
// never matches the literal `active` segment as a session id.
|
||||
router.get(
|
||||
"/companies/:companyId/adapters/:type/login-sessions/active",
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const type = req.params.type as string;
|
||||
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
|
||||
assertDeviceLoginAdapter(type);
|
||||
res.setHeader("Cache-Control", "no-store, private");
|
||||
|
||||
const owner = await adapterLoginService.readActiveOwnerSession(companyId, type, ownerUserId);
|
||||
if (!owner) {
|
||||
res.status(404).json({ error: "Adapter login session not found" });
|
||||
return;
|
||||
}
|
||||
res.json(owner);
|
||||
},
|
||||
);
|
||||
|
||||
// Read a login session. The owner receives the status and the one-time prompt.
|
||||
// A non-owner or a cross-company caller receives a 404.
|
||||
// A non-owner or a cross-company caller receives a 404. The owner response
|
||||
// repeats the live prompt on every read while the session holds an active
|
||||
// public status, so this sets the same private no-store policy as the
|
||||
// active-session route above: a shared or a browser cache never stores the
|
||||
// authenticated response between one poll and the next.
|
||||
router.get(
|
||||
"/companies/:companyId/adapters/:type/login-sessions/:sessionId",
|
||||
async (req, res) => {
|
||||
|
|
@ -3338,6 +3390,7 @@ export function agentRoutes(
|
|||
const sessionId = req.params.sessionId as string;
|
||||
const ownerUserId = await assertCanManageAdapterLogin(req, companyId);
|
||||
assertDeviceLoginAdapter(type);
|
||||
res.setHeader("Cache-Control", "no-store, private");
|
||||
|
||||
const owner = await readOwnerLoginSession(companyId, type, sessionId, ownerUserId);
|
||||
if (!owner) {
|
||||
|
|
@ -5662,6 +5715,41 @@ export function agentRoutes(
|
|||
}
|
||||
});
|
||||
|
||||
// Read the caller's active Claude setup-token login session, with no session
|
||||
// id. The browser rediscovers its own session after a reload with no local
|
||||
// state. The response carries the panel mode and the one-time prompt, the
|
||||
// same owner response shape the start route returns. A caller with no active
|
||||
// session receives the same fixed not-found error as a foreign session.
|
||||
//
|
||||
// This route registers before the `:sessionId` route below, so Express never
|
||||
// matches the literal `active` segment as a session id.
|
||||
router.get("/companies/:companyId/setup-token-login-sessions/active", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
if (ownerUserId === null) return;
|
||||
res.setHeader("Cache-Control", "no-store, private");
|
||||
const descriptor = await setupTokenLoginService.findActiveByScope(
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
if (!descriptor) {
|
||||
res.status(404).json({ error: SETUP_TOKEN_SESSION_NOT_FOUND });
|
||||
return;
|
||||
}
|
||||
// Read the panel mode from the adapter capability, the same way the start
|
||||
// route does. The full login URL rides in this response, guarded by the
|
||||
// same transport advisory the prompt route attaches.
|
||||
const panelMode =
|
||||
getRegistryLoginCapability(SETUP_TOKEN_ADAPTER_TYPE)?.panelMode ?? "submitted_browser_code";
|
||||
const body: ClaudeSetupTokenSessionOwnerResponse = {
|
||||
...toClaudePublicResponse(descriptor),
|
||||
panelMode,
|
||||
prompt: descriptor.loginUrl
|
||||
? { authorizationUrl: descriptor.loginUrl, transportAdvisory: assessSetupTokenTransport(req) }
|
||||
: null,
|
||||
};
|
||||
res.json(body);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/setup-token-login-sessions/:sessionId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const ownerUserId = resolveCompanySessionOwner(req, companyId, res);
|
||||
|
|
@ -5779,11 +5867,14 @@ export function agentRoutes(
|
|||
res.setHeader("Cache-Control", "no-store");
|
||||
const sessionId = req.params.sessionId as string;
|
||||
try {
|
||||
const scope = setupTokenLoginService.resolveCompanyScope(
|
||||
// `cancelByScope` tries the live in-memory session first, then falls back
|
||||
// to a durable-only cancel when no live session matches — for example,
|
||||
// after a restart drops the in-memory session but the durable row still
|
||||
// holds the company slot.
|
||||
await setupTokenLoginService.cancelByScope(
|
||||
sessionId,
|
||||
companySetupTokenKey(companyId, ownerUserId),
|
||||
);
|
||||
await setupTokenLoginService.cancel(sessionId, scope);
|
||||
res.status(200).json({});
|
||||
} catch (err) {
|
||||
// Cancel is idempotent. The service removes a session when it reaches a
|
||||
|
|
|
|||
|
|
@ -2265,6 +2265,17 @@ registry.registerPath({
|
|||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/active",
|
||||
tags: ["adapters"],
|
||||
summary: "Read the caller's active adapter device login session",
|
||||
request: {
|
||||
params: z.object({ companyId: z.string(), type: z.string() }),
|
||||
},
|
||||
responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound },
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/adapters/{type}/login-sessions/{sessionId}",
|
||||
|
|
@ -4957,6 +4968,20 @@ registry.registerPath({
|
|||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/active",
|
||||
tags: ["companies"],
|
||||
summary: "Read the caller's active Claude setup-token login session",
|
||||
request: { params: z.object({ companyId: z.string() }) },
|
||||
responses: {
|
||||
200: r.ok(claudeSetupTokenSessionOwnerResponseSchema),
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/setup-token-login-sessions/{sessionId}",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import {
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
SETUP_TOKEN_PROVIDER_UNSUPPORTED,
|
||||
isTerminalSessionState,
|
||||
type SetupTokenCleanupRecord,
|
||||
type SetupTokenCleanupStore,
|
||||
type SetupTokenLeaseManager,
|
||||
|
|
@ -289,6 +290,26 @@ function buildTransport(opts: { onSubmit?: "complete" | "throw" | "pending" } =
|
|||
row.boundAt = Date.now();
|
||||
return { ...row };
|
||||
},
|
||||
async cancelDurable(identity, cancellableStates) {
|
||||
const row = rows.get(identity.sessionId);
|
||||
if (!row || !cancellableStates.includes(row.state)) return null;
|
||||
row.state = "cancelled";
|
||||
return { ...row };
|
||||
},
|
||||
async findActiveDurable(key, now) {
|
||||
for (const row of rows.values()) {
|
||||
if (
|
||||
row.companyId === key.companyId &&
|
||||
row.ownerUserId === key.ownerUserId &&
|
||||
row.adapterType === key.adapterType &&
|
||||
!isTerminalSessionState(row.state) &&
|
||||
row.deadline > now
|
||||
) {
|
||||
return { ...row };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const leases: SetupTokenLeaseManager = {
|
||||
async acquire() {
|
||||
|
|
@ -639,6 +660,50 @@ describe("company-and-environment setup-token route — object-level authorizati
|
|||
expect(res.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("returns the caller's active session with no session id in the URL", async () => {
|
||||
const transport = buildTransport({ onSubmit: "pending" });
|
||||
const { app } = await createApp({ transport });
|
||||
|
||||
const startRes = await startCompanySession(app);
|
||||
const sessionId = startRes.body.sessionId as string;
|
||||
|
||||
const active = await request(app).get(`${COMPANY_BASE}/active`).send();
|
||||
expect(active.status, JSON.stringify(active.body)).toBe(200);
|
||||
expect(active.body.sessionId).toBe(sessionId);
|
||||
expect(active.body.status).toBe("waiting_for_user");
|
||||
expect(active.body.panelMode).toBe("submitted_browser_code");
|
||||
// The full login URL rides in this owner response, the same way it rides in
|
||||
// the guarded `.../prompt` response — this is not a public, secret-free
|
||||
// surface, so it is not checked against `expectNoSecret`.
|
||||
expect(active.body.prompt).toEqual({ authorizationUrl: FULL_LOGIN_URL, transportAdvisory: null });
|
||||
expect(active.headers["cache-control"]).toBe("no-store, private");
|
||||
});
|
||||
|
||||
it("returns the identical not-found on the active route for no active session, a cross-owner caller, and a cross-company caller", async () => {
|
||||
const transport = buildTransport({ onSubmit: "pending" });
|
||||
const { app } = await createApp({ transport });
|
||||
|
||||
// No session has started yet.
|
||||
const none = await request(app).get(`${COMPANY_BASE}/active`).send();
|
||||
expect(none.status).toBe(404);
|
||||
expect(none.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
|
||||
await startCompanySession(app);
|
||||
|
||||
// A different board user in the same company holds no active session.
|
||||
useOwner(OTHER_USER_ID);
|
||||
const otherOwner = await request(app).get(`${COMPANY_BASE}/active`).send();
|
||||
expect(otherOwner.status).toBe(404);
|
||||
expect(otherOwner.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
useOwner();
|
||||
|
||||
// The same owner under a different company holds no active session there.
|
||||
const crossCompanyBase = `/api/companies/${OTHER_COMPANY_ID}/setup-token-login-sessions`;
|
||||
const otherCompany = await request(app).get(`${crossCompanyBase}/active`).send();
|
||||
expect(otherCompany.status).toBe(404);
|
||||
expect(otherCompany.body.error).toBe(SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
});
|
||||
|
||||
it("returns the fixed not-found for a non-member on every action across a company boundary", async () => {
|
||||
const transport = buildTransport({ onSubmit: "complete" });
|
||||
const { app } = await createApp({ transport });
|
||||
|
|
|
|||
|
|
@ -342,6 +342,17 @@ export interface AdapterAuthSessionStore {
|
|||
companyId: string,
|
||||
adapterType?: AgentAdapterType,
|
||||
): Promise<AdapterAuthSessionRow | null>;
|
||||
/**
|
||||
* Read the active session for one company, owner, and adapter, with no
|
||||
* session id. The predicate matches the same three columns as the active-slot
|
||||
* partial unique index, so at most one row can match. It returns null when the
|
||||
* owner holds no active session for the adapter.
|
||||
*/
|
||||
getActiveByOwner(
|
||||
companyId: string,
|
||||
startedByUserId: string,
|
||||
adapterType: AgentAdapterType,
|
||||
): Promise<AdapterAuthSessionRow | null>;
|
||||
/** Run `fn` while the process holds the promotion critical-section lock for the
|
||||
* company, owner, and adapter slot. The reaper reclaims a stale `promoting`
|
||||
* row inside this lock, so a reclaim never interleaves with a live credential
|
||||
|
|
@ -585,6 +596,24 @@ export function createDbAdapterAuthSessionStore(
|
|||
const row = rows[0];
|
||||
return row ? toRow(row) : null;
|
||||
},
|
||||
async getActiveByOwner(companyId, startedByUserId, adapterType) {
|
||||
// The predicate matches the same three columns as the active-slot partial
|
||||
// unique index, plus the active-status set, so at most one row can match.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(adapterAuthSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(adapterAuthSessions.companyId, companyId),
|
||||
eq(adapterAuthSessions.startedByUserId, startedByUserId),
|
||||
eq(adapterAuthSessions.adapterType, adapterType),
|
||||
inArray(adapterAuthSessions.status, [...ADAPTER_AUTH_ACTIVE_STATUSES]),
|
||||
),
|
||||
)
|
||||
.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
|
||||
|
|
@ -1159,6 +1188,9 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
|
|||
activity: (phase: LoginSessionActivityPhase) => void;
|
||||
}): Promise<DeviceLoginOutcome> {
|
||||
const { sessionId, lease, activity } = ctx;
|
||||
// The reaper already reclaimed the row, so this process no longer owns the
|
||||
// slot. Delete a prompt this process may still hold for it.
|
||||
promptsBySession.delete(sessionId);
|
||||
const observation = await observeSandboxDelete(() => lease.deleteSandbox());
|
||||
if (observation.confirmed) {
|
||||
activity("sandbox_deleted");
|
||||
|
|
@ -1193,6 +1225,11 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
|
|||
const { sessionId, lease, terminal, reason, expectedStatuses, conditionalTransition, activity } =
|
||||
ctx;
|
||||
|
||||
// Delete the in-memory prompt on every terminal transition this function
|
||||
// handles: an authenticated success, a failure, a timeout, and a
|
||||
// cancellation. A terminal response then always carries a null prompt.
|
||||
promptsBySession.delete(sessionId);
|
||||
|
||||
// The cleanup-state handoff. The service owns and observes the provider
|
||||
// delete on every terminal path. The reaper path shares the same delete
|
||||
// observation and the same durable-write choice.
|
||||
|
|
@ -1233,6 +1270,30 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
|
|||
};
|
||||
}
|
||||
|
||||
// Build the owner response for a row. The prompt survives every read while
|
||||
// the session holds an active public status: the read never deletes it. A
|
||||
// terminal transition deletes the prompt at its own write site instead (see
|
||||
// `terminate`, `abandonAfterLostClaim`, and `cancelOwnerSession`), so a
|
||||
// terminal response always carries a null prompt.
|
||||
function buildOwnerResponse(
|
||||
row: AdapterAuthSessionRow,
|
||||
requestingUserId: string,
|
||||
): AdapterAuthSessionOwnerResponse {
|
||||
const isOwner = row.startedByUserId === requestingUserId;
|
||||
const status = resolvePublicStatus(row);
|
||||
// The prompt map keys on the internal id, so read it by `row.id`, not the
|
||||
// public id. Only the owner principal ever reads the prompt.
|
||||
const prompt = isOwner ? promptsBySession.get(row.id) ?? null : null;
|
||||
return {
|
||||
sessionId: row.publicSessionId,
|
||||
environmentId: row.environmentId,
|
||||
status,
|
||||
expiresAt: row.expiresAt?.toISOString() ?? null,
|
||||
failure: buildFailure(row, status),
|
||||
prompt: prompt ? { url: prompt.url, code: prompt.code } : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function readOwnerSession(
|
||||
publicSessionId: string,
|
||||
companyId: string,
|
||||
|
|
@ -1242,26 +1303,21 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
|
|||
// 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. 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(row.id) ?? null;
|
||||
if (prompt) promptsBySession.delete(row.id);
|
||||
}
|
||||
return {
|
||||
sessionId: row.publicSessionId,
|
||||
environmentId: row.environmentId,
|
||||
status,
|
||||
expiresAt: row.expiresAt?.toISOString() ?? null,
|
||||
failure: buildFailure(row, status),
|
||||
prompt: prompt ? { url: prompt.url, code: prompt.code } : null,
|
||||
};
|
||||
return buildOwnerResponse(row, requestingUserId);
|
||||
}
|
||||
|
||||
// Read the caller's active session for one company and adapter, with no
|
||||
// session id. The browser rediscovers its own session after a reload with no
|
||||
// local state. The store predicate matches only an active row for this exact
|
||||
// owner, so this never surfaces a foreign owner's session.
|
||||
async function readActiveOwnerSession(
|
||||
companyId: string,
|
||||
adapterType: AgentAdapterType,
|
||||
requestingUserId: string,
|
||||
): Promise<AdapterAuthSessionOwnerResponse | null> {
|
||||
const row = await store.getActiveByOwner(companyId, requestingUserId, adapterType);
|
||||
if (!row) return null;
|
||||
return buildOwnerResponse(row, requestingUserId);
|
||||
}
|
||||
|
||||
// Cancel a login session for its owner. The write is durable, so a cancel
|
||||
|
|
@ -1296,10 +1352,14 @@ export function createDeviceLoginService(deps: DeviceLoginServiceDeps) {
|
|||
finishedAt: now(),
|
||||
promotionExpiresAt: null,
|
||||
});
|
||||
// Delete the in-memory prompt on every cancel, whether or not the write
|
||||
// above won. A lost write means the row already left the cancellable
|
||||
// states, so no fresh prompt exists for it either.
|
||||
promptsBySession.delete(row.id);
|
||||
return readOwnerSession(publicSessionId, companyId, requestingUserId);
|
||||
}
|
||||
|
||||
return { start, readOwnerSession, cancelOwnerSession };
|
||||
return { start, readOwnerSession, readActiveOwnerSession, cancelOwnerSession };
|
||||
}
|
||||
|
||||
export type DeviceLoginService = ReturnType<typeof createDeviceLoginService>;
|
||||
|
|
|
|||
|
|
@ -163,10 +163,18 @@ const LOGIN_PTY_CLOSE_TIMEOUT_MS = 10_000;
|
|||
* select an arbitrary command in the sandbox.
|
||||
*/
|
||||
const LOGIN_PTY_COMMAND_NOT_ALLOWED = "LOGIN_PTY_COMMAND_NOT_ALLOWED";
|
||||
/** The fixed non-secret error a rejected second credential open returns. */
|
||||
const LOGIN_PTY_ROUTE_BUSY = "LOGIN_PTY_ROUTE_BUSY";
|
||||
/** The fixed non-secret error a failed open returns. */
|
||||
const LOGIN_PTY_OPEN_FAILED = "LOGIN_PTY_OPEN_FAILED";
|
||||
/**
|
||||
* The fixed non-secret error a login pseudo-terminal open returns when the
|
||||
* process-wide aggregate route-slot ceiling is full. This is a distinct
|
||||
* condition from the removed per-worker single-route gate: it means the whole
|
||||
* process is at capacity, across every worker, not that one worker already
|
||||
* holds a terminal. It is also distinct from {@link DUPLEX_CHANNEL_ROUTE_BUSY}:
|
||||
* `packages/adapter-utils/src/execution-target.ts` matches that exact text as
|
||||
* a marker for the duplex path, and a login failure must never enter it.
|
||||
*/
|
||||
const LOGIN_PTY_ROUTES_AT_CAPACITY = "LOGIN_PTY_ROUTES_AT_CAPACITY";
|
||||
|
||||
// Bounds and timeouts for the generic duplex channel route. The route mirrors the
|
||||
// login pseudo-terminal route, but it carries no command allowlist and adds seven
|
||||
|
|
@ -1317,18 +1325,26 @@ export function createPluginWorkerHandle(
|
|||
// -----------------------------------------------------------------------
|
||||
// Host-owned login pseudo-terminal route gate
|
||||
// -----------------------------------------------------------------------
|
||||
// The manager owns one live login pseudo-terminal route per worker. It mints a
|
||||
// host-owned opaque route identifier, carries it in the open call, and keys the
|
||||
// close on it, so it closes a worker-created terminal even when the open reply
|
||||
// was lost and no worker session identifier arrived. It binds the worker
|
||||
// session identifier one time while the route is `opening`, for output only. It
|
||||
// never trusts a worker-supplied identifier as proof of origin: it delivers
|
||||
// output only while the route is `open` and the notification carries the exact
|
||||
// bound identifier and valid bounded bytes, and it never logs the raw bytes. It
|
||||
// terminalizes the route exactly once on every open failure path, closes the
|
||||
// terminal by the host route identifier, and admits a new open only after it
|
||||
// verifies a close acknowledgement bound to that identifier; it retires the
|
||||
// worker on an unconfirmed close.
|
||||
// The manager owns every live login pseudo-terminal route on a worker. One
|
||||
// worker admits more than one concurrent route, so more than one owner can
|
||||
// hold an active credential login on the same shared worker at once. The
|
||||
// manager mints a host-owned opaque route identifier for each open, carries
|
||||
// it in the open call, and keys the close on it, so it closes a
|
||||
// worker-created terminal even when the open reply was lost and no worker
|
||||
// session identifier arrived. It binds the worker session identifier one
|
||||
// time while a route is `opening`, for output only. It never trusts a
|
||||
// worker-supplied identifier as proof of origin: for each output or exit
|
||||
// notification, it resolves the route by the host route identifier first,
|
||||
// then delivers only while that route is `open` and the notification
|
||||
// carries the exact bound worker session identifier and valid bounded
|
||||
// bytes; it drops an unknown, a stale, a duplicate, a malformed, or a
|
||||
// mismatched notification, and it never logs the raw bytes. It terminalizes
|
||||
// one route exactly once on every open failure path, closes the terminal by
|
||||
// its host route identifier, and admits a new open on that same identifier
|
||||
// only after it verifies a close acknowledgement bound to it. On an
|
||||
// unconfirmed close it retires the whole worker, which settles and clears
|
||||
// every route the worker still holds — a possibly live terminal must never
|
||||
// reach reuse or a wrong delivery.
|
||||
|
||||
// A single-consumer route state. The login pseudo-terminal route and the
|
||||
// generic duplex channel route share it.
|
||||
|
|
@ -1399,9 +1415,47 @@ export function createPluginWorkerHandle(
|
|||
// record.
|
||||
preBindChars: number;
|
||||
}
|
||||
// At most one active credential pseudo-terminal per worker. A non-null route
|
||||
// blocks a second open until the manager confirms the first route's close.
|
||||
let loginPtyRoute: LoginPtyRoute | null = null;
|
||||
// The live login pseudo-terminal routes on this worker, keyed by the host
|
||||
// route id. A route lives here from reservation — before the open call —
|
||||
// until it terminalizes. An output or an exit notification resolves its
|
||||
// route through this map first, so more than one route can be `reserved`,
|
||||
// `opening`, or `open` on one worker at once.
|
||||
const loginPtyRoutesByHostRouteId = new Map<string, LoginPtyRoute>();
|
||||
// True once this worker has logged the missing-`hostRouteId` warning below.
|
||||
// A plugin build old enough to omit the field would otherwise log one line
|
||||
// for every dropped message, and a login pseudo-terminal can carry a high
|
||||
// volume of output notifications. One warning per worker is enough for an
|
||||
// operator to see the cause.
|
||||
let loggedMissingLoginPtyHostRouteId = false;
|
||||
// The bound routes on this worker, keyed by the worker session id. A route
|
||||
// enters this map once its worker session id binds and leaves it when the
|
||||
// route terminalizes. The host checks this map at bind time, so one worker
|
||||
// session id can never bind to two live routes at once.
|
||||
const loginPtyRoutesByWorkerSessionId = new Map<string, LoginPtyRoute>();
|
||||
|
||||
// The routes that currently hold one process-wide aggregate route slot. The
|
||||
// host releases a slot one time per route, so a double terminalize, or a
|
||||
// terminal exit followed by a later close, never releases two slots.
|
||||
const loginPtyRouteSlotHolders = new Set<LoginPtyRoute>();
|
||||
|
||||
// Try to reserve one aggregate route slot for a route. Return true when the
|
||||
// route holds a slot after the call. When no controller is present, the
|
||||
// route always holds a slot. This calls the SAME shared controller instance
|
||||
// the duplex channel route uses (`duplexRouteSlots`), so the two route types
|
||||
// share one process-wide ceiling.
|
||||
function acquireLoginPtyRouteSlot(route: LoginPtyRoute): boolean {
|
||||
if (!duplexRouteSlots) return true;
|
||||
if (!duplexRouteSlots.tryAcquire()) return false;
|
||||
loginPtyRouteSlotHolders.add(route);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Release the aggregate route slot a route holds, one time. A route that
|
||||
// never held a slot, or already released it, releases nothing.
|
||||
function releaseLoginPtyRouteSlot(route: LoginPtyRoute): void {
|
||||
if (!loginPtyRouteSlotHolders.delete(route)) return;
|
||||
duplexRouteSlots?.release();
|
||||
}
|
||||
|
||||
// Close the worker terminal by the host route identifier and verify the bound
|
||||
// acknowledgement. Return true only when the worker returns an acknowledgement
|
||||
|
|
@ -1421,9 +1475,13 @@ export function createPluginWorkerHandle(
|
|||
}
|
||||
}
|
||||
|
||||
// Terminalize the route exactly once. Resolve the login wait, close the worker
|
||||
// terminal by the host route identifier, and free the per-worker slot only
|
||||
// after the close resolves. Retire the worker when the close is unconfirmed.
|
||||
// Terminalize one route exactly once. Remove it from both maps at once,
|
||||
// before the worker close call, so no later notification for its host route
|
||||
// identifier or its worker session identifier can still resolve to it.
|
||||
// Resolve the login wait, close the worker terminal by the host route
|
||||
// identifier, and retire the whole worker when the close is unconfirmed —
|
||||
// every other route this worker still holds settles through the worker-exit
|
||||
// path that follows.
|
||||
async function terminalizeLoginPtyRoute(route: LoginPtyRoute): Promise<void> {
|
||||
if (route.terminalized) return;
|
||||
route.terminalized = true;
|
||||
|
|
@ -1433,14 +1491,19 @@ export function createPluginWorkerHandle(
|
|||
// A terminalized route never replays a queued pre-bind record.
|
||||
route.preBind = [];
|
||||
route.preBindChars = 0;
|
||||
loginPtyRoutesByHostRouteId.delete(route.hostRouteId);
|
||||
if (route.workerSessionId !== null) {
|
||||
loginPtyRoutesByWorkerSessionId.delete(route.workerSessionId);
|
||||
}
|
||||
releaseLoginPtyRouteSlot(route);
|
||||
// A terminalized route reports a null exit code, which the runner treats as a
|
||||
// failure.
|
||||
settleRouteWait(route, { exitCode: null });
|
||||
const confirmed = await closeLoginPtyTerminal(route.hostRouteId);
|
||||
if (loginPtyRoute === route) loginPtyRoute = null;
|
||||
if (!confirmed) {
|
||||
// The worker did not acknowledge the close, so the host cannot prove the
|
||||
// terminal is gone. Fail closed: retire the worker before any reuse.
|
||||
// terminal is gone. Fail closed: retire the worker before any reuse. This
|
||||
// also settles and clears every other route the worker still holds.
|
||||
log.error(
|
||||
{ pluginId },
|
||||
"login pseudo-terminal close not acknowledged; retiring worker",
|
||||
|
|
@ -1449,20 +1512,61 @@ export function createPluginWorkerHandle(
|
|||
}
|
||||
}
|
||||
|
||||
// Resolve one login pseudo-terminal notification to its route. Return null
|
||||
// for an unknown or a stale (already terminalized) identifier, so the caller
|
||||
// drops the notification.
|
||||
//
|
||||
// The current protocol tags every notification with the host route
|
||||
// identifier, so a current build resolves through
|
||||
// `loginPtyRoutesByHostRouteId` directly, even while more than one route is
|
||||
// open on this worker.
|
||||
//
|
||||
// A plugin build old enough to predate the host route identifier tags the
|
||||
// notification with only the worker session identifier, the sole routing
|
||||
// key the previous protocol used. The worker itself controls that
|
||||
// identifier, so it alone cannot prove which route a message belongs to
|
||||
// once this worker holds two or more concurrent routes: a message that
|
||||
// omits `hostRouteId` and names a different, live route's session
|
||||
// identifier would otherwise deliver into, or end, that other route. A
|
||||
// build old enough to omit `hostRouteId` never ran a concurrent route, so
|
||||
// the fallback only trusts the worker session identifier while this worker
|
||||
// holds exactly one route. The host warns once for this worker — never
|
||||
// once for each dropped message, since a live pseudo-terminal can send
|
||||
// many — so an operator can see the build is too old, then still delivers
|
||||
// the route's output and exit notifications instead of losing them.
|
||||
function resolveLoginPtyRoute(params: Record<string, unknown>): LoginPtyRoute | null {
|
||||
const hostRouteId = readNonEmptyString(params.hostRouteId);
|
||||
if (hostRouteId) {
|
||||
return loginPtyRoutesByHostRouteId.get(hostRouteId) ?? null;
|
||||
}
|
||||
if (!loggedMissingLoginPtyHostRouteId) {
|
||||
loggedMissingLoginPtyHostRouteId = true;
|
||||
log.warn(
|
||||
"login pseudo-terminal message has no hostRouteId; the plugin build is too old",
|
||||
);
|
||||
}
|
||||
if (loginPtyRoutesByHostRouteId.size !== 1) return null;
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
if (!workerSessionId) return null;
|
||||
return loginPtyRoutesByWorkerSessionId.get(workerSessionId) ?? null;
|
||||
}
|
||||
|
||||
// Route one login pseudo-terminal output notification to the per-session
|
||||
// listener. Deliver only while the route is `open` and the notification carries
|
||||
// the exact bound worker session identifier and valid bounded bytes. Queue the
|
||||
// notification while the route is still `opening`. Drop an unknown, late,
|
||||
// malformed, or mismatched notification. Never log the raw bytes.
|
||||
// listener. Resolve the route by the host route identifier first. Deliver
|
||||
// only while that route is `open` and the notification carries the exact
|
||||
// bound worker session identifier and valid bounded bytes. Queue the
|
||||
// notification while the route is still `opening`. Drop an unknown, a stale,
|
||||
// a duplicate, a malformed, or a mismatched notification. Never log the raw
|
||||
// bytes.
|
||||
function routeLoginPtyOutput(notification: JsonRpcNotification): void {
|
||||
const route = loginPtyRoute;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const route = resolveLoginPtyRoute(params);
|
||||
if (!route) return;
|
||||
if (route.state === "opening") {
|
||||
queuePreBindLoginPtyOutput(route, notification);
|
||||
return;
|
||||
}
|
||||
if (route.state !== "open") return;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
if (!workerSessionId || workerSessionId !== route.workerSessionId) return;
|
||||
const chunk = params.chunk;
|
||||
|
|
@ -1483,24 +1587,28 @@ export function createPluginWorkerHandle(
|
|||
else route.buffered.push(chunk);
|
||||
}
|
||||
|
||||
// Route one login pseudo-terminal exit notification to the login wait. Resolve
|
||||
// only while the route is `open` and the notification carries the exact bound
|
||||
// worker session identifier. Queue the notification while the route is still
|
||||
// `opening`. A resolved exit moves the state off `open`, so a later record —
|
||||
// live or replayed — finds a closed route and drops there.
|
||||
// Route one login pseudo-terminal exit notification to the login wait.
|
||||
// Resolve the route by the host route identifier first. Settle the wait
|
||||
// only while that route is `open` and the notification carries the exact
|
||||
// bound worker session identifier. Queue the notification while the route
|
||||
// is still `opening`. A resolved exit moves the state off `open`, so a
|
||||
// later record — live or replayed — finds a closed route and drops there.
|
||||
function routeLoginPtyExit(notification: JsonRpcNotification): void {
|
||||
const route = loginPtyRoute;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const route = resolveLoginPtyRoute(params);
|
||||
if (!route) return;
|
||||
if (route.state === "opening") {
|
||||
queuePreBindLoginPtyExit(route, notification);
|
||||
return;
|
||||
}
|
||||
if (route.state !== "open") return;
|
||||
const params = isRecord(notification.params) ? notification.params : {};
|
||||
const workerSessionId = readNonEmptyString(params.workerSessionId);
|
||||
if (!workerSessionId || workerSessionId !== route.workerSessionId) return;
|
||||
const exitCode = typeof params.exitCode === "number" ? params.exitCode : null;
|
||||
route.state = "closed";
|
||||
// A terminal exit is its own slot-release path, distinct from the later
|
||||
// explicit close, so a route that already exited returns its slot at once.
|
||||
releaseLoginPtyRouteSlot(route);
|
||||
settleRouteWait(route, { exitCode });
|
||||
}
|
||||
|
||||
|
|
@ -1583,38 +1691,51 @@ export function createPluginWorkerHandle(
|
|||
routeLoginPtyOutput({
|
||||
jsonrpc: "2.0",
|
||||
method: LOGIN_PTY_OUTPUT_NOTIFICATION,
|
||||
params: { workerSessionId: record.workerSessionId, chunk: record.chunk },
|
||||
params: {
|
||||
hostRouteId: route.hostRouteId,
|
||||
workerSessionId: record.workerSessionId,
|
||||
chunk: record.chunk,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
routeLoginPtyExit({
|
||||
jsonrpc: "2.0",
|
||||
method: LOGIN_PTY_EXIT_NOTIFICATION,
|
||||
params: { workerSessionId: record.workerSessionId, exitCode: record.exitCode },
|
||||
params: {
|
||||
hostRouteId: route.hostRouteId,
|
||||
workerSessionId: record.workerSessionId,
|
||||
exitCode: record.exitCode,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close the one route on a worker exit. The worker is gone, so the manager
|
||||
// resolves the login wait with the fixed non-secret exit and clears the route
|
||||
// one time. The pending pseudo-terminal calls reject through `rejectAllPending`.
|
||||
// Close every route on a worker exit. The worker is gone, so the manager
|
||||
// resolves each login wait with the fixed non-secret exit and clears both
|
||||
// maps one time. The pending pseudo-terminal calls reject through
|
||||
// `rejectAllPending`.
|
||||
function closeLoginPtyRouteOnWorkerExit(): void {
|
||||
const route = loginPtyRoute;
|
||||
if (!route) return;
|
||||
loginPtyRoute = null;
|
||||
route.terminalized = true;
|
||||
route.state = "closed";
|
||||
route.listener = null;
|
||||
route.buffered = [];
|
||||
route.preBind = [];
|
||||
route.preBindChars = 0;
|
||||
settleRouteWait(route, { exitCode: null });
|
||||
const routes = [...loginPtyRoutesByHostRouteId.values()];
|
||||
loginPtyRoutesByHostRouteId.clear();
|
||||
loginPtyRoutesByWorkerSessionId.clear();
|
||||
for (const route of routes) {
|
||||
route.terminalized = true;
|
||||
route.state = "closed";
|
||||
route.listener = null;
|
||||
route.buffered = [];
|
||||
route.preBind = [];
|
||||
route.preBindChars = 0;
|
||||
releaseLoginPtyRouteSlot(route);
|
||||
settleRouteWait(route, { exitCode: null });
|
||||
}
|
||||
}
|
||||
|
||||
// Open one live login pseudo-terminal route. Reserve the route
|
||||
// before the open call, bind the worker session identifier one time on the
|
||||
// first successful open reply, and return a session the login transport drives.
|
||||
// Terminalize the route on every open failure path.
|
||||
// Open one live login pseudo-terminal route. Reserve one process-wide
|
||||
// aggregate route slot before the open call, bind the worker session
|
||||
// identifier one time on the first successful open reply, and return a
|
||||
// session the login transport drives. Terminalize the route on every open
|
||||
// failure path. One worker can hold more than one concurrent route.
|
||||
async function openLoginPtySession(
|
||||
input: LoginPtyOpenInput,
|
||||
): Promise<LoginPtyHostSession> {
|
||||
|
|
@ -1629,11 +1750,6 @@ export function createPluginWorkerHandle(
|
|||
// The binding validated it at the service boundary; this is the last host gate
|
||||
// before the worker call, so a malformed home fails closed here too.
|
||||
validateLoginSessionHome(input.sessionHome);
|
||||
if (loginPtyRoute) {
|
||||
// A route for this worker is not yet closed and confirmed. Reject the
|
||||
// second open with one fixed non-secret error before it reaches the worker.
|
||||
throw new Error(LOGIN_PTY_ROUTE_BUSY);
|
||||
}
|
||||
const hostRouteId = randomUUID();
|
||||
let settleWait: (value: { exitCode: number | null }) => void = () => {};
|
||||
const waitPromise = new Promise<{ exitCode: number | null }>((resolve) => {
|
||||
|
|
@ -1651,7 +1767,18 @@ export function createPluginWorkerHandle(
|
|||
preBind: [],
|
||||
preBindChars: 0,
|
||||
};
|
||||
loginPtyRoute = route;
|
||||
// Reserve one process-wide aggregate route slot before any work. When the
|
||||
// ceiling is full, reject with the fixed capacity error and open nothing,
|
||||
// so an active login route never downgrades and the ceiling never
|
||||
// overcommits. This never reveals the live count, the ceiling, or any
|
||||
// other tenant.
|
||||
if (!acquireLoginPtyRouteSlot(route)) {
|
||||
throw new Error(LOGIN_PTY_ROUTES_AT_CAPACITY);
|
||||
}
|
||||
// Reserve the route by its host route identifier before the open call, so
|
||||
// a notification that echoes this identifier can queue against it even
|
||||
// before the worker replies.
|
||||
loginPtyRoutesByHostRouteId.set(hostRouteId, route);
|
||||
|
||||
route.state = "opening";
|
||||
let openResult: HostToWorkerMethods["loginPtyOpen"][1];
|
||||
|
|
@ -1683,9 +1810,16 @@ export function createPluginWorkerHandle(
|
|||
await terminalizeLoginPtyRoute(route);
|
||||
throw new Error(LOGIN_PTY_OPEN_FAILED);
|
||||
}
|
||||
if (loginPtyRoutesByWorkerSessionId.has(workerSessionId)) {
|
||||
// A live route already owns this worker session identifier. Fail closed
|
||||
// instead of binding a second route to it.
|
||||
await terminalizeLoginPtyRoute(route);
|
||||
throw new Error(LOGIN_PTY_OPEN_FAILED);
|
||||
}
|
||||
// Bind the worker session identifier one time and move the route to `open`.
|
||||
route.workerSessionId = workerSessionId;
|
||||
route.state = "open";
|
||||
loginPtyRoutesByWorkerSessionId.set(workerSessionId, route);
|
||||
// Replay every record the route queued before the bind, in arrival order.
|
||||
replayPreBindLoginPtyRecords(route);
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
SETUP_TOKEN_CAP_EXCEEDED,
|
||||
SETUP_TOKEN_TOKEN_UNAVAILABLE,
|
||||
SETUP_TOKEN_STORAGE_FAILED,
|
||||
SETUP_TOKEN_CANCELLABLE_STATES,
|
||||
type SetupTokenCleanupIdentity,
|
||||
type SetupTokenCleanupRecord,
|
||||
type SetupTokenCleanupStore,
|
||||
|
|
@ -36,6 +37,7 @@ import {
|
|||
type SetupTokenRateLimiter,
|
||||
type SetupTokenSecretWriter,
|
||||
type SetupTokenSessionScope,
|
||||
type SetupTokenSessionState,
|
||||
} from "./setup-token-session.js";
|
||||
import { redactSensitive } from "../middleware/redact-sensitive.js";
|
||||
import { sanitizeRecord } from "../redaction.js";
|
||||
|
|
@ -217,6 +219,34 @@ class FakeStore implements SetupTokenCleanupStore {
|
|||
row.boundAt = Date.now();
|
||||
return { ...row };
|
||||
}
|
||||
async cancelDurable(
|
||||
identity: SetupTokenCleanupIdentity,
|
||||
cancellableStates: readonly SetupTokenSessionState[],
|
||||
): Promise<SetupTokenCleanupRecord | null> {
|
||||
const row = this.rows.get(identity.sessionId);
|
||||
if (!row || !identityMatchesRow(row, identity) || !cancellableStates.includes(row.state)) {
|
||||
return null;
|
||||
}
|
||||
row.state = "cancelled";
|
||||
return { ...row };
|
||||
}
|
||||
async findActiveDurable(
|
||||
key: Pick<SetupTokenCleanupIdentity, "companyId" | "ownerUserId" | "adapterType">,
|
||||
now: number,
|
||||
): Promise<SetupTokenCleanupRecord | null> {
|
||||
for (const row of this.rows.values()) {
|
||||
if (
|
||||
row.companyId === key.companyId &&
|
||||
row.ownerUserId === key.ownerUserId &&
|
||||
row.adapterType === key.adapterType &&
|
||||
!isTerminalSessionState(row.state) &&
|
||||
row.deadline > now
|
||||
) {
|
||||
return { ...row };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function allowAllRateLimiter(): SetupTokenRateLimiter {
|
||||
|
|
@ -654,6 +684,141 @@ describe("SetupTokenSessionService durable reaper", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService.cancelByScope", () => {
|
||||
it("cancels the live in-memory session the normal way when one exists", async () => {
|
||||
const { service, store } = buildService();
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
const result = await service.cancelByScope(sessionId, OWNER_SCOPE);
|
||||
expect(result.state).toBe("cancelled");
|
||||
expect(store.rows.has(sessionId)).toBe(false);
|
||||
});
|
||||
|
||||
it("releases the durable row when no live session exists (a restart dropped it)", async () => {
|
||||
const store = new FakeStore();
|
||||
// Simulate a durable row a prior process created. No `service.start()` ever
|
||||
// ran for it in THIS service instance, so `sessions` holds nothing for it —
|
||||
// the shape a restart leaves behind.
|
||||
await store.record({
|
||||
sessionId: "durable-only-1",
|
||||
companyId: OWNER_SCOPE.companyId,
|
||||
ownerUserId: OWNER_SCOPE.ownerUserId,
|
||||
adapterType: OWNER_SCOPE.adapterType,
|
||||
environmentId: OWNER_SCOPE.environmentId,
|
||||
leaseId: "lease-durable-only-1",
|
||||
deadline: Date.now() + 60_000,
|
||||
state: "awaiting_code",
|
||||
boundAt: null,
|
||||
});
|
||||
const { service } = buildService({ store });
|
||||
|
||||
const result = await service.cancelByScope("durable-only-1", OWNER_SCOPE);
|
||||
expect(result.state).toBe("cancelled");
|
||||
expect(store.rows.get("durable-only-1")?.state).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("cancels only the caller's row: a foreign scope leaves a same-id row untouched and throws not-found", async () => {
|
||||
const store = new FakeStore();
|
||||
await store.record({
|
||||
sessionId: "durable-only-2",
|
||||
companyId: OWNER_SCOPE.companyId,
|
||||
ownerUserId: OWNER_SCOPE.ownerUserId,
|
||||
adapterType: OWNER_SCOPE.adapterType,
|
||||
environmentId: OWNER_SCOPE.environmentId,
|
||||
leaseId: "lease-durable-only-2",
|
||||
deadline: Date.now() + 60_000,
|
||||
state: "awaiting_code",
|
||||
boundAt: null,
|
||||
});
|
||||
const { service } = buildService({ store });
|
||||
|
||||
const foreignKey = { ...OWNER_SCOPE, ownerUserId: "user-2" };
|
||||
await expect(service.cancelByScope("durable-only-2", foreignKey)).rejects.toThrow(
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
);
|
||||
// The foreign-scope attempt never touched the real owner's row.
|
||||
expect(store.rows.get("durable-only-2")?.state).toBe("awaiting_code");
|
||||
});
|
||||
|
||||
it("does not interrupt a session in the submitting (credential-write) state", async () => {
|
||||
const store = new FakeStore();
|
||||
await store.record({
|
||||
sessionId: "durable-only-3",
|
||||
companyId: OWNER_SCOPE.companyId,
|
||||
ownerUserId: OWNER_SCOPE.ownerUserId,
|
||||
adapterType: OWNER_SCOPE.adapterType,
|
||||
environmentId: OWNER_SCOPE.environmentId,
|
||||
leaseId: "lease-durable-only-3",
|
||||
deadline: Date.now() + 60_000,
|
||||
state: "submitting",
|
||||
boundAt: null,
|
||||
});
|
||||
const { service } = buildService({ store });
|
||||
|
||||
await expect(service.cancelByScope("durable-only-3", OWNER_SCOPE)).rejects.toThrow(
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
);
|
||||
// The row stays in `submitting`: the durable-only cancel never interrupts
|
||||
// the credential write in progress.
|
||||
expect(store.rows.get("durable-only-3")?.state).toBe("submitting");
|
||||
});
|
||||
|
||||
it("throws the fixed not-found error when nothing matches at all", async () => {
|
||||
const { service } = buildService();
|
||||
await expect(service.cancelByScope("never-existed", OWNER_SCOPE)).rejects.toThrow(
|
||||
SETUP_TOKEN_SESSION_NOT_FOUND,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService.findActiveByScope", () => {
|
||||
it("finds the caller's live active session with no session id", async () => {
|
||||
const { service } = buildService();
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
const found = await service.findActiveByScope(OWNER_SCOPE);
|
||||
expect(found?.sessionId).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("returns null for another owner and for a terminal session", async () => {
|
||||
const { service } = buildService();
|
||||
const { sessionId } = await service.start(OWNER_SCOPE);
|
||||
expect(
|
||||
await service.findActiveByScope({ ...OWNER_SCOPE, ownerUserId: "user-2" }),
|
||||
).toBeNull();
|
||||
|
||||
await service.cancel(sessionId, OWNER_SCOPE);
|
||||
expect(await service.findActiveByScope(OWNER_SCOPE)).toBeNull();
|
||||
});
|
||||
|
||||
it("finds the durable active row after a restart drops the in-memory session", async () => {
|
||||
const store = new FakeStore();
|
||||
const { service: before } = buildService({ store });
|
||||
const { sessionId } = await before.start(OWNER_SCOPE);
|
||||
|
||||
// Simulate a restart: a fresh service shares the durable store but starts
|
||||
// with an empty in-memory session map.
|
||||
const { service: after } = buildService({ store });
|
||||
const found = await after.findActiveByScope(OWNER_SCOPE);
|
||||
expect(found?.sessionId).toBe(sessionId);
|
||||
// The full login URL lives only in memory (SR-5), so a restart-recovered
|
||||
// descriptor never carries one.
|
||||
expect(found?.loginUrl).toBeNull();
|
||||
});
|
||||
|
||||
it("does not find a foreign-scope or an expired durable row after a restart", async () => {
|
||||
const store = new FakeStore();
|
||||
const { service: before } = buildService({ store, ttlMs: 1_000 });
|
||||
await before.start(OWNER_SCOPE);
|
||||
|
||||
const { service: after } = buildService({ store });
|
||||
expect(
|
||||
await after.findActiveByScope({ ...OWNER_SCOPE, ownerUserId: "user-2" }),
|
||||
).toBeNull();
|
||||
|
||||
const { service: expiredAfter } = buildService({ store, now: () => Date.now() + 60_000 });
|
||||
expect(await expiredAfter.findActiveByScope(OWNER_SCOPE)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SetupTokenSessionService.completeSession", () => {
|
||||
it("returns the non-secret storedSessionId after a successful secret write, and no token", async () => {
|
||||
const { service, processes, leases, store, secretWrites, logs } = buildService();
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
// testable.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq, gt, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm";
|
||||
import { and, eq, gt, inArray, isNotNull, isNull, lte, notInArray, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { adapterAuthSessions } from "@paperclipai/db";
|
||||
import type { AgentAdapterType } from "@paperclipai/shared";
|
||||
|
|
@ -85,6 +85,18 @@ export function isTerminalSessionState(state: SetupTokenSessionState): boolean {
|
|||
return SETUP_TOKEN_TERMINAL_STATES.includes(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cancellable pre-promotion states. A durable-only cancel (no live
|
||||
* in-memory session) may transition a row only from one of these. `submitting`
|
||||
* is the credential-write phase — the setup-token analogue of the device-login
|
||||
* `promoting` claim — so it is excluded: a durable cancel never interrupts a
|
||||
* write in progress. `stored` and every terminal state are excluded too.
|
||||
*/
|
||||
export const SETUP_TOKEN_CANCELLABLE_STATES: readonly SetupTokenSessionState[] = [
|
||||
"starting",
|
||||
"awaiting_code",
|
||||
];
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
@ -264,6 +276,31 @@ export interface SetupTokenCleanupStore {
|
|||
* step. The agent-service transaction calls this method.
|
||||
*/
|
||||
consumeStoredClaim(identity: SetupTokenCleanupIdentity): Promise<SetupTokenCleanupRecord | null>;
|
||||
/**
|
||||
* Cancels the exact durable row with one conditional write. The predicate
|
||||
* matches the full owner scope, the session id, and one of
|
||||
* `cancellableStates`. It returns the updated record only on a successful
|
||||
* transition. It returns null for a missing row, a foreign-scope row, and a
|
||||
* row outside the cancellable states — a caller cannot tell these apart.
|
||||
*/
|
||||
cancelDurable(
|
||||
identity: SetupTokenCleanupIdentity,
|
||||
cancellableStates: readonly SetupTokenSessionState[],
|
||||
): Promise<SetupTokenCleanupRecord | null>;
|
||||
/**
|
||||
* Returns the caller's active durable row for a scope, with no session id. A
|
||||
* restarted server process holds no in-memory session, so this is the
|
||||
* fallback source of truth for session discovery: the row survives the
|
||||
* restart even though the live process and the in-memory session do not. It
|
||||
* returns a row only when the company, the owner, and the adapter match, the
|
||||
* state is not terminal, and the deadline is not yet past. It returns null
|
||||
* for a missing row, a foreign-scope row, a terminal row, and an expired
|
||||
* row.
|
||||
*/
|
||||
findActiveDurable(
|
||||
key: Pick<SetupTokenCleanupIdentity, "companyId" | "ownerUserId" | "adapterType">,
|
||||
now: number,
|
||||
): Promise<SetupTokenCleanupRecord | null>;
|
||||
}
|
||||
|
||||
/** The counts one reaper sweep produced over the durable cleanup store. */
|
||||
|
|
@ -1124,6 +1161,78 @@ export class SetupTokenSessionService {
|
|||
return { state: session.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the caller's active session for a scope, with no session id. The
|
||||
* browser rediscovers its own session after a reload with no local state. It
|
||||
* matches the company, the owner, and the adapter, and it returns only a
|
||||
* non-terminal session, so a caller with no active login and a caller with a
|
||||
* foreign scope both find nothing.
|
||||
*
|
||||
* It checks the in-memory session first. A server restart drops every
|
||||
* in-memory session, so it then falls back to the durable active row. The
|
||||
* durable-only descriptor carries no login URL, because the full URL lives
|
||||
* only in memory (SR-5). This fallback keeps the caller's start route from
|
||||
* retrying a start that the durable active-row uniqueness constraint would
|
||||
* reject.
|
||||
*/
|
||||
async findActiveByScope(
|
||||
key: Pick<SetupTokenSessionScope, "companyId" | "ownerUserId" | "adapterType">,
|
||||
): Promise<SetupTokenSessionDescriptor | null> {
|
||||
for (const session of this.sessions.values()) {
|
||||
if (isTerminalSessionState(session.state)) continue;
|
||||
if (
|
||||
session.scope.companyId === key.companyId &&
|
||||
session.scope.ownerUserId === key.ownerUserId &&
|
||||
session.scope.adapterType === key.adapterType
|
||||
) {
|
||||
return this.describeOwned(session.id, session.scope);
|
||||
}
|
||||
}
|
||||
const durable = await this.store.findActiveDurable(key, this.now());
|
||||
if (!durable) return null;
|
||||
return {
|
||||
sessionId: durable.sessionId,
|
||||
state: durable.state,
|
||||
environmentId: durable.environmentId,
|
||||
deadline: durable.deadline,
|
||||
loginUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a session by its full scope, with a durable fallback when no live
|
||||
* session exists. It first tries the live in-memory session that matches the
|
||||
* scope and the session id, and cancels it the normal way. When no live
|
||||
* session matches — for example, a restart dropped the in-memory session —
|
||||
* it falls back to a durable-only cancel: a conditional transition of the
|
||||
* exact row from a cancellable pre-promotion state to `cancelled`. This
|
||||
* releases the company slot even though this process holds no live process
|
||||
* to stop, so it aborts no local process. It throws the fixed not-found
|
||||
* error for a missing row, a foreign owner, a foreign company, a foreign
|
||||
* adapter, and a row outside the cancellable states — the caller cannot tell
|
||||
* these apart.
|
||||
*/
|
||||
async cancelByScope(
|
||||
sessionId: string,
|
||||
key: Pick<SetupTokenSessionScope, "companyId" | "ownerUserId" | "adapterType">,
|
||||
): Promise<{ state: SetupTokenSessionState }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (
|
||||
session &&
|
||||
session.scope.companyId === key.companyId &&
|
||||
session.scope.ownerUserId === key.ownerUserId &&
|
||||
session.scope.adapterType === key.adapterType
|
||||
) {
|
||||
return this.cancel(sessionId, session.scope);
|
||||
}
|
||||
const identity: SetupTokenCleanupIdentity = { sessionId, ...key };
|
||||
const cancelled = await this.store.cancelDurable(identity, SETUP_TOKEN_CANCELLABLE_STATES);
|
||||
if (!cancelled) {
|
||||
throw new SetupTokenSessionError(404, SETUP_TOKEN_SESSION_NOT_FOUND);
|
||||
}
|
||||
return { state: "cancelled" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Expires a session on a timeout. It stops the direct child before it releases
|
||||
* the lease. The harness can call it, and the deadline timer calls the same
|
||||
|
|
@ -1500,5 +1609,43 @@ export function createDbSetupTokenCleanupStore(db: Db): SetupTokenCleanupStore {
|
|||
const row = changed[0];
|
||||
return row ? toCleanupRecord(row) : null;
|
||||
},
|
||||
|
||||
async cancelDurable(identity, cancellableStates): Promise<SetupTokenCleanupRecord | null> {
|
||||
// One conditional write. The predicate carries the company, the owner, the
|
||||
// adapter, the session id, and one of `cancellableStates`. It never
|
||||
// interrupts a `submitting` write, a `stored` claim, or a terminal row.
|
||||
const changed = await db
|
||||
.update(adapterAuthSessions)
|
||||
.set({
|
||||
status: "cancelled",
|
||||
finishedAt: sql`clock_timestamp()`,
|
||||
updatedAt: sql`clock_timestamp()`,
|
||||
})
|
||||
.where(and(scopeMatch(identity), inArray(adapterAuthSessions.status, [...cancellableStates])))
|
||||
.returning();
|
||||
const row = changed[0];
|
||||
return row ? toCleanupRecord(row) : null;
|
||||
},
|
||||
|
||||
async findActiveDurable(key, now): Promise<SetupTokenCleanupRecord | null> {
|
||||
// The active slot cap is one row per company, owner, and adapter, so at
|
||||
// most one row can match. The scan filters by the setup-token adapter, so
|
||||
// it never reads a Codex device-login row on the shared table.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(adapterAuthSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(adapterAuthSessions.companyId, key.companyId),
|
||||
eq(adapterAuthSessions.startedByUserId, key.ownerUserId),
|
||||
eq(adapterAuthSessions.adapterType, key.adapterType as AgentAdapterType),
|
||||
notInArray(adapterAuthSessions.status, [...SETUP_TOKEN_TERMINAL_STATES]),
|
||||
gt(adapterAuthSessions.expiresAt, new Date(now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? toCleanupRecord(row) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,23 @@ function createRecordingStore() {
|
|||
async consumeStoredClaim() {
|
||||
return null;
|
||||
},
|
||||
async cancelDurable() {
|
||||
return null;
|
||||
},
|
||||
async findActiveDurable(key, now) {
|
||||
for (const row of rows.values()) {
|
||||
if (
|
||||
row.companyId === key.companyId &&
|
||||
row.ownerUserId === key.ownerUserId &&
|
||||
row.adapterType === key.adapterType &&
|
||||
!isTerminalSessionState(row.state) &&
|
||||
row.deadline > now
|
||||
) {
|
||||
return { ...row };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -305,6 +305,213 @@ test.describe("Onboarding wizard", () => {
|
|||
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
test("a reload during a login resumes the same session", async ({ page }) => {
|
||||
// The last piece of the resume feature: a customer who reloads the page
|
||||
// mid-login must see the same login still running, not the tile row
|
||||
// again and not a second session started behind their back.
|
||||
//
|
||||
// A bare `/onboarding` reload is not the right way to exercise this: that
|
||||
// route always forces its own fixed entry step (see
|
||||
// `resolveRouteOnboardingOptions` — a bare `/onboarding` hit is a request
|
||||
// to start a new company, by design, and always wins over a saved draft's
|
||||
// step). A returning visit to an existing company's own onboarding path
|
||||
// (`/{prefix}/onboarding`) is the real "reopen onboarding" entry point, so
|
||||
// this test reloads through that path instead, landing on the agent step
|
||||
// with the draft's agent name already restored, then advances once to the
|
||||
// connect step — where the resumed login must already be running.
|
||||
const pageErrors: string[] = [];
|
||||
page.on("pageerror", (err) => pageErrors.push(err.message));
|
||||
|
||||
const flagRes = await page.request.patch("/api/instance/settings/experimental", {
|
||||
data: { enableConferenceRoomChat: true },
|
||||
});
|
||||
expect(flagRes.ok()).toBe(true);
|
||||
|
||||
const FAKE_SANDBOX_ENVIRONMENT_ID = "e2e-fake-sandbox-environment-reload";
|
||||
const FAKE_SANDBOX_PROVIDER = "e2e-fake-provider-reload";
|
||||
|
||||
await page.route("**/environments", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const environments = await response.json();
|
||||
environments.push({
|
||||
id: FAKE_SANDBOX_ENVIRONMENT_ID,
|
||||
name: "E2E fake sandbox",
|
||||
description: null,
|
||||
driver: "sandbox",
|
||||
status: "active",
|
||||
config: { provider: FAKE_SANDBOX_PROVIDER },
|
||||
envVars: {},
|
||||
metadata: {},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
await route.fulfill({ response, json: environments });
|
||||
});
|
||||
|
||||
await page.route("**/environments/capabilities", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const capabilities = await response.json();
|
||||
capabilities.sandboxProviders[FAKE_SANDBOX_PROVIDER] = {
|
||||
status: "supported",
|
||||
supportsSavedProbe: true,
|
||||
supportsUnsavedProbe: true,
|
||||
supportsRunExecution: true,
|
||||
supportsReusableLeases: false,
|
||||
supportsInteractiveSetup: false,
|
||||
interactiveSetupConnectionTypes: [],
|
||||
supportsTemplateCapture: false,
|
||||
supportsTemplateDelete: false,
|
||||
supportsLoginPty: true,
|
||||
source: "plugin",
|
||||
};
|
||||
await route.fulfill({ response, json: capabilities });
|
||||
});
|
||||
|
||||
await page.route("**/instance/settings", async (route) => {
|
||||
const response = await route.fetch();
|
||||
const settings = await response.json();
|
||||
settings.defaultEnvironmentId = FAKE_SANDBOX_ENVIRONMENT_ID;
|
||||
await route.fulfill({ response, json: settings });
|
||||
});
|
||||
|
||||
await page.route("**/adapters/*/auth-signal*", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "absent" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const SESSION_ID = "e2e-reload-setup-token-session";
|
||||
const AUTHORIZATION_URL = "https://claude.ai/oauth/authorize?code=true&client=e2e-reload";
|
||||
let startCalls = 0;
|
||||
// Flips once the login actually starts, so the owner-scoped resume read
|
||||
// below answers "no active session" until then — matching the real
|
||||
// route, and keeping the pre-Connect part of this test the same as the
|
||||
// ordinary sign-in test above.
|
||||
let sessionStarted = false;
|
||||
await page.route("**/setup-token-login-sessions", (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
startCalls += 1;
|
||||
sessionStarted = true;
|
||||
}
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: SESSION_ID,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(`**/setup-token-login-sessions/${SESSION_ID}/prompt`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
authorizationUrl: AUTHORIZATION_URL,
|
||||
transportAdvisory: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await page.route(`**/setup-token-login-sessions/${SESSION_ID}`, (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: SESSION_ID,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
}),
|
||||
});
|
||||
});
|
||||
// The owner-scoped resume read. Both the wizard's own step-restore effect
|
||||
// and the panel's own resume-on-mount read use this, with no session id
|
||||
// in the URL — the caller rediscovers its own session.
|
||||
await page.route("**/setup-token-login-sessions/active", (route) => {
|
||||
if (!sessionStarted) {
|
||||
return route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "Setup-token login session not found." }),
|
||||
});
|
||||
}
|
||||
return route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionId: SESSION_ID,
|
||||
status: "pending",
|
||||
expiresAt: new Date(Date.now() + 600_000).toISOString(),
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: { authorizationUrl: AUTHORIZATION_URL, transportAdvisory: null },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/onboarding");
|
||||
|
||||
const startBtn = page.getByRole("button", {
|
||||
name: /Start Onboarding|New Organization|Add Agent/,
|
||||
});
|
||||
if (await startBtn.count()) {
|
||||
await startBtn.first().click();
|
||||
}
|
||||
const createCard = page.getByRole("button", { name: /Build a new organization/ });
|
||||
if (await createCard.count()) {
|
||||
await createCard.first().click();
|
||||
}
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "What is the name of your organization?" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByPlaceholder("e.g. Northwind Labs").fill(`${COMPANY_NAME}-reload`);
|
||||
await page.getByRole("button", { name: /^Continue/ }).click();
|
||||
|
||||
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
|
||||
await page.locator("#onboarding-agent-name").fill("Ada");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
const source = page.getByRole("radio").first();
|
||||
await source.waitFor({ timeout: 30_000 });
|
||||
|
||||
// Answering the row is what starts the sign-in now — there is no separate
|
||||
// Connect press between choosing a source and being signed in.
|
||||
await source.click();
|
||||
|
||||
const cardInstruction = page.getByText("then come back and enter authorization code");
|
||||
const authorizationLink = page.getByRole("link", { name: /^Sign in to / });
|
||||
|
||||
await expect(cardInstruction).toBeVisible({ timeout: 30_000 });
|
||||
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
||||
await expect(authorizationLink).toHaveAttribute("href", /claude\.ai\/oauth\/authorize/);
|
||||
expect(startCalls).toBe(1);
|
||||
|
||||
const companiesRes = await page.request.get("/api/companies");
|
||||
expect(companiesRes.ok()).toBe(true);
|
||||
const companies = await companiesRes.json();
|
||||
const company = companies.find(
|
||||
(c: { name: string }) => c.name === `${COMPANY_NAME}-reload`,
|
||||
);
|
||||
expect(company, "the created company should exist").toBeTruthy();
|
||||
|
||||
// Reload through the company's own onboarding path — the real "reopen
|
||||
// onboarding" entry point for a company that already exists.
|
||||
await page.goto(`/${company.issuePrefix}/onboarding`);
|
||||
|
||||
// This entry point always re-enters on the agent step, with the agent
|
||||
// name restored from the draft. One more press reaches the connect step.
|
||||
await page.waitForSelector("#onboarding-agent-name", { timeout: 30_000 });
|
||||
await expect(page.locator("#onboarding-agent-name")).toHaveValue("Ada");
|
||||
await page.getByRole("button", { name: "Next" }).click();
|
||||
|
||||
// The resumed login shows with no new source pick and no fresh sign-in
|
||||
// press: the panel and the step both discover it from the caller's active
|
||||
// session.
|
||||
await expect(cardInstruction).toBeVisible({ timeout: 15_000 });
|
||||
await expect(authorizationLink).toBeVisible({ timeout: 15_000 });
|
||||
expect(startCalls, "the reload must resume the session, not start a new one").toBe(1);
|
||||
|
||||
expect(pageErrors, pageErrors.join("\n")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("connect step blocks the hire when the environment probe fails and no sign-in is needed", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -252,9 +252,21 @@ export const agentsApi = {
|
|||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions`,
|
||||
data,
|
||||
),
|
||||
// The owner response repeats the live prompt on every read while the
|
||||
// session holds an active public status, so this polling call carries the
|
||||
// same no-store request option as the active-session read below.
|
||||
getAdapterAuthLoginStatus: (companyId: string, type: string, sessionId: string) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
{ cache: "no-store" },
|
||||
),
|
||||
// Reads the caller's active login session for one company and adapter, with
|
||||
// no session id, so the browser rediscovers its own session after a reload
|
||||
// with no local state. A 404 means no active session for the caller.
|
||||
getActiveAdapterAuthLoginSession: (companyId: string, type: string) =>
|
||||
api.get<AdapterAuthSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/login-sessions/active`,
|
||||
{ cache: "no-store" },
|
||||
),
|
||||
cancelAdapterAuthLogin: (companyId: string, type: string, sessionId: string) =>
|
||||
api.post<AdapterAuthSessionOwnerResponse>(
|
||||
|
|
@ -287,6 +299,14 @@ export const agentsApi = {
|
|||
api.get<ClaudeSetupTokenSessionResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}`,
|
||||
),
|
||||
// Reads the caller's active Claude setup-token login session, with no
|
||||
// session id, so the browser rediscovers its own session after a reload with
|
||||
// no local state. A 404 means no active session for the caller.
|
||||
getActiveClaudeSetupTokenLoginSession: (companyId: string) =>
|
||||
api.get<ClaudeSetupTokenSessionOwnerResponse>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/active`,
|
||||
{ cache: "no-store" },
|
||||
),
|
||||
getClaudeSetupTokenLoginPrompt: (companyId: string, sessionId: string) =>
|
||||
api.get<ClaudeSetupTokenSessionPrompt>(
|
||||
`/companies/${encodeURIComponent(companyId)}/setup-token-login-sessions/${encodeURIComponent(sessionId)}/prompt`,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export interface RequestOptions {
|
|||
signal?: AbortSignal;
|
||||
/** Extra request headers (e.g. the async-import opt-in). Mutations only. */
|
||||
headers?: Record<string, string>;
|
||||
/** The `fetch` cache mode. Use `"no-store"` for a response that must never
|
||||
* come from the browser's HTTP cache. */
|
||||
cache?: RequestCache;
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
|
|
@ -88,7 +91,11 @@ function coalescedGet<T>(path: string, options?: RequestOptions): Promise<T> {
|
|||
let entry = inflightGets.get(path);
|
||||
if (!entry) {
|
||||
const controller = new AbortController();
|
||||
const promise = request<T>(path, { method: "GET", signal: controller.signal });
|
||||
const promise = request<T>(path, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
...(options?.cache ? { cache: options.cache } : {}),
|
||||
});
|
||||
const created: InflightGet = { promise, controller, refs: new Set() };
|
||||
// Clear the shared entry once settled so later calls issue a fresh request.
|
||||
promise.then(
|
||||
|
|
|
|||
|
|
@ -21,9 +21,11 @@ const mockAgentsApi = vi.hoisted(() => ({
|
|||
testEnvironment: vi.fn(),
|
||||
startAdapterAuthLogin: vi.fn(),
|
||||
getAdapterAuthLoginStatus: vi.fn(),
|
||||
getActiveAdapterAuthLoginSession: vi.fn(),
|
||||
cancelAdapterAuthLogin: vi.fn(),
|
||||
startClaudeSetupTokenLogin: vi.fn(),
|
||||
getClaudeSetupTokenLoginStatus: vi.fn(),
|
||||
getActiveClaudeSetupTokenLoginSession: vi.fn(),
|
||||
getClaudeSetupTokenLoginPrompt: vi.fn(),
|
||||
submitClaudeSetupTokenBrowserCode: vi.fn(),
|
||||
completeClaudeSetupTokenLogin: vi.fn(),
|
||||
|
|
@ -31,6 +33,14 @@ const mockAgentsApi = vi.hoisted(() => ({
|
|||
getClaudeOAuthTokenStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
// The default resume read for a test that does not exercise resume: no active
|
||||
// session for the caller.
|
||||
function noActiveSession() {
|
||||
return Promise.reject(
|
||||
new ApiError("Adapter login session not found", 404, { error: "Adapter login session not found" }),
|
||||
);
|
||||
}
|
||||
|
||||
const mockClipboard = vi.hoisted(() => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}));
|
||||
|
|
@ -645,6 +655,10 @@ describe("AgentConfigForm environment selector", () => {
|
|||
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
|
||||
mockSecretsApi.list.mockResolvedValue([]);
|
||||
mockSecretsApi.listProposals.mockResolvedValue([]);
|
||||
// Default: the caller has no active session. A resume test overrides this
|
||||
// with a resolved session body.
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(noActiveSession);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(noActiveSession);
|
||||
mockAgentsApi.startAdapterAuthLogin.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
environmentId: "sandbox-1",
|
||||
|
|
@ -1603,17 +1617,14 @@ describe("AgentConfigForm environment selector", () => {
|
|||
expect(result.container.textContent).not.toContain("WXYZ-1234");
|
||||
});
|
||||
|
||||
it("releases an active login session when the panel unmounts", async () => {
|
||||
// The server holds a one-per-owner reservation until the session reaches a
|
||||
// terminal state, so a panel that disappears mid-login would leave the owner
|
||||
// unable to start another until it expires.
|
||||
//
|
||||
// Reachable in the settings form only by navigating away, which is why this
|
||||
// went unnoticed. The connect step unmounts the panel routinely — Cancel
|
||||
// closes the canvas, switching source remounts it under a new key, closing
|
||||
// the wizard drops it — so the cleanup is what keeps an immediate retry
|
||||
// possible. Deliberately not pushed to `roots`: this test does the unmount
|
||||
// itself, and that unmount is the thing under test.
|
||||
it("does not cancel an active login session when the panel unmounts", async () => {
|
||||
// The owner-scoped active-session read and the manual Cancel button now
|
||||
// take over the purpose the unmount cancel used to serve. The connect step
|
||||
// unmounts the panel routinely — Cancel closes the canvas, switching source
|
||||
// remounts it under a new key, closing the wizard drops it — and each of
|
||||
// those unmounts must leave the session reachable by a later mount's resume
|
||||
// read, not release it. Deliberately not pushed to `roots`: this test does
|
||||
// the unmount itself, and that unmount is the thing under test.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
|
||||
const result = await renderCodexSandbox();
|
||||
|
||||
|
|
@ -1626,11 +1637,163 @@ describe("AgentConfigForm environment selector", () => {
|
|||
result.root.unmount();
|
||||
});
|
||||
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a reachable Cancel control in the onboarding chrome and cancels the session", async () => {
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider>
|
||||
<AdapterLoginPanel
|
||||
companyId="company-1"
|
||||
adapterType="codex_local"
|
||||
environmentId="sandbox-1"
|
||||
chrome="onboarding"
|
||||
autoStart
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushUntil(() => Boolean(findButton(container, "Cancel")));
|
||||
|
||||
await clickByText(container, "Cancel");
|
||||
|
||||
expect(mockAgentsApi.cancelAdapterAuthLogin).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"codex_local",
|
||||
"session-1",
|
||||
);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resumes an active login session on mount, adopting its session id and prompt", async () => {
|
||||
// A page reload loses every piece of local state, so the panel must read
|
||||
// the caller's active session and adopt it instead of starting a new one.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValue({
|
||||
sessionId: "resumed-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
prompt: { url: "https://auth.example.test/resumed", code: "RESUME-1" },
|
||||
});
|
||||
// The prompt survives every owner read while the session stays active, so
|
||||
// the status poll for the resumed session agrees with the resumed read.
|
||||
mockAgentsApi.getAdapterAuthLoginStatus.mockResolvedValue({
|
||||
sessionId: "resumed-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
prompt: { url: "https://auth.example.test/resumed", code: "RESUME-1" },
|
||||
});
|
||||
const result = await renderCodexSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() => (result.container.textContent ?? "").includes("RESUME-1"));
|
||||
|
||||
// The prompt from the resumed read shows without a fresh start.
|
||||
expect(result.container.textContent).toContain("RESUME-1");
|
||||
expect(result.container.textContent).toContain("https://auth.example.test/resumed");
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
// The panel polls the resumed session id, not a freshly started one.
|
||||
expect(mockAgentsApi.getAdapterAuthLoginStatus).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"codex_local",
|
||||
"resumed-session-1",
|
||||
);
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a new session when the active-session read finds none", async () => {
|
||||
// The default mock already answers with no active session (a 404). This
|
||||
// pins the fallback: the panel still waits for the caller's press.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
|
||||
const result = await renderCodexSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() => mockAgentsApi.getActiveAdapterAuthLoginSession.mock.calls.length > 0);
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
|
||||
|
||||
await startLogin(result.container);
|
||||
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", {
|
||||
environmentId: "sandbox-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("releases a resumed session after an unrecoverable resume error, waiting for the cancel response", async () => {
|
||||
// The active-session read finds a session, but by the time the status poll
|
||||
// reaches the server the session is already gone (a race between the two
|
||||
// reads). The panel cannot resume it, so it releases the reservation
|
||||
// explicitly instead of trusting the 404 alone, and it waits for that
|
||||
// release before it returns to its start state.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockResolvedValue({
|
||||
sessionId: "resumed-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
prompt: null,
|
||||
});
|
||||
mockAgentsApi.getAdapterAuthLoginStatus.mockRejectedValue(
|
||||
new ApiError("Adapter login session not found", 404, {
|
||||
error: "Adapter login session not found",
|
||||
}),
|
||||
);
|
||||
let resolveCancel!: () => void;
|
||||
mockAgentsApi.cancelAdapterAuthLogin.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCancel = () => resolve({
|
||||
sessionId: "resumed-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "cancelled",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
prompt: null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const result = await renderCodexSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() =>
|
||||
mockAgentsApi.cancelAdapterAuthLogin.mock.calls.some((call) => call[2] === "resumed-session-1"),
|
||||
);
|
||||
|
||||
// The cancel call fired, but the panel still shows the resumed login as
|
||||
// active because it is waiting for the cancel response.
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
|
||||
resolveCancel();
|
||||
await flushUntil(() => findButton(result.container, "Sign in")?.disabled === false);
|
||||
|
||||
expect(findButton(result.container, "Cancel")).toBeFalsy();
|
||||
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("announces the login state through a polite live region", async () => {
|
||||
|
|
@ -2028,6 +2191,46 @@ describe("AgentConfigForm environment selector", () => {
|
|||
expect(onStored).toHaveBeenCalledWith("stored-session-1");
|
||||
});
|
||||
|
||||
it("shows a reachable Cancel control in the onboarding chrome and cancels the session", async () => {
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<TooltipProvider>
|
||||
<AdapterLoginPanel
|
||||
companyId="company-1"
|
||||
adapterType="claude_local"
|
||||
environmentId="sandbox-1"
|
||||
chrome="onboarding"
|
||||
autoStart
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushUntil(() => Boolean(findButton(container, "Cancel")));
|
||||
|
||||
await clickByText(container, "Cancel");
|
||||
|
||||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"claude-session-1",
|
||||
);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("offers an apply-existing affordance when the status route reports a stored value", async () => {
|
||||
mockAgentsApi.getClaudeOAuthTokenStatus.mockResolvedValue({
|
||||
secretId: "secret-1",
|
||||
|
|
@ -2265,7 +2468,10 @@ describe("AgentConfigForm environment selector", () => {
|
|||
expect(result.container.textContent).not.toContain("Could not cancel the login.");
|
||||
});
|
||||
|
||||
it("cancels the active server session when the panel unmounts", async () => {
|
||||
it("does not cancel an active login session when the panel unmounts", async () => {
|
||||
// The owner-scoped active-session read and the manual Cancel button now
|
||||
// take over the purpose the unmount cancel used to serve, so an unmount
|
||||
// must leave the session reachable by a later mount's resume read.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
const result = await renderClaudeSandbox();
|
||||
|
||||
|
|
@ -2277,16 +2483,12 @@ describe("AgentConfigForm environment selector", () => {
|
|||
// The login is active before the unmount.
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockClear();
|
||||
await act(async () => {
|
||||
result.root.unmount();
|
||||
});
|
||||
|
||||
// The unmount released the active server session, so the abandoned session
|
||||
// does not hold the per-owner reservation until the server deadline.
|
||||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"claude-session-1",
|
||||
);
|
||||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not cancel on unmount when no login is active", async () => {
|
||||
|
|
@ -2305,6 +2507,134 @@ describe("AgentConfigForm environment selector", () => {
|
|||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resumes an active Claude login session on mount, adopting its session id and authorization URL", async () => {
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
|
||||
sessionId: "resumed-claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: { authorizationUrl: "https://claude.example.test/resumed" },
|
||||
});
|
||||
const result = await renderClaudeSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() =>
|
||||
(result.container.textContent ?? "").includes("https://claude.example.test/resumed"),
|
||||
);
|
||||
|
||||
expect(result.container.textContent).toContain("https://claude.example.test/resumed");
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
expect(mockAgentsApi.getClaudeSetupTokenLoginStatus).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"resumed-claude-session-1",
|
||||
);
|
||||
expect(findButton(result.container, "Cancel")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("starts a new Claude login when the active-session read finds none", async () => {
|
||||
// The default mock already answers with no active session (a 404).
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
const result = await renderClaudeSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() => mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mock.calls.length > 0);
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
|
||||
|
||||
await startLogin(result.container);
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels a resumed Claude login session with the manual Cancel button", async () => {
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
|
||||
sessionId: "resumed-claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: { authorizationUrl: "https://claude.example.test/resumed" },
|
||||
});
|
||||
const result = await renderClaudeSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() =>
|
||||
(result.container.textContent ?? "").includes("https://claude.example.test/resumed"),
|
||||
);
|
||||
|
||||
await clickByText(result.container, "Cancel");
|
||||
await flushReact();
|
||||
|
||||
expect(mockAgentsApi.cancelClaudeSetupTokenLogin).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"resumed-claude-session-1",
|
||||
);
|
||||
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
|
||||
expect(findButton(result.container, "Cancel")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("releases a resumed Claude login after an unrecoverable resume error, waiting for the cancel response", async () => {
|
||||
// The active-session read finds a session, but the status poll for that
|
||||
// resumed session finds it already gone (a race between the two reads).
|
||||
// The panel cannot resume it, so it releases the reservation explicitly
|
||||
// and waits for that release before it returns to its start state.
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
|
||||
sessionId: "resumed-claude-session-1",
|
||||
environmentId: "sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: null,
|
||||
});
|
||||
mockAgentsApi.getClaudeSetupTokenLoginStatus.mockRejectedValue(
|
||||
new ApiError("Setup-token login session not found.", 404, {
|
||||
error: "Setup-token login session not found.",
|
||||
}),
|
||||
);
|
||||
mockAgentsApi.getClaudeSetupTokenLoginPrompt.mockRejectedValue(
|
||||
new ApiError("Setup-token login session not found.", 404, {
|
||||
error: "Setup-token login session not found.",
|
||||
}),
|
||||
);
|
||||
let resolveCancel!: () => void;
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveCancel = () => resolve(undefined);
|
||||
}),
|
||||
);
|
||||
const result = await renderClaudeSandbox();
|
||||
roots.push(result.root);
|
||||
|
||||
await runTest(result.container);
|
||||
await flushUntil(() =>
|
||||
mockAgentsApi.cancelClaudeSetupTokenLogin.mock.calls.some(
|
||||
(call) => call[1] === "resumed-claude-session-1",
|
||||
),
|
||||
);
|
||||
|
||||
// The cancel call fired, but the panel still shows the resumed login as
|
||||
// active because it is waiting for the cancel response.
|
||||
expect(findButton(result.container, "Sign in")?.disabled).toBe(true);
|
||||
|
||||
resolveCancel();
|
||||
await flushUntil(() => findButton(result.container, "Sign in")?.disabled === false);
|
||||
|
||||
expect(findButton(result.container, "Cancel")).toBeFalsy();
|
||||
expect(findButton(result.container, "Sign in")?.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("stops both polls and shows the timed-out state at the server deadline", async () => {
|
||||
vi.useFakeTimers();
|
||||
// Pin the clock to a known base. The panel arms the timed-out timer from the
|
||||
|
|
@ -2539,6 +2869,8 @@ describe("AgentConfigForm create-mode Claude OAuth binding", () => {
|
|||
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
|
||||
mockSecretsApi.list.mockResolvedValue([]);
|
||||
mockSecretsApi.listProposals.mockResolvedValue([]);
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(noActiveSession);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(noActiveSession);
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
|
|
@ -2737,6 +3069,8 @@ describe("AgentConfigForm edit-mode Claude OAuth binding", () => {
|
|||
mockEnvironmentsApi.capabilities.mockResolvedValue(SANDBOX_CAPABILITIES);
|
||||
mockSecretsApi.list.mockResolvedValue([]);
|
||||
mockSecretsApi.listProposals.mockResolvedValue([]);
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockImplementation(noActiveSession);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockImplementation(noActiveSession);
|
||||
mockAgentsApi.testEnvironment.mockResolvedValue(CLAUDE_AUTH_MISSING_RESULT);
|
||||
mockAgentsApi.startClaudeSetupTokenLogin.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
|
|
|
|||
|
|
@ -2020,10 +2020,10 @@ export type AdapterLoginDescriptor = {
|
|||
//
|
||||
// They are props on the existing panels rather than a second implementation
|
||||
// because the part onboarding needs unchanged is the whole of it: the session
|
||||
// start, the two polls, the server deadline, the one-shot completion read, the
|
||||
// unmount release. A copy drawn to the new design would have had to reproduce
|
||||
// all of that correctly, and the first thing to rot would have been the
|
||||
// timeout and cleanup paths, which are the ones nobody exercises by hand.
|
||||
// start, the two polls, the server deadline, the one-shot completion read. A
|
||||
// copy drawn to the new design would have had to reproduce all of that
|
||||
// correctly, and the first thing to rot would have been the timeout and
|
||||
// cleanup paths, which are the ones nobody exercises by hand.
|
||||
export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
||||
onStored?: (storedSessionId: string) => void;
|
||||
onApplyStored?: () => void;
|
||||
|
|
@ -2047,8 +2047,9 @@ export type AdapterLoginPanelProps = AdapterLoginDescriptor & {
|
|||
* its own button is what sends the customer there, and a prompt arriving is
|
||||
* what moves the step from waiting to ready. Everything else it needs the
|
||||
* panel already does — the paste submits itself, success is reported through
|
||||
* `onConnected`, and an unmount releases the session — so this stays a single
|
||||
* value rather than a whole session handed upward.
|
||||
* `onConnected`, and the customer's own Cancel press is reported through
|
||||
* `onCancel` — so this stays a single value rather than a whole session
|
||||
* handed upward.
|
||||
*/
|
||||
onPromptReady?: (authorizationUrl: string | null) => void;
|
||||
};
|
||||
|
|
@ -2102,9 +2103,18 @@ function DisplayedCodeLoginPanel({
|
|||
// URL.
|
||||
const [latchedPrompt, setLatchedPrompt] = useState<AdapterAuthSessionPrompt | null>(null);
|
||||
|
||||
// True for the session currently held in `sessionId` when it came from the
|
||||
// owner-scoped resume read rather than a fresh `startLogin`. It marks the
|
||||
// one case that needs the extra release-on-error path below: a session this
|
||||
// browser instance did not just start, so a broken poll cannot fall back to
|
||||
// the ordinary "let the user press Sign in again" recovery — the owner has
|
||||
// no local memory of ever starting it.
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
const startLogin = useMutation({
|
||||
mutationFn: () => agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId }),
|
||||
onSuccess: (session) => {
|
||||
resumedRef.current = false;
|
||||
setStartError(null);
|
||||
setLatchedPrompt(null);
|
||||
setSessionId(session.sessionId);
|
||||
|
|
@ -2114,24 +2124,54 @@ function DisplayedCodeLoginPanel({
|
|||
},
|
||||
});
|
||||
|
||||
// Reset local state, so the panel returns to its idle start state and the
|
||||
// Sign in button is available again.
|
||||
const clearActiveSession = useCallback(() => {
|
||||
resumedRef.current = false;
|
||||
setSessionId(null);
|
||||
setLatchedPrompt(null);
|
||||
setStartError(null);
|
||||
}, []);
|
||||
|
||||
const cancelLogin = useMutation({
|
||||
mutationFn: () => agentsApi.cancelAdapterAuthLogin(companyId, adapterType, sessionId!),
|
||||
onSuccess: () => {
|
||||
// Reset local state, so the panel returns to its idle start state and the
|
||||
// Log in button is available again.
|
||||
setSessionId(null);
|
||||
setLatchedPrompt(null);
|
||||
setStartError(null);
|
||||
},
|
||||
onSuccess: clearActiveSession,
|
||||
onError: (error) => {
|
||||
setStartError(error instanceof Error ? error.message : "Could not cancel the login.");
|
||||
},
|
||||
});
|
||||
|
||||
// Read the caller's active session on mount, with no session id, so the
|
||||
// browser rediscovers its own session after a reload with no local state. A
|
||||
// 404 means no active session for the caller.
|
||||
const activeSessionQuery = useQuery({
|
||||
queryKey: ["adapter-login-active-session", companyId, adapterType],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.getActiveAdapterAuthLoginSession(companyId, adapterType);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// While the panel releases a resumed session it cannot recover (see below),
|
||||
// it keeps showing the login as active rather than dropping back to idle, so
|
||||
// it does not clear local state before the release finishes.
|
||||
const [releasingResumedSession, setReleasingResumedSession] = useState(false);
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["adapter-login-status", companyId, adapterType, sessionId],
|
||||
queryFn: () => agentsApi.getAdapterAuthLoginStatus(companyId, adapterType, sessionId!),
|
||||
enabled: Boolean(sessionId),
|
||||
enabled: Boolean(sessionId) && !releasingResumedSession,
|
||||
// A status 404 is unrecoverable: the server removed the row, so a retry
|
||||
// cannot bring it back. Stop at once and fail loudly.
|
||||
retry: (failureCount, error) => {
|
||||
if (error instanceof ApiError && error.status === 404) return false;
|
||||
return failureCount < 3;
|
||||
},
|
||||
refetchInterval: (query) => {
|
||||
const status = query.state.data?.status;
|
||||
return status && ADAPTER_LOGIN_TERMINAL_STATUSES.has(status)
|
||||
|
|
@ -2154,54 +2194,79 @@ function DisplayedCodeLoginPanel({
|
|||
const isActive = Boolean(sessionId) && !isTerminal;
|
||||
const startDisabled = startLogin.isPending || isActive;
|
||||
|
||||
// Release the server session at once, without a change to the panel state, the
|
||||
// way the submitted-browser-code panel does. The server holds a per-owner
|
||||
// reservation until the session reaches a terminal state, so an abandoned
|
||||
// session locks the owner out until the server deadline. Fire-and-forget: a
|
||||
// 404 means the server already removed a terminal session, and a cleanup path
|
||||
// cannot surface any other error either, so it drops them all. The manual
|
||||
// Cancel button keeps using the `cancelLogin` mutation, because that path also
|
||||
// returns the panel to its idle start state.
|
||||
const releaseServerSession = useCallback(
|
||||
(id: string) => {
|
||||
void agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id).catch(() => {
|
||||
// Drop the error, as above.
|
||||
});
|
||||
},
|
||||
[companyId, adapterType],
|
||||
);
|
||||
|
||||
// Hold the active session id for the unmount cleanup. Onboarding removes this
|
||||
// panel as soon as Cancel is pressed — `handleCancel` fires the request and
|
||||
// calls `onCancel` without waiting for it — so the panel can be gone before
|
||||
// the cancel resolves. Without this, a failed cancel, or any other unmount
|
||||
// (navigating away, the step advancing), would leave the reservation held
|
||||
// until the server deadline and an immediate retry unable to start. The ref is
|
||||
// null once the session leaves the active state, so the cleanup never cancels
|
||||
// a session the server already removed.
|
||||
const activeSessionRef = useRef<string | null>(null);
|
||||
activeSessionRef.current = isActive ? sessionId : null;
|
||||
|
||||
// Adopt the caller's active session once, on mount. This is what makes a
|
||||
// page reload keep the session: with no local state at all, the panel would
|
||||
// otherwise show its idle start state even though the server still holds an
|
||||
// active login for this owner.
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const id = activeSessionRef.current;
|
||||
if (id) releaseServerSession(id);
|
||||
};
|
||||
}, [releaseServerSession]);
|
||||
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
const active = activeSessionQuery.data;
|
||||
if (!active) return;
|
||||
resumedRef.current = true;
|
||||
setStartError(null);
|
||||
setLatchedPrompt(active.prompt ?? null);
|
||||
setSessionId(active.sessionId);
|
||||
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
|
||||
// Start once, on mount, when the caller has already taken the press. The ref
|
||||
// is the guard rather than the mutation's own pending flag: `startLogin`
|
||||
// settles, and without a latch a re-render after it settles would read "not
|
||||
// pending, no session yet" during the gap before the session id lands and
|
||||
// start a second login the server would count against the per-owner cap.
|
||||
// A resumed session's status poll found the session already gone: the read
|
||||
// that discovered it and the poll that tried to use it raced, and the
|
||||
// session lost. The panel cannot resume it, and there is no unmount cleanup
|
||||
// left to fall back on, so it releases the reservation itself and waits for
|
||||
// that release before it returns to the idle start state.
|
||||
useEffect(() => {
|
||||
const error = statusQuery.error;
|
||||
if (!(error instanceof ApiError && error.status === 404)) return;
|
||||
if (!resumedRef.current || releasingResumedSession) return;
|
||||
setReleasingResumedSession(true);
|
||||
const id = sessionId;
|
||||
void (async () => {
|
||||
if (id) {
|
||||
await agentsApi.cancelAdapterAuthLogin(companyId, adapterType, id).catch(() => {
|
||||
// The session is already gone either way; nothing more to do.
|
||||
});
|
||||
}
|
||||
setReleasingResumedSession(false);
|
||||
clearActiveSession();
|
||||
})();
|
||||
}, [statusQuery.error, releasingResumedSession, sessionId, companyId, adapterType, clearActiveSession]);
|
||||
|
||||
// Start once, on mount, when the caller has already taken the press, and
|
||||
// only once the resume read has answered: a resumed session takes over
|
||||
// instead of a fresh start. The ref is the guard rather than the mutation's
|
||||
// own pending flag: `startLogin` settles, and without a latch a re-render
|
||||
// after it settles would read "not pending, no session yet" during the gap
|
||||
// before the session id lands and start a second login the server would
|
||||
// count against the per-owner cap.
|
||||
const autoStartedRef = useRef(false);
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoStartedRef.current) return;
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
if (activeSessionQuery.isError) {
|
||||
autoStartedRef.current = true;
|
||||
setStartError(
|
||||
activeSessionQuery.error instanceof Error
|
||||
? activeSessionQuery.error.message
|
||||
: "Could not check for an active login.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!activeSessionQuery.isSuccess) return;
|
||||
autoStartedRef.current = true;
|
||||
if (activeSessionQuery.data) return;
|
||||
startLoginRef.current();
|
||||
}, [autoStart]);
|
||||
}, [
|
||||
autoStart,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionQuery.isError,
|
||||
activeSessionQuery.data,
|
||||
activeSessionQuery.error,
|
||||
]);
|
||||
|
||||
// Report success upward once. `authenticated` is this panel's terminal
|
||||
// success: unlike the Claude login there is no completion read after it, so
|
||||
|
|
@ -2236,6 +2301,7 @@ function DisplayedCodeLoginPanel({
|
|||
return (
|
||||
<OnboardingLoginCard
|
||||
loading={!prompt && !startError && !failed}
|
||||
onCancel={handleCancel}
|
||||
instruction={
|
||||
<>
|
||||
{/* The same destination as the step's own button. Two ways to one
|
||||
|
|
@ -2465,6 +2531,13 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
// apply-existing path binds the fixed reference with no new login round trip,
|
||||
// so the panel shows the applied confirmation and hides the apply affordance.
|
||||
const [appliedStored, setAppliedStored] = useState(false);
|
||||
// True for the session currently held in `sessionId` when it came from the
|
||||
// owner-scoped resume read rather than a fresh `startLogin`. It marks the
|
||||
// one case that needs the extra release-on-error path below: a session this
|
||||
// browser instance did not just start, so a broken poll cannot fall back to
|
||||
// the ordinary "let the user press Sign in again" recovery — the owner has
|
||||
// no local memory of ever starting it.
|
||||
const resumedRef = useRef(false);
|
||||
|
||||
const resetLocalState = () => {
|
||||
setStartError(null);
|
||||
|
|
@ -2520,6 +2593,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
: {}),
|
||||
}),
|
||||
onSuccess: (session) => {
|
||||
resumedRef.current = false;
|
||||
resetLocalState();
|
||||
setSessionId(session.sessionId);
|
||||
},
|
||||
|
|
@ -2531,6 +2605,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
const clearActiveSession = () => {
|
||||
// Return the panel to its idle start state. The Log in button is available
|
||||
// again, and both polls stop because the session id is null.
|
||||
resumedRef.current = false;
|
||||
setSessionId(null);
|
||||
resetLocalState();
|
||||
};
|
||||
|
|
@ -2555,7 +2630,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
});
|
||||
|
||||
// Release the server session at once, without a change to the panel state. The
|
||||
// client-cutoff timer and the unmount path both use this. The server holds a
|
||||
// client-cutoff timer uses this. The server holds a
|
||||
// per-owner reservation until the session reaches a terminal state, so an
|
||||
// abandoned session locks the owner out until the server deadline. A best-
|
||||
// effort cancel frees that reservation now, so the same owner can start a new
|
||||
|
|
@ -2575,11 +2650,33 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
[companyId],
|
||||
);
|
||||
|
||||
// Read the caller's active Claude setup-token session on mount, with no
|
||||
// session id, so the browser rediscovers its own session after a reload
|
||||
// with no local state. A 404 means no active session for the caller.
|
||||
const activeSessionQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-active-session", companyId],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await agentsApi.getActiveClaudeSetupTokenLoginSession(companyId);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// While the panel releases a resumed session it cannot recover (see below),
|
||||
// it keeps showing the login as active rather than dropping back to idle, so
|
||||
// it does not clear local state before the release finishes.
|
||||
const [releasingResumedSession, setReleasingResumedSession] = useState(false);
|
||||
|
||||
// Both polls run only while a session is active and the client cap has not
|
||||
// passed. The timeout stops the polls, so the panel never polls forever. A
|
||||
// status 404 also stops the polls: the server cleaned up the session, so the
|
||||
// panel enters a terminal failure state instead.
|
||||
const pollingEnabled = Boolean(sessionId) && !timedOut && !statusGone;
|
||||
const pollingEnabled =
|
||||
Boolean(sessionId) && !timedOut && !statusGone && !releasingResumedSession;
|
||||
|
||||
const statusQuery = useQuery({
|
||||
queryKey: ["claude-setup-token-status", companyId, sessionId],
|
||||
|
|
@ -2606,12 +2703,51 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
// race against the next poll. React Query keeps the last successful data on
|
||||
// error, so without this branch the panel would hold stale data and show
|
||||
// nothing. Enter the terminal failure state, which stops both polls.
|
||||
//
|
||||
// A resumed session takes a different path: the read that discovered it and
|
||||
// the poll that tried to use it raced, and the session lost. There is no
|
||||
// unmount cleanup left to fall back on, so the panel releases the
|
||||
// reservation itself and waits for that release before it returns to the
|
||||
// idle start state, instead of trusting the 404 alone.
|
||||
useEffect(() => {
|
||||
const error = statusQuery.error;
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setStatusGone(true);
|
||||
if (!(error instanceof ApiError && error.status === 404)) return;
|
||||
if (resumedRef.current) {
|
||||
if (releasingResumedSession) return;
|
||||
setReleasingResumedSession(true);
|
||||
const id = sessionId;
|
||||
void (async () => {
|
||||
if (id) {
|
||||
await agentsApi.cancelClaudeSetupTokenLogin(companyId, id).catch(() => {
|
||||
// The session is already gone either way; nothing more to do.
|
||||
});
|
||||
}
|
||||
setReleasingResumedSession(false);
|
||||
clearActiveSession();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
}, [statusQuery.error]);
|
||||
setStatusGone(true);
|
||||
}, [statusQuery.error, releasingResumedSession, sessionId, companyId]);
|
||||
|
||||
// Adopt the caller's active session once, on mount. This is what makes a
|
||||
// page reload keep the session: with no local state at all, the panel would
|
||||
// otherwise show its idle start state even though the server still holds an
|
||||
// active login for this owner.
|
||||
const resumeAttemptedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (resumeAttemptedRef.current || !activeSessionQuery.isFetched) return;
|
||||
resumeAttemptedRef.current = true;
|
||||
const active = activeSessionQuery.data;
|
||||
if (!active) return;
|
||||
resumedRef.current = true;
|
||||
resetLocalState();
|
||||
setSessionId(active.sessionId);
|
||||
if (active.prompt) {
|
||||
setAuthorizationUrl(active.prompt.authorizationUrl);
|
||||
if (active.prompt.transportAdvisory) setTransportInsecure(true);
|
||||
}
|
||||
}, [activeSessionQuery.isFetched, activeSessionQuery.data]);
|
||||
|
||||
// Poll the guarded prompt route until it returns the authorization URL. The
|
||||
// route returns 404 until the URL is ready, so the panel treats a 404 as
|
||||
|
|
@ -2686,21 +2822,6 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
const isActive = Boolean(sessionId) && !isStored && !isFailure && !timedOut;
|
||||
const startDisabled = startLogin.isPending || isActive;
|
||||
|
||||
// Hold the active session id for the unmount cleanup. The panel updates it on
|
||||
// every render. When the panel unmounts, or the parent removes it as the login
|
||||
// closes, with an active, non-terminal session, the cleanup releases that
|
||||
// session on the server. The ref is null once the session leaves the active
|
||||
// state, so the cleanup never cancels a session the server already removed.
|
||||
const activeSessionRef = useRef<string | null>(null);
|
||||
activeSessionRef.current = isActive ? sessionId : null;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const id = activeSessionRef.current;
|
||||
if (id) releaseServerSession(id);
|
||||
};
|
||||
}, [releaseServerSession]);
|
||||
|
||||
// Cap the active login at the server deadline. The timer arms when the login
|
||||
// becomes active and clears when the login leaves the active state (a terminal
|
||||
// status, a stored success, or a new login). It re-arms when `expiresAt`
|
||||
|
|
@ -2746,17 +2867,39 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
setBrowserCode("");
|
||||
};
|
||||
|
||||
// Start once, on mount, when the caller has already taken the press. Latched
|
||||
// for the same reason as the displayed-code panel: a second start would burn
|
||||
// an owner reservation, and here it would also rotate the stored token twice.
|
||||
// Start once, on mount, when the caller has already taken the press, and
|
||||
// only once the resume read has answered: a resumed session takes over
|
||||
// instead of a fresh start. Latched for the same reason as the
|
||||
// displayed-code panel: a second start would burn an owner reservation, and
|
||||
// here it would also rotate the stored token twice.
|
||||
const autoStartedRef = useRef(false);
|
||||
const startLoginRef = useRef(startLogin.mutate);
|
||||
startLoginRef.current = startLogin.mutate;
|
||||
useEffect(() => {
|
||||
if (!autoStart || autoStartedRef.current) return;
|
||||
// A failed lookup is not proof that no session exists: only a successful
|
||||
// lookup is. Show the failure to the user instead of starting a second
|
||||
// login the server would reject against the per-owner cap.
|
||||
if (activeSessionQuery.isError) {
|
||||
autoStartedRef.current = true;
|
||||
setStartError(
|
||||
activeSessionQuery.error instanceof Error
|
||||
? activeSessionQuery.error.message
|
||||
: "Could not check for an active login.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!activeSessionQuery.isSuccess) return;
|
||||
autoStartedRef.current = true;
|
||||
if (activeSessionQuery.data) return;
|
||||
startLoginRef.current();
|
||||
}, [autoStart]);
|
||||
}, [
|
||||
autoStart,
|
||||
activeSessionQuery.isSuccess,
|
||||
activeSessionQuery.isError,
|
||||
activeSessionQuery.data,
|
||||
activeSessionQuery.error,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Submit the pasted code without a press.
|
||||
|
|
@ -2819,6 +2962,7 @@ function SubmittedBrowserCodeLoginPanel({
|
|||
return (
|
||||
<OnboardingLoginCard
|
||||
loading={!authorizationUrl && !startError && !failedNow}
|
||||
onCancel={handleCancel}
|
||||
instruction={
|
||||
<>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ const mockAgentsApi = vi.hoisted(() => ({
|
|||
prompt: { url: "https://auth.openai.com/codex/device", code: "Q2RJ-E1YIF" },
|
||||
})),
|
||||
cancelAdapterAuthLogin: vi.fn(async () => ({})),
|
||||
// No default implementation: the top-level `beforeEach` sets the "no active
|
||||
// session" 404 rejection, matching the real route.
|
||||
getActiveAdapterAuthLoginSession: vi.fn(),
|
||||
getActiveClaudeSetupTokenLoginSession: vi.fn(),
|
||||
}));
|
||||
// The adapter registry mock below always returns this function, so a test
|
||||
// can shape the built adapter config (e.g. a configured ANTHROPIC_API_KEY)
|
||||
|
|
@ -326,6 +330,16 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
mockAgentsApi.getClaudeOAuthTokenStatus.mockRejectedValue(
|
||||
new ApiError("Not found", 404, null),
|
||||
);
|
||||
// Default: no active login session for the caller. A resume test
|
||||
// overrides this with a resolved session body.
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset();
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue(
|
||||
new ApiError("Not found", 404, null),
|
||||
);
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset();
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(
|
||||
new ApiError("Not found", 404, null),
|
||||
);
|
||||
// Reset to each mock's original default. `mockResolvedValue` /
|
||||
// `mockReturnValue` overrides a mock's implementation permanently — it
|
||||
// is not undone by `afterEach`'s `vi.clearAllMocks()`, which only clears
|
||||
|
|
@ -2418,6 +2432,42 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("shows the failure instead of starting a codex_local login when the active-session read fails", async () => {
|
||||
// A transient active-session lookup failure is not proof that no
|
||||
// session exists — only a successful lookup that resolves to null is.
|
||||
// The step must not start a second login the server would reject
|
||||
// against the per-owner cap; it shows the failure instead.
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "unknown" });
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockReset();
|
||||
mockAgentsApi.getActiveAdapterAuthLoginSession.mockRejectedValue(
|
||||
new ApiError("Service unavailable", 503, null),
|
||||
);
|
||||
const { root } = await openStep4({ adapterType: "codex_local" });
|
||||
|
||||
await pickSource(/OpenAI/);
|
||||
|
||||
expect(mockAgentsApi.startAdapterAuthLogin).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Service unavailable");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("shows the failure instead of starting a claude_local login when the active-session read fails", async () => {
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockReset();
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockRejectedValue(
|
||||
new ApiError("Service unavailable", 503, null),
|
||||
);
|
||||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||||
|
||||
await pickSource(/Claude/);
|
||||
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Service unavailable");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("hires on Connect, with no sign-in, when the signal reports a ready credential", async () => {
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "present" });
|
||||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||||
|
|
@ -2494,5 +2544,38 @@ describe("OnboardingWizard restore-gate (stale localStorage across accounts)", (
|
|||
expect(mockAgentsApi.getAdapterAuthSignal).not.toHaveBeenCalled();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
it("restores the started-login state after a reload, resuming the active session with no press", async () => {
|
||||
// A reload loses `connectPhase` — the draft deliberately does not carry
|
||||
// it, because a login is a live server session with a deadline, not
|
||||
// wizard state to replay blindly. The step must instead re-derive it
|
||||
// from the caller's active session, so a customer who reloads mid-login
|
||||
// sees their sign-in still running rather than the tile row again.
|
||||
mockAgentsApi.getAdapterAuthSignal.mockResolvedValue({ status: "absent" });
|
||||
mockAgentsApi.getActiveClaudeSetupTokenLoginSession.mockResolvedValue({
|
||||
sessionId: "claude-session-1",
|
||||
environmentId: "env-sandbox-1",
|
||||
status: "waiting_for_user",
|
||||
expiresAt: null,
|
||||
failure: null,
|
||||
panelMode: "submitted_browser_code",
|
||||
prompt: { authorizationUrl: "https://claude.ai/oauth/authorize?code=true" },
|
||||
});
|
||||
const { root } = await openStep4({ adapterType: "claude_local" });
|
||||
// A reload's resume now runs one layer deeper than a fresh press: the
|
||||
// step's own active-session read has to land before it opens the
|
||||
// sequence, and only then does the mounted panel run its own resume
|
||||
// read. Each is a further round trip `flushReact` has to catch up to.
|
||||
for (let i = 0; i < 10; i += 1) await flushReact();
|
||||
|
||||
// No press: the resumed session is discovered on load and the card
|
||||
// shows the login already running.
|
||||
expect(mockAgentsApi.startClaudeSetupTokenLogin).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain(
|
||||
"Sign in to Claude then come back and enter authorization code",
|
||||
);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1106,6 +1106,46 @@ function OnboardingWizardInner({
|
|||
const authSignalStatus = authSignalQuery.data?.status ?? null;
|
||||
const showAdapterLoginPanel =
|
||||
canShowAdapterLogin && (authSignalStatus === "absent" || authSignalStatus === "unknown");
|
||||
/**
|
||||
* Restores the connect sequence after a reload.
|
||||
*
|
||||
* The panel resumes an active session on its own mount, but this step only
|
||||
* mounts the panel once the sequence has moved off `idle` — and a reload
|
||||
* starts the sequence at `idle` again, deliberately: `connectPhase` is not
|
||||
* in the draft. Without this read, a reload during a login would leave the
|
||||
* panel unmounted and the resumed session unreachable from this step. A 404
|
||||
* means no active session for the caller.
|
||||
*/
|
||||
const activeLoginSessionQuery = useQuery({
|
||||
queryKey: createdCompanyId
|
||||
? queryKeys.agents.activeLoginSession(createdCompanyId, adapterType)
|
||||
: ["agents", "none", "active-login-session", adapterType],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return adapterCaps.login?.panelMode === "submitted_browser_code"
|
||||
? await agentsApi.getActiveClaudeSetupTokenLoginSession(createdCompanyId!)
|
||||
: await agentsApi.getActiveAdapterAuthLoginSession(createdCompanyId!, adapterType);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
enabled:
|
||||
Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 4 && canShowAdapterLogin,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!activeLoginSessionQuery.data) return;
|
||||
// Re-derive the row's answer along with the sequence: a resumed session
|
||||
// implies a source was already picked, and the row stays a question
|
||||
// otherwise (see `sourcePicked` above).
|
||||
setSourcePicked(true);
|
||||
// Skip straight past the collapsing beat — that animation is for a press
|
||||
// landing on a step already on screen, not for a reload that should show
|
||||
// the running login at once. The panel's own mount reports the resumed
|
||||
// prompt through `onPromptReady`, below, which is what moves this beat
|
||||
// from `loading` to `ready`, exactly as a fresh press would.
|
||||
setConnectPhase((phase) => (phase === "idle" ? "loading" : phase));
|
||||
}, [activeLoginSessionQuery.data]);
|
||||
/**
|
||||
* The signal is being fetched and has not answered yet.
|
||||
*
|
||||
|
|
@ -1353,20 +1393,18 @@ function OnboardingWizardInner({
|
|||
/**
|
||||
* Back, on the connect step, unwinds the sign-in before it leaves the step.
|
||||
*
|
||||
* With no Cancel on the card this is the only way out, and what it undoes
|
||||
* depends on how far in you are. Unmounting the panel is what releases the
|
||||
* server session — see the release-on-unmount effect in `AdapterLoginPanel`
|
||||
* — so the card leaving is the cancel, not a separate call.
|
||||
* This hides the card; it does not cancel the login. Unmounting the panel no
|
||||
* longer releases the server session — the session stays reachable for a
|
||||
* later resume, the same read that restores it after a reload — so backing
|
||||
* out and returning shows the sign-in still running, not a fresh one. The
|
||||
* card's own Cancel button is the only explicit release; `onCancel` below
|
||||
* puts the step back here when it fires.
|
||||
*/
|
||||
function unwindConnectStep() {
|
||||
setConnectAuthUrl(null);
|
||||
// Where the reverse starts depends on how far the sequence got. Backing out
|
||||
// during the collapse has no card to close and no room to give back, and
|
||||
// entering `unwindCard` regardless mounted the panel — which starts a
|
||||
// server login on mount — purely so the unmount could cancel it. Should
|
||||
// that cancel fail, the reservation is held to the server deadline and an
|
||||
// immediate retry cannot start. With no card open, the row is the whole of
|
||||
// the unwind.
|
||||
// during the collapse has no card to close and no room to give back.
|
||||
// With no card open, the row is the whole of the unwind.
|
||||
setConnectPhase(connectCardLive ? "unwindCard" : "unwindRow");
|
||||
}
|
||||
|
||||
|
|
@ -1502,7 +1540,9 @@ function OnboardingWizardInner({
|
|||
* Nothing else needs to reset it. A source can only change by being picked,
|
||||
* and picking sets the phase itself; the credential mode can only change
|
||||
* before the sequence starts, because its control is inert once the row has
|
||||
* collapsed.
|
||||
* collapsed. Switching either no longer needs its own reset: the panel keeps
|
||||
* its server session reachable for a later resume instead of releasing it on
|
||||
* the remount, so there is nothing left here for that change to undo.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (step === 4) return;
|
||||
|
|
@ -3131,9 +3171,11 @@ function OnboardingWizardInner({
|
|||
in the connect step's chrome. It owns the session; the
|
||||
step owns the sequence around it.
|
||||
|
||||
Unmounting it is the cancel: the panel releases its
|
||||
server session on unmount, so Back closing the card is
|
||||
what frees the owner's reservation.
|
||||
Unmounting it is no longer the cancel: the session
|
||||
stays reachable for a later resume, so Back and a
|
||||
source switch only hide the card. `onCancel` fires
|
||||
from the card's own Cancel button, the one explicit
|
||||
release, and puts the step back where Back would.
|
||||
|
||||
No "Use saved login" control: the hire step already
|
||||
applies a stored login on its own. */
|
||||
|
|
@ -3144,6 +3186,7 @@ function OnboardingWizardInner({
|
|||
environmentId={resolvedLoginEnvironmentId}
|
||||
chrome="onboarding"
|
||||
autoStart
|
||||
onCancel={unwindConnectStep}
|
||||
onPromptReady={(url) => {
|
||||
setConnectAuthUrl(url);
|
||||
// The prompt arriving is what ends the waiting beat.
|
||||
|
|
|
|||
|
|
@ -224,6 +224,8 @@ export const queryKeys = {
|
|||
["agents", companyId, "detect-model", adapterType] as const,
|
||||
authSignal: (companyId: string, adapterType: string, environmentId?: string | null) =>
|
||||
["agents", companyId, "auth-signal", adapterType, environmentId ?? null] as const,
|
||||
activeLoginSession: (companyId: string, adapterType: string) =>
|
||||
["agents", companyId, "active-login-session", adapterType] as const,
|
||||
},
|
||||
builtInAgents: {
|
||||
list: (companyId: string) => ["built-in-agents", companyId] as const,
|
||||
|
|
|
|||
Loading…
Reference in New Issue