feat(gateway): project passive QR setup sessions

This commit is contained in:
jesse-merhi
2026-08-12 01:21:31 +10:00
parent 5cac277539
commit f9ba9ea2f5
18 changed files with 570 additions and 49 deletions
@@ -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"
+27 -4
View File
@@ -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
@@ -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,
@@ -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
+20
View File
@@ -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<void> | 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 () => {
+18 -18
View File
@@ -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<GatewaySystemAgentChatReply>;
answerWizard: (answer: WizardAnswer) => Promise<GatewaySystemAgentChatReply>;
cancelWizard: (cancel: SystemAgentWizardCancel) => Promise<GatewaySystemAgentChatReply>;
pollStep: (stepId: string) => Promise<GatewaySystemAgentChatReply>;
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 };
};
@@ -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 });
});
});
@@ -7,24 +7,33 @@ import type { SystemAgentChatEngine } from "../../system-agent/chat-engine.js";
type SystemAgentChatReply = Awaited<ReturnType<SystemAgentChatEngine["handle"]>>;
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<SystemAgentChatReply | undefined> {
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 } : {}),
@@ -87,6 +87,7 @@ function discardableSessions(dispose: () => Promise<void>): Map<string, SystemAg
welcome: "welcome text",
lastUsedAt: 1,
ownerKey: "device:device-test",
supportsQrCode: false,
},
],
]) as unknown as Map<string, SystemAgentChatSession>;
@@ -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<string, infer Value> ? Value : never;
function session(ownerKey: string, dispose: () => Promise<void>): 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<void>();
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();
});
});
@@ -0,0 +1,88 @@
import type { GatewayRequestContext } from "./types.js";
type SystemAgentSessions = GatewayRequestContext["systemAgentSessions"];
type WizardSessions = GatewayRequestContext["wizardSessions"];
type ApprovalManager = NonNullable<GatewayRequestContext["systemAgentApprovalManager"]>;
const retiredStores = new WeakSet<SystemAgentSessions>();
const pendingDisposals = new WeakMap<SystemAgentSessions, Set<Promise<void>>>();
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<void>): Promise<void> {
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<string, infer Session> ? 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<void> {
const disposals: Promise<void>[] = [];
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<void> {
retiredStores.add(params.sessions);
const disposals: Promise<void>[] = [...(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);
}
@@ -38,6 +38,8 @@ vi.mock("../../system-agent/greeting.js", () => ({
type FakeEngine = {
answerWizard: ReturnType<typeof vi.fn>;
cancelWizard: ReturnType<typeof vi.fn>;
pollStep: ReturnType<typeof vi.fn>;
hasPendingQrCode: ReturnType<typeof vi.fn>;
handle: ReturnType<typeof vi.fn>;
seedHistory: ReturnType<typeof vi.fn>;
historyLength: ReturnType<typeof vi.fn>;
@@ -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<string, SystemAgentChatSession>): 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<string, SystemAgentChatSession>([
["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<string, SystemAgentChatSession>();
const context = makeContext(sessions);
@@ -218,6 +218,7 @@ function seededSession(overrides?: Partial<SystemAgentChatSession>): 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<string, SystemAgentChatSession>();
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([]);
+94 -8
View File
@@ -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<string, SystemAgentChatSession>,
context: GatewayRequestContext,
): Promise<void> {
): Promise<boolean> {
if (sessions.size < MAX_SYSTEM_AGENT_SESSIONS) {
return;
return true;
}
const protectedQrSessions = new Map<string, { key: string; lastUsedAt: number }>();
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)) {
+8
View File
@@ -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
+60
View File
@@ -74,6 +74,7 @@ export class SystemAgentChatEngine {
private disposed = false;
private disposal: Promise<void> | null = null;
private persistentApplySettlement: Promise<void> | null = null;
private retainedPollReplies = new Map<string, SystemAgentChatReply>();
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<SystemAgentChatReply> {
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<typeof setTimeout> | undefined;
const outcome = await Promise.race([
observation.then((reply) => ({ reply })),
new Promise<undefined>((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<SystemAgentChatReply> {
this.assertActive();
const turn = this.turnQueue.then(async () => {
+38
View File
@@ -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<void>((resolve) => {
settleOwner = resolve;
});
let releaseRunner!: () => void;
const runnerGate = new Promise<void>((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",
+19
View File
@@ -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<ChatWizardResult> {
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<ChatWizardResult | null> {
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),