From f9ba9ea2f58669e7b5f1778cb3a9dc42fcb7edfc Mon Sep 17 00:00:00 2001 From: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Wed, 12 Aug 2026 01:21:31 +1000 Subject: [PATCH] feat(gateway): project passive QR setup sessions --- .../OpenClawProtocol/GatewayModels.swift | 8 ++ docs/gateway/clients.md | 31 +++++- .../src/schema/openclaw.test.ts | 6 ++ .../gateway-protocol/src/schema/openclaw.ts | 6 +- src/gateway/server-lifecycle.ts | 20 ++++ src/gateway/server-methods/shared-types.ts | 36 +++---- .../system-agent-chat-turn.test.ts | 46 ++++++-- .../server-methods/system-agent-chat-turn.ts | 37 ++++--- .../system-agent-reset-boundary.test.ts | 1 + .../system-agent-session-lifecycle.test.ts | 61 +++++++++++ .../system-agent-session-lifecycle.ts | 88 +++++++++++++++ .../system-agent-session-ownership.test.ts | 30 ++++++ .../server-methods/system-agent.test.ts | 22 ++++ src/gateway/server-methods/system-agent.ts | 102 ++++++++++++++++-- src/gateway/server/ws-connection.ts | 8 ++ src/system-agent/chat-engine.ts | 60 +++++++++++ src/system-agent/chat-wizard-host.test.ts | 38 +++++++ src/system-agent/chat-wizard-host.ts | 19 ++++ 18 files changed, 570 insertions(+), 49 deletions(-) create mode 100644 src/gateway/server-methods/system-agent-session-lifecycle.test.ts create mode 100644 src/gateway/server-methods/system-agent-session-lifecycle.ts diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3eab7340ab23..479ee9d3c1d3 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -11132,6 +11132,7 @@ public struct SystemAgentChatParams: Codable, Sendable { public let message: String? public let wizardanswer: [String: AnyCodable]? public let wizardcancel: [String: AnyCodable]? + public let pollstepid: String? public let welcomevariant: AnyCodable? public let reset: Bool? public let context: [String: AnyCodable]? @@ -11142,6 +11143,7 @@ public struct SystemAgentChatParams: Codable, Sendable { message: String? = nil, wizardanswer: [String: AnyCodable]? = nil, wizardcancel: [String: AnyCodable]? = nil, + pollstepid: String? = nil, welcomevariant: AnyCodable? = nil, reset: Bool? = nil, context: [String: AnyCodable]? = nil, @@ -11151,6 +11153,7 @@ public struct SystemAgentChatParams: Codable, Sendable { self.message = message self.wizardanswer = wizardanswer self.wizardcancel = wizardcancel + self.pollstepid = pollstepid self.welcomevariant = welcomevariant self.reset = reset self.context = context @@ -11162,6 +11165,7 @@ public struct SystemAgentChatParams: Codable, Sendable { case message case wizardanswer = "wizardAnswer" case wizardcancel = "wizardCancel" + case pollstepid = "pollStepId" case welcomevariant = "welcomeVariant" case reset case context @@ -11174,6 +11178,7 @@ public struct SystemAgentChatResult: Codable, Sendable { public let reply: String public let sensitive: Bool? public let wizardinputpending: Bool? + public let wizardsettling: Bool? public let action: AnyCodable public let agentdraft: String? public let agentid: String? @@ -11187,6 +11192,7 @@ public struct SystemAgentChatResult: Codable, Sendable { reply: String, sensitive: Bool? = nil, wizardinputpending: Bool? = nil, + wizardsettling: Bool? = nil, action: AnyCodable, agentdraft: String? = nil, agentid: String? = nil, @@ -11199,6 +11205,7 @@ public struct SystemAgentChatResult: Codable, Sendable { self.reply = reply self.sensitive = sensitive self.wizardinputpending = wizardinputpending + self.wizardsettling = wizardsettling self.action = action self.agentdraft = agentdraft self.agentid = agentid @@ -11213,6 +11220,7 @@ public struct SystemAgentChatResult: Codable, Sendable { case reply case sensitive case wizardinputpending = "wizardInputPending" + case wizardsettling = "wizardSettling" case action case agentdraft = "agentDraft" case agentid = "agentId" diff --git a/docs/gateway/clients.md b/docs/gateway/clients.md index fff4cf98c1e4..9bd811891f2c 100644 --- a/docs/gateway/clients.md +++ b/docs/gateway/clients.md @@ -136,10 +136,33 @@ snapshot, so re-read them on every reconnect. ### Present system-agent QR codes -`GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE` and the QR wizard-step shape are -reserved until system-agent QR production and Gateway projection are both -available. Clients should not advertise this capability yet; the contract alone -does not make existing Gateway methods emit QR steps. +Advertise `GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE` only when the client can +render a QR image. A capable `openclaw.chat` session can then receive a QR +`step` through the same wizard-step contract used for other setup controls: + +```json +{ + "step": { + "id": "setup-qr", + "type": "qr", + "title": "Scan QR code", + "message": "Scan the code to continue.", + "qrDataUrl": "data:image/png;base64,...", + "expiresInMs": 120000, + "executor": "client" + } +} +``` + +The dependency owns completion of a QR step. Do not send `wizardAnswer` for +it. Keep the image visible for at most `expiresInMs`, and observe progress by +calling `openclaw.chat` with the same `sessionId` and `pollStepId: step.id`. +A response with `wizardSettling: true` means the owner is still finishing; +continue polling with bounded backoff until the next step or terminal reply. + +QR capability is bound to the in-memory session. If reconnect negotiation +changes it, the Gateway returns typed session-invalidated details. Discard the +old QR and call `openclaw.chat` with `reset: true` before continuing. ## Recover state after reconnect diff --git a/packages/gateway-protocol/src/schema/openclaw.test.ts b/packages/gateway-protocol/src/schema/openclaw.test.ts index 27c61c422773..4f5f8542aa16 100644 --- a/packages/gateway-protocol/src/schema/openclaw.test.ts +++ b/packages/gateway-protocol/src/schema/openclaw.test.ts @@ -56,6 +56,12 @@ describe("OpenClaw chat params protocol", () => { ).toBe(false); }); + it("accepts a passive wizard step poll", () => { + expect(validateSystemAgentChatParams({ sessionId: "session-1", pollStepId: "setup-qr" })).toBe( + true, + ); + }); + it("rejects unsafe page ids and unknown context fields", () => { expect(validateSystemAgentChatParams({ ...base, context: { page: "channels?tab=all" } })).toBe( false, diff --git a/packages/gateway-protocol/src/schema/openclaw.ts b/packages/gateway-protocol/src/schema/openclaw.ts index 969181f413c6..0361bb514de7 100644 --- a/packages/gateway-protocol/src/schema/openclaw.ts +++ b/packages/gateway-protocol/src/schema/openclaw.ts @@ -14,7 +14,7 @@ export const SystemAgentWizardCancelSchema = closedObject({ * OpenClaw chat lets clients (macOS app onboarding, future UIs) hold the * setup/repair conversation over the gateway. The gateway live-tests the * configured inference route before creating a session. Omitting `message` - * returns the welcome/greeting for a verified fresh session without input. + * returns the welcome/greeting unless `pollStepId` observes an active wizard step. */ export const SystemAgentChatParamsSchema = closedObject({ sessionId: NonEmptyString, @@ -24,6 +24,8 @@ export const SystemAgentChatParamsSchema = closedObject({ wizardAnswer: Type.Optional(WizardAnswerSchema), /** Direct client control for cancelling the currently rendered hosted wizard. */ wizardCancel: Type.Optional(SystemAgentWizardCancelSchema), + /** Observe one active wizard step without answering it. */ + pollStepId: Type.Optional(NonEmptyString), /** Seeds a purpose-specific first greeting for a fresh conversation. */ welcomeVariant: Type.Optional( Type.Union([Type.Literal("onboarding"), Type.Literal("new-agent")]), @@ -86,6 +88,8 @@ export const SystemAgentChatResultSchema = closedObject({ sensitive: Type.Optional(Type.Boolean()), /** The hosted wizard will consume the next message as its current step answer. */ wizardInputPending: Type.Optional(Type.Boolean()), + /** The hosted wizard is settling external work and has no answerable step. */ + wizardSettling: Type.Optional(Type.Boolean()), action: Type.Union([ Type.Literal("none"), // The user asked to talk to their agent; clients should move to their diff --git a/src/gateway/server-lifecycle.ts b/src/gateway/server-lifecycle.ts index bc1bc25eb416..dab394c76c9b 100644 --- a/src/gateway/server-lifecycle.ts +++ b/src/gateway/server-lifecycle.ts @@ -23,6 +23,7 @@ import { createLazyGatewayCronState } from "./server-cron-lazy.js"; import { createGatewayCronReconciliation } from "./server-cron-reconciled.js"; import { applyGatewayLaneConcurrency, resolveGatewayLaneConcurrency } from "./server-lanes.js"; import { createGatewayServerLiveState } from "./server-live-state.js"; +import { retireAndDisposeSystemAgentSessions } from "./server-methods/system-agent-session-lifecycle.js"; import type { GatewayRequestContext } from "./server-methods/types.js"; import type { GatewayCloseOptions } from "./server-public.js"; import type { prepareGatewayKernelState } from "./server-runtime-state-prepare.js"; @@ -85,6 +86,9 @@ export async function prepareGatewayLifecycle(params: { nodeDesktopStreamBroker, bindDeviceNodeControl, workerPlacementRuntime, + systemAgentSessions, + wizardSessions, + pluginGatewayContext, } = runtime; workerGatewayEndpoint.resolve = transportBridge.getWorkerIngressEndpoint; const subscribeSessionMessageEvents: GatewayRequestContext["subscribeSessionMessageEvents"] = ( @@ -368,6 +372,20 @@ export async function prepareGatewayLifecycle(params: { maintenanceTimer: null, retainedPluginCleanupHandle: null, }; + let systemAgentSessionsStopPromise: Promise | null = null; + const systemAgentSessionsResident = residentRegistry.register({ + name: "system-agent-sessions", + start: () => undefined, + stop: () => { + systemAgentSessionsStopPromise ??= retireAndDisposeSystemAgentSessions({ + sessions: systemAgentSessions, + wizardSessions, + approvalManager: pluginGatewayContext.current?.systemAgentApprovalManager, + }); + return systemAgentSessionsStopPromise; + }, + }); + systemAgentSessionsResident.start(); const clearPostReadyMaintenanceTimer = () => { if (!postReadyState.maintenanceTimer) { return; @@ -389,6 +407,7 @@ export async function prepareGatewayLifecycle(params: { lifecycle.closePreludeStarted = true; // Fence background owners before any awaited close step can tear down the // plugin/channel or shared-state runtime they still need. + void systemAgentSessionsResident.stop(); void stopOutboundDeliveryRecoveryForClose(); void stopMediaCleanupForClose(); runtimeState.stopGatewayUpdateCheck(); @@ -415,6 +434,7 @@ export async function prepareGatewayLifecycle(params: { stopOutboundDeliveryRecoveryForClose(), stopMediaCleanupForClose(), stopConfigReloaderForClose().catch(() => {}), + systemAgentSessionsResident.stop(), ]); }; const runClosePrelude = async () => { diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index a6ec18b66f10..a877d8f1c758 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -3,6 +3,7 @@ import type { SystemAgentChatQuestion, SystemAgentWizardCancel, WizardAnswer, + WizardStep, } from "../../../packages/gateway-protocol/src/index.js"; // Shared server-method types define the client, context, response, and handler // contracts used by every gateway RPC method module. @@ -148,32 +149,29 @@ type SystemAgentHistoryTurn = { text: string; }; +type GatewaySystemAgentChatReply = { + text: string; + action: "none" | "exit" | "open-tui" | "open-setup"; + sensitive?: boolean; + wizardInputPending?: boolean; + wizardSettling?: boolean; + question?: SystemAgentChatQuestion; + step?: WizardStep; +}; + type GatewaySystemAgentSession = { engine: { handle: ( message: string, options?: { uiContext?: { page: string } }, - ) => Promise<{ - text: string; - action: "none" | "exit" | "open-tui" | "open-setup"; - sensitive?: boolean; - question?: SystemAgentChatQuestion; - }>; - answerWizard: (answer: WizardAnswer) => Promise<{ - text: string; - action: "none" | "exit" | "open-tui" | "open-setup"; - sensitive?: boolean; - question?: SystemAgentChatQuestion; - }>; - cancelWizard: (cancel: SystemAgentWizardCancel) => Promise<{ - text: string; - action: "none" | "exit" | "open-tui" | "open-setup"; - sensitive?: boolean; - question?: SystemAgentChatQuestion; - }>; + ) => Promise; + answerWizard: (answer: WizardAnswer) => Promise; + cancelWizard: (cancel: SystemAgentWizardCancel) => Promise; + pollStep: (stepId: string) => Promise; seedHistory: (turns: readonly SystemAgentHistoryTurn[]) => void; historyLength: () => number; historySince: (index: number) => SystemAgentHistoryTurn[]; + hasPendingQrCode: () => boolean; getPendingOperatorProposal: () => { operation: SystemAgentOperation; hash: string } | null; resolveOperatorApproval: ( decision: "allow-once" | "allow-always" | "deny" | null, @@ -187,6 +185,8 @@ type GatewaySystemAgentSession = { welcomeAuditSequence?: number; lastUsedAt: number; ownerKey: string; + /** QR presentation support negotiated when the session was created. */ + supportsQrCode: boolean; pendingApproval?: { id: string; proposalHash: string }; }; diff --git a/src/gateway/server-methods/system-agent-chat-turn.test.ts b/src/gateway/server-methods/system-agent-chat-turn.test.ts index 822091b99f94..36b932d1ed6e 100644 --- a/src/gateway/server-methods/system-agent-chat-turn.test.ts +++ b/src/gateway/server-methods/system-agent-chat-turn.test.ts @@ -9,11 +9,13 @@ function makeEngine() { const handle = vi.fn(); const answerWizard = vi.fn(); const cancelWizard = vi.fn(); + const pollStep = vi.fn(); return { answerWizard, cancelWizard, handle, - engine: { answerWizard, cancelWizard, handle }, + pollStep, + engine: { answerWizard, cancelWizard, handle, pollStep }, }; } @@ -25,7 +27,7 @@ describe("system-agent chat input", () => { message: "5", wizardAnswer: { stepId: "channel", value: "twitch" }, }, - error: "Send either message or wizardAnswer, not both.", + error: "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.", }, { input: { @@ -33,7 +35,7 @@ describe("system-agent chat input", () => { wizardAnswer: { stepId: "secret", value: "not-forwarded" }, delegation: { agentId: "main", sessionKey: "agent:main:main" }, }, - error: "Delegated OpenClaw sessions cannot submit structured wizard answers.", + error: "Delegated OpenClaw sessions cannot answer or poll structured wizard steps.", }, { input: { @@ -41,7 +43,7 @@ describe("system-agent chat input", () => { wizardAnswer: { stepId: "channel", value: "twitch" }, reset: true, }, - error: "A wizard answer cannot reset its OpenClaw chat session.", + error: "A wizard answer or poll cannot reset its OpenClaw chat session.", }, { input: { @@ -49,7 +51,7 @@ describe("system-agent chat input", () => { message: "cancel", wizardCancel: { stepId: "channel" }, }, - error: "Send wizardCancel without a message or wizardAnswer.", + error: "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.", }, { input: { @@ -57,7 +59,7 @@ describe("system-agent chat input", () => { wizardAnswer: { stepId: "channel", value: "twitch" }, wizardCancel: { stepId: "channel" }, }, - error: "Send wizardCancel without a message or wizardAnswer.", + error: "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId.", }, { input: { @@ -116,6 +118,25 @@ describe("system-agent chat input", () => { expect(handle).not.toHaveBeenCalled(); }); + it("routes a passive wizard poll without answering the step", async () => { + const { engine, answerWizard, pollStep } = makeEngine(); + pollStep.mockResolvedValue({ + text: "Setup is still finishing this QR operation.", + action: "none", + wizardSettling: true, + }); + + await expect( + runSystemAgentChatInput({ + engine, + input: { sessionId: "s1", pollStepId: "qr-step" }, + }), + ).resolves.toMatchObject({ wizardSettling: true }); + + expect(pollStep).toHaveBeenCalledWith("qr-step"); + expect(answerWizard).not.toHaveBeenCalled(); + }); + it("preserves the enriched wizard step in the gateway result", () => { expect( buildSystemAgentChatResult({ @@ -138,4 +159,17 @@ describe("system-agent chat input", () => { step: { id: "channel", type: "select" }, }); }); + + it("projects wizard settlement state", () => { + expect( + buildSystemAgentChatResult({ + sessionId: "s1", + reply: { + text: "Setup is still finishing this QR operation.", + action: "none", + wizardSettling: true, + }, + }), + ).toMatchObject({ wizardSettling: true }); + }); }); diff --git a/src/gateway/server-methods/system-agent-chat-turn.ts b/src/gateway/server-methods/system-agent-chat-turn.ts index 34b730bce5ee..a280f5772c99 100644 --- a/src/gateway/server-methods/system-agent-chat-turn.ts +++ b/src/gateway/server-methods/system-agent-chat-turn.ts @@ -7,24 +7,33 @@ import type { SystemAgentChatEngine } from "../../system-agent/chat-engine.js"; type SystemAgentChatReply = Awaited>; type SystemAgentChatEngineInput = Pick< SystemAgentChatEngine, - "answerWizard" | "cancelWizard" | "handle" + "answerWizard" | "cancelWizard" | "handle" | "pollStep" >; export function getSystemAgentChatInputError(params: SystemAgentChatParams): string | undefined { - if (params.message !== undefined && params.wizardAnswer !== undefined) { - return "Send either message or wizardAnswer, not both."; - } - if (params.wizardAnswer !== undefined && params.delegation !== undefined) { - return "Delegated OpenClaw sessions cannot submit structured wizard answers."; - } - if (params.wizardAnswer !== undefined && params.reset === true) { - return "A wizard answer cannot reset its OpenClaw chat session."; + const inputCount = [ + params.message, + params.wizardAnswer, + params.wizardCancel, + params.pollStepId, + ].filter((value) => value !== undefined).length; + if (inputCount > 1) { + return "Send exactly one of message, wizardAnswer, wizardCancel, or pollStepId."; } if ( - params.wizardCancel !== undefined && - (params.message !== undefined || params.wizardAnswer !== undefined) + (params.wizardAnswer !== undefined || params.pollStepId !== undefined) && + params.delegation !== undefined ) { - return "Send wizardCancel without a message or wizardAnswer."; + return "Delegated OpenClaw sessions cannot answer or poll structured wizard steps."; + } + if ( + (params.wizardAnswer !== undefined || params.pollStepId !== undefined) && + params.reset === true + ) { + return "A wizard answer or poll cannot reset its OpenClaw chat session."; + } + if (params.pollStepId !== undefined && (params.welcomeVariant || params.context)) { + return "A wizard poll cannot include welcome or UI context."; } if (params.wizardCancel !== undefined && params.delegation !== undefined) { return "Delegated OpenClaw sessions cannot cancel hosted wizards."; @@ -39,6 +48,9 @@ export async function runSystemAgentChatInput(params: { engine: SystemAgentChatEngineInput; input: SystemAgentChatParams; }): Promise { + if (params.input.pollStepId !== undefined) { + return await params.engine.pollStep(params.input.pollStepId); + } if (params.input.wizardAnswer !== undefined) { return await params.engine.answerWizard(params.input.wizardAnswer); } @@ -82,6 +94,7 @@ export function buildSystemAgentChatResult(params: { : {}), ...(params.reply.sensitive === true ? { sensitive: true } : {}), ...(params.reply.wizardInputPending === true ? { wizardInputPending: true } : {}), + ...(params.reply.wizardSettling === true ? { wizardSettling: true } : {}), ...(params.reply.question ? { question: params.reply.question } : {}), ...(params.reply.step ? { step: params.reply.step } : {}), ...(params.proposalId ? { needsApproval: true, proposalId: params.proposalId } : {}), diff --git a/src/gateway/server-methods/system-agent-reset-boundary.test.ts b/src/gateway/server-methods/system-agent-reset-boundary.test.ts index 853f1ccf7892..9f2b4f3dd8af 100644 --- a/src/gateway/server-methods/system-agent-reset-boundary.test.ts +++ b/src/gateway/server-methods/system-agent-reset-boundary.test.ts @@ -87,6 +87,7 @@ function discardableSessions(dispose: () => Promise): Map; diff --git a/src/gateway/server-methods/system-agent-session-lifecycle.test.ts b/src/gateway/server-methods/system-agent-session-lifecycle.test.ts new file mode 100644 index 000000000000..09f8681b6323 --- /dev/null +++ b/src/gateway/server-methods/system-agent-session-lifecycle.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDeferred } from "../../../test/helpers/promise.js"; +import { + assertSystemAgentSessionStoreActive, + disposeSystemAgentSessionsForOwner, + retireAndDisposeSystemAgentSessions, +} from "./system-agent-session-lifecycle.js"; +import type { GatewayRequestContext } from "./types.js"; + +type Sessions = GatewayRequestContext["systemAgentSessions"]; +type Session = Sessions extends Map ? Value : never; + +function session(ownerKey: string, dispose: () => Promise): Session { + return { + ownerKey, + engine: { dispose }, + } as unknown as Session; +} + +describe("system-agent session lifecycle", () => { + it("removes one connection owner synchronously and joins its disposal", async () => { + const release = createDeferred(); + const sessions = new Map([ + ["connection-session", session("connection:one", async () => await release.promise)], + ["device-session", session("device:two", async () => undefined)], + ]) as Sessions; + + const disposal = disposeSystemAgentSessionsForOwner({ + sessions, + ownerKey: "connection:one", + }); + + expect(sessions.has("connection-session")).toBe(false); + expect(sessions.has("device-session")).toBe(true); + release.resolve(); + await expect(disposal).resolves.toBeUndefined(); + }); + + it("retires admission before cancelling and joining every setup owner", async () => { + const dispose = vi.fn(async () => undefined); + const cancel = vi.fn(() => true); + const sessions = new Map([["session", session("device:one", dispose)]]) as Sessions; + const wizardSessions = new Map([ + [ + "wizard", + { + cancel, + whenSettled: async () => undefined, + }, + ], + ]) as unknown as GatewayRequestContext["wizardSessions"]; + + await retireAndDisposeSystemAgentSessions({ sessions, wizardSessions }); + + expect(() => assertSystemAgentSessionStoreActive(sessions)).toThrow("shutting down"); + expect(sessions.size).toBe(0); + expect(wizardSessions.size).toBe(0); + expect(dispose).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/gateway/server-methods/system-agent-session-lifecycle.ts b/src/gateway/server-methods/system-agent-session-lifecycle.ts new file mode 100644 index 000000000000..1dc100dd8c25 --- /dev/null +++ b/src/gateway/server-methods/system-agent-session-lifecycle.ts @@ -0,0 +1,88 @@ +import type { GatewayRequestContext } from "./types.js"; + +type SystemAgentSessions = GatewayRequestContext["systemAgentSessions"]; +type WizardSessions = GatewayRequestContext["wizardSessions"]; +type ApprovalManager = NonNullable; + +const retiredStores = new WeakSet(); +const pendingDisposals = new WeakMap>>(); + +export function assertSystemAgentSessionStoreActive(sessions: SystemAgentSessions): void { + if (retiredStores.has(sessions)) { + throw new Error("OpenClaw session owner is shutting down."); + } +} + +function trackDisposal(sessions: SystemAgentSessions, disposal: Promise): Promise { + let pending = pendingDisposals.get(sessions); + if (!pending) { + pending = new Set(); + pendingDisposals.set(sessions, pending); + } + pending.add(disposal); + void disposal.finally(() => pending?.delete(disposal)).catch(() => undefined); + return disposal; +} + +function expireSessionApproval( + session: SystemAgentSessions extends Map ? Session : never, + approvalManager: ApprovalManager | undefined, + reason: string, +): void { + if (session.pendingApproval) { + approvalManager?.expire(session.pendingApproval.id, reason); + } +} + +export function disposeSystemAgentSessionsForOwner(params: { + sessions: SystemAgentSessions; + ownerKey: string; + approvalManager?: ApprovalManager; +}): Promise { + const disposals: Promise[] = []; + for (const [sessionId, session] of params.sessions) { + if (session.ownerKey !== params.ownerKey) { + continue; + } + params.sessions.delete(sessionId); + expireSessionApproval(session, params.approvalManager, "session-owner-disconnected"); + disposals.push(session.engine.dispose()); + } + const disposal = Promise.allSettled(disposals).then((results) => { + const errors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (errors.length > 0) { + throw new AggregateError(errors, `Failed to dispose ${errors.length} OpenClaw session(s)`); + } + }); + return trackDisposal(params.sessions, disposal); +} + +export function retireAndDisposeSystemAgentSessions(params: { + sessions: SystemAgentSessions; + wizardSessions: WizardSessions; + approvalManager?: ApprovalManager; +}): Promise { + retiredStores.add(params.sessions); + const disposals: Promise[] = [...(pendingDisposals.get(params.sessions) ?? [])]; + for (const [sessionId, session] of params.sessions) { + params.sessions.delete(sessionId); + expireSessionApproval(session, params.approvalManager, "gateway-shutdown"); + disposals.push(session.engine.dispose()); + } + for (const [sessionId, session] of params.wizardSessions) { + params.wizardSessions.delete(sessionId); + session.cancel(); + disposals.push(session.whenSettled()); + } + const disposal = Promise.allSettled(disposals).then((results) => { + const errors = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (errors.length > 0) { + throw new AggregateError(errors, `Failed to settle ${errors.length} Gateway setup owner(s)`); + } + }); + return trackDisposal(params.sessions, disposal); +} diff --git a/src/gateway/server-methods/system-agent-session-ownership.test.ts b/src/gateway/server-methods/system-agent-session-ownership.test.ts index 9113e3306b78..d8c0b5c837a5 100644 --- a/src/gateway/server-methods/system-agent-session-ownership.test.ts +++ b/src/gateway/server-methods/system-agent-session-ownership.test.ts @@ -38,6 +38,8 @@ vi.mock("../../system-agent/greeting.js", () => ({ type FakeEngine = { answerWizard: ReturnType; cancelWizard: ReturnType; + pollStep: ReturnType; + hasPendingQrCode: ReturnType; handle: ReturnType; seedHistory: ReturnType; historyLength: ReturnType; @@ -57,6 +59,10 @@ function makeEngine(): FakeEngine { cancelWizard: vi.fn(async () => { throw new SystemAgentWizardAnswerError("No hosted wizard is awaiting cancellation."); }), + pollStep: vi.fn(async () => { + throw new SystemAgentWizardAnswerError("The hosted wizard step is no longer active."); + }), + hasPendingQrCode: vi.fn(() => false), handle: vi.fn(async () => ({ text: "did the thing", action: "none" })), seedHistory: vi.fn(), historyLength: vi.fn(() => 0), @@ -112,12 +118,14 @@ function makeContext(sessions: Map): GatewayRequ function seededSession(params?: { engine?: FakeEngine; ownerKey?: string; + supportsQrCode?: boolean; }): SystemAgentChatSession { return { engine: params?.engine ?? makeEngine(), welcome: "welcome text", lastUsedAt: 1, ownerKey: params?.ownerKey ?? "device:device-test", + supportsQrCode: params?.supportsQrCode ?? false, } as unknown as SystemAgentChatSession; } @@ -154,6 +162,28 @@ afterEach(() => { }); describe("openclaw.chat session ownership", () => { + it("invalidates a session when reconnect negotiation changes QR support", async () => { + const engine = makeEngine(); + const sessions = new Map([ + ["qr-session", seededSession({ engine, supportsQrCode: true })], + ]); + + const result = await callChat(makeContext(sessions), { + sessionId: "qr-session", + message: "status", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "INVALID_REQUEST", + details: { code: "system_agent_session_invalidated" }, + }, + }); + expect(engine.handle).not.toHaveBeenCalled(); + expect(engine.dispose).not.toHaveBeenCalled(); + }); + it("binds a new non-delegated session and rejects another principal", async () => { const sessions = new Map(); const context = makeContext(sessions); diff --git a/src/gateway/server-methods/system-agent.test.ts b/src/gateway/server-methods/system-agent.test.ts index c1dc3aab4989..e61f6b8a2a8d 100644 --- a/src/gateway/server-methods/system-agent.test.ts +++ b/src/gateway/server-methods/system-agent.test.ts @@ -218,6 +218,7 @@ function seededSession(overrides?: Partial): SystemAgent welcome: "welcome text", lastUsedAt: 1, ownerKey: "device:device-test", + supportsQrCode: false, ...overrides, }; } @@ -1056,6 +1057,27 @@ describe("openclaw.chat", () => { expect([sessions.has("new-1"), sessions.has("new-2")]).toEqual([true, true]); }); + it("does not evict the newest active QR operation for each owner", async () => { + const sessions = new Map(); + for (let index = 0; index < 8; index += 1) { + const protectedSession = seededSession({ + lastUsedAt: index, + ownerKey: `device:owner-${index}`, + }); + vi.spyOn(protectedSession.engine, "hasPendingQrCode").mockReturnValue(true); + sessions.set(`protected-${index}`, protectedSession); + } + stubEngineOverview(); + + const result = await callChat(makeContext(sessions), { sessionId: "new-session" }); + + expect(result).toMatchObject({ ok: false, error: { code: "UNAVAILABLE" } }); + expect(sessions.size).toBe(8); + expect([...sessions.keys()]).toEqual( + Array.from({ length: 8 }, (_value, index) => `protected-${index}`), + ); + }); + it("resets a session on request", async () => { stubEngineOverview(); transcriptStoreMocks.readTranscriptTail.mockReturnValue([]); diff --git a/src/gateway/server-methods/system-agent.ts b/src/gateway/server-methods/system-agent.ts index 644072609820..d84ec15880a1 100644 --- a/src/gateway/server-methods/system-agent.ts +++ b/src/gateway/server-methods/system-agent.ts @@ -1,5 +1,9 @@ import { randomUUID } from "node:crypto"; // OpenClaw gateway methods host the setup/repair conversation for clients. +import { + GATEWAY_CLIENT_CAPS, + hasGatewayClientCap, +} from "../../../packages/gateway-protocol/src/client-info.js"; import { buildSystemAgentInferenceUnavailableErrorDetails, buildSystemAgentSessionInvalidatedErrorDetails, @@ -62,6 +66,7 @@ import { getSystemAgentChatInputError, runSystemAgentChatInput, } from "./system-agent-chat-turn.js"; +import { assertSystemAgentSessionStoreActive } from "./system-agent-session-lifecycle.js"; import type { GatewayClient, GatewayRequestContext, @@ -154,13 +159,27 @@ function resolveSystemAgentSessionOwnerKey(params: { async function evictOldestSession( sessions: Map, context: GatewayRequestContext, -): Promise { +): Promise { if (sessions.size < MAX_SYSTEM_AGENT_SESSIONS) { - return; + return true; } + const protectedQrSessions = new Map(); + for (const [key, session] of sessions) { + if (!session.engine.hasPendingQrCode()) { + continue; + } + const current = protectedQrSessions.get(session.ownerKey); + if (!current || session.lastUsedAt >= current.lastUsedAt) { + protectedQrSessions.set(session.ownerKey, { key, lastUsedAt: session.lastUsedAt }); + } + } + const protectedKeys = new Set([...protectedQrSessions.values()].map(({ key }) => key)); let oldestKey: string | undefined; let oldestAt = Number.POSITIVE_INFINITY; for (const [key, session] of sessions) { + if (protectedKeys.has(key)) { + continue; + } if (session.lastUsedAt < oldestAt) { oldestAt = session.lastUsedAt; oldestKey = key; @@ -171,9 +190,11 @@ async function evictOldestSession( if (oldest?.pendingApproval) { context.systemAgentApprovalManager?.expire(oldest.pendingApproval.id, "session-evicted"); } - await oldest?.engine.dispose(); sessions.delete(oldestKey); + await oldest?.engine.dispose(); + return true; } + return false; } function persistEngineHistory(engine: SystemAgentChatSession["engine"], startIndex: number): void { @@ -521,6 +542,11 @@ export const systemAgentHandlers: GatewayRequestHandlers = { // it, concurrent first messages can create competing engines and lose // conversation state when the later initializer replaces the first. await getSystemAgentSessionQueue(sessions).enqueue(sessionId, async () => { + assertSystemAgentSessionStoreActive(sessions); + const supportsQrCode = hasGatewayClientCap( + client?.connect.caps, + GATEWAY_CLIENT_CAPS.SYSTEM_AGENT_QR_CODE, + ); const ownerKey = resolveSystemAgentSessionOwnerKey({ delegation: params.delegation, client, @@ -538,7 +564,21 @@ export const systemAgentHandlers: GatewayRequestHandlers = { respond( false, undefined, - errorShape(ErrorCodes.INVALID_REQUEST, "OpenClaw session belongs to another caller."), + errorShape(ErrorCodes.INVALID_REQUEST, "OpenClaw session belongs to another caller.", { + details: buildSystemAgentSessionInvalidatedErrorDetails(), + }), + ); + return; + } + if (boundSession && !params.reset && boundSession.supportsQrCode !== supportsQrCode) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + "OpenClaw session capabilities changed; reset the session before continuing.", + { details: buildSystemAgentSessionInvalidatedErrorDetails() }, + ), ); return; } @@ -556,7 +596,12 @@ export const systemAgentHandlers: GatewayRequestHandlers = { await existing?.engine.dispose(); } let session = sessions.get(sessionId); - if ((params.wizardAnswer !== undefined || params.wizardCancel !== undefined) && !session) { + if ( + (params.wizardAnswer !== undefined || + params.wizardCancel !== undefined || + params.pollStepId !== undefined) && + !session + ) { respond( false, undefined, @@ -564,7 +609,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = { ErrorCodes.INVALID_REQUEST, params.wizardCancel !== undefined ? "No active OpenClaw chat session is awaiting that wizard cancel." - : "No active OpenClaw chat session is awaiting that wizard answer.", + : "No active OpenClaw chat session is awaiting that wizard step.", { details: buildSystemAgentSessionInvalidatedErrorDetails() }, ), ); @@ -574,6 +619,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = { const welcomeOnly = params.wizardAnswer === undefined && params.wizardCancel === undefined && + params.pollStepId === undefined && (params.message === undefined || !params.message.trim()); if (!session) { const { verifySystemAgentInferenceWithFallback } = @@ -600,6 +646,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = { // engine's setup path honors this via surface: "gateway". const engine = new SystemAgentChatEngine({ surface: "gateway", + supportsQrCode, verifiedInference: inference.binding, operatorApprovalOnly: params.delegation !== undefined, }); @@ -645,8 +692,37 @@ export const systemAgentHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, error.message)); return; } + if (!(await evictOldestSession(sessions, context))) { + await engine.dispose().catch(() => undefined); + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "OpenClaw chat capacity is reserved for active QR operations; try again after one completes.", + { retryable: true }, + ), + ); + return; + } + const connectionId = client?.connId?.trim(); + if ( + ownerKey.startsWith("connection:") && + connectionId && + context.isConnectionActive?.(connectionId) === false + ) { + await engine.dispose().catch(() => undefined); + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "OpenClaw connection is no longer active.", { + details: buildSystemAgentSessionInvalidatedErrorDetails(), + }), + ); + return; + } + assertSystemAgentSessionStoreActive(sessions); persistEngineHistory(engine, welcomeHistoryStart); - await evictOldestSession(sessions, context); session = { engine, welcome, @@ -656,6 +732,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = { : {}), lastUsedAt: Date.now(), ownerKey, + supportsQrCode, }; sessions.set(sessionId, session); if (welcomeOnly) { @@ -678,6 +755,7 @@ export const systemAgentHandlers: GatewayRequestHandlers = { if ( params.wizardAnswer === undefined && params.wizardCancel === undefined && + params.pollStepId === undefined && (params.message === undefined || !params.message.trim()) ) { respond( @@ -712,7 +790,15 @@ export const systemAgentHandlers: GatewayRequestHandlers = { } catch (error) { persistEngineHistory(session.engine, historyStart); if (error instanceof SystemAgentWizardAnswerError) { - respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, error.message)); + const options = + params.pollStepId === undefined + ? undefined + : { details: buildSystemAgentSessionInvalidatedErrorDetails() }; + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, error.message, options), + ); return; } if (!isSystemAgentInferenceUnavailableError(error)) { diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 11547c0ddd2a..71b92bc7a2a5 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -25,6 +25,7 @@ import { MAX_PAYLOAD_BYTES, MAX_PREAUTH_PAYLOAD_BYTES, } from "../server-constants.js"; +import { disposeSystemAgentSessionsForOwner } from "../server-methods/system-agent-session-lifecycle.js"; import type { GatewayRequestContext, GatewayRequestHandlers } from "../server-methods/types.js"; import { formatError } from "../server-utils.js"; import { @@ -510,6 +511,13 @@ export function attachGatewayWsConnectionHandler(params: AttachGatewayWsConnecti } if (connectionKind === "gateway") { const context = buildRequestContext(); + void disposeSystemAgentSessionsForOwner({ + sessions: context.systemAgentSessions, + ownerKey: `connection:${connId}`, + approvalManager: context.systemAgentApprovalManager, + }).catch((error) => { + logGateway.warn(`OpenClaw session cleanup failed: ${formatError(error)}`); + }); cleanupTalkConnection(connId, logGateway); context.unsubscribeAllSessionEvents(connId); // Detach (or, with a zero grace period, kill) any PTY shells this diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index 4dcc8d2507c1..3a3f978499d2 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -74,6 +74,7 @@ export class SystemAgentChatEngine { private disposed = false; private disposal: Promise | null = null; private persistentApplySettlement: Promise | null = null; + private retainedPollReplies = new Map(); constructor( private readonly options: SystemAgentChatEngineOptions, @@ -203,6 +204,65 @@ export class SystemAgentChatEngine { return await turn; } + /** Observe a passive wizard step while keeping its continuation in the turn queue. */ + async pollStep(stepId: string): Promise { + this.assertActive(); + const retained = this.retainedPollReplies.get(stepId); + if (retained) { + this.retainedPollReplies.delete(stepId); + if (retained.text) { + this.history.push({ role: "assistant", text: retained.text }); + } + return { ...retained }; + } + const observation = this.turnQueue.then(async () => { + this.assertActive(); + const queuedRetained = this.retainedPollReplies.get(stepId); + if (queuedRetained) { + return { ...queuedRetained }; + } + const result = await this.wizard.pollStep(stepId); + this.assertActive(); + const reply = this.wizard.decorateReply({ text: result.text, action: "none" }); + if ( + reply.step === undefined && + reply.wizardInputPending !== true && + reply.wizardSettling !== true + ) { + this.retainedPollReplies.set(stepId, { ...reply }); + } + return reply; + }); + this.turnQueue = observation.catch(() => undefined); + let timer: ReturnType | undefined; + const outcome = await Promise.race([ + observation.then((reply) => ({ reply })), + new Promise((resolve) => { + timer = setTimeout(resolve, 0); + }), + ]); + if (timer) { + clearTimeout(timer); + } + if (outcome) { + if ( + outcome.reply.text && + outcome.reply.step === undefined && + outcome.reply.wizardInputPending !== true && + outcome.reply.wizardSettling !== true + ) { + this.history.push({ role: "assistant", text: outcome.reply.text }); + this.retainedPollReplies.delete(stepId); + } + return outcome.reply; + } + return { + text: "Setup is still finishing this QR operation.", + action: "none", + wizardSettling: true, + }; + } + async answerWizard(answer: WizardAnswer): Promise { this.assertActive(); const turn = this.turnQueue.then(async () => { diff --git a/src/system-agent/chat-wizard-host.test.ts b/src/system-agent/chat-wizard-host.test.ts index 556610c5f6c8..e853b75f487b 100644 --- a/src/system-agent/chat-wizard-host.test.ts +++ b/src/system-agent/chat-wizard-host.test.ts @@ -80,6 +80,44 @@ describe("SystemAgentChatEngine wizard", () => { expect(done.step).toBeUndefined(); }); + it("polls owner completion without answering the QR step", async () => { + let settleOwner!: () => void; + const owner = new Promise((resolve) => { + settleOwner = resolve; + }); + let releaseRunner!: () => void; + const runnerGate = new Promise((resolve) => { + releaseRunner = resolve; + }); + let runnerReachedGate = false; + const engine = createQrEngine(async (_channel, prompter) => { + await prompter.qrCode?.({ + title: "Link a device", + message: "Scan this QR code and approve the device.", + text: QR_TEXT, + dismissed: owner, + }); + runnerReachedGate = true; + await runnerGate; + }); + + const prompt = await engine.handle("connect telegram"); + const stepId = expectDefined(prompt.step, "QR step").id; + settleOwner(); + await vi.waitFor(() => expect(runnerReachedGate).toBe(true)); + + const settling = await engine.pollStep(stepId); + expect(settling).toMatchObject({ wizardSettling: true }); + expect(settling).not.toHaveProperty("step"); + + releaseRunner(); + await vi.waitFor(() => expect(engine.hasPendingQrCode()).toBe(false)); + const completed = await engine.pollStep(stepId); + expect(completed.text).toContain("telegram is configured"); + expect(completed).not.toHaveProperty("wizardSettling"); + expect(completed).not.toHaveProperty("step"); + }); + it.each([ { name: "chat command", diff --git a/src/system-agent/chat-wizard-host.ts b/src/system-agent/chat-wizard-host.ts index 6939db005561..9daaefa1c1e2 100644 --- a/src/system-agent/chat-wizard-host.ts +++ b/src/system-agent/chat-wizard-host.ts @@ -86,6 +86,7 @@ type ActiveWizardBridge = { expiryKind: "presentation" | "cancellation" | undefined; qrExpiresAtMs: number | undefined; qrStepId: string | undefined; + passiveQrStepId: string | undefined; qrExpired: boolean; kind: "channel" | "skills" | "search" | "gateway" | "memory-import"; label: string; @@ -233,6 +234,22 @@ export class ChatWizardHost { return { ...(await this.pump()), userHistoryText: "Cancel" }; } + /** Observe a QR-owned step without answering the dependency-owned prompt. */ + async pollStep(stepId: string): Promise { + this.expireActiveQrIfNeeded(); + const bridge = this.bridge; + if (!bridge) { + throw new SystemAgentWizardAnswerError("The hosted wizard step is no longer active."); + } + if (bridge.step?.id === stepId) { + return { text: renderWizardStep(bridge.step), configWritten: false }; + } + if (bridge.passiveQrStepId !== stepId) { + throw new SystemAgentWizardAnswerError("The hosted wizard poll targets a stale step."); + } + return this.renderPendingQrOwner(bridge) ?? (await this.pump()); + } + async resolveReply(text: string): Promise { const bridge = this.bridge; if (!bridge) { @@ -416,6 +433,7 @@ export class ChatWizardHost { expiryKind: undefined, qrExpiresAtMs: undefined, qrStepId: undefined, + passiveQrStepId: undefined, qrExpired: false, kind: params.kind, label: params.label, @@ -467,6 +485,7 @@ export class ChatWizardHost { ? "presentation" : "cancellation"; bridge.qrStepId = bridge.step?.id; + bridge.passiveQrStepId = bridge.step?.id; const expiresAtMs = bridge.qrExpiresAtMs; bridge.expiryTimer = setTimeout( () => this.expireQr(bridge, expiresAtMs),