diff --git a/ui/src/pages/custodian/custodian-page-agent-create.test.ts b/ui/src/pages/custodian/custodian-page-agent-create.test.ts index 3718e2987c58..b5cb1cd92c40 100644 --- a/ui/src/pages/custodian/custodian-page-agent-create.test.ts +++ b/ui/src/pages/custodian/custodian-page-agent-create.test.ts @@ -29,7 +29,11 @@ function createContext(request: ReturnType) { hello: { type: "hello-ok", protocol: 1, - auth: { role: "operator", scopes: ["operator.admin"] }, + auth: { + role: "operator", + scopes: ["operator.admin"], + recoveryScope: "custodian-test-owner", + }, features: { methods: ["openclaw.chat"] }, }, assistantAgentId: "main", diff --git a/ui/src/pages/custodian/custodian-page.qr.test.ts b/ui/src/pages/custodian/custodian-page.qr.test.ts index 217ab8093959..93a2f9fcc34c 100644 --- a/ui/src/pages/custodian/custodian-page.qr.test.ts +++ b/ui/src/pages/custodian/custodian-page.qr.test.ts @@ -218,6 +218,72 @@ describe("custodian QR wizard step", () => { expect(page.textContent).toContain("Signal is configured."); }); + it("starts fresh instead of polling when the authenticated owner changes", async () => { + vi.useFakeTimers(); + const request = vi + .fn() + .mockResolvedValueOnce(qrResult()) + .mockResolvedValueOnce(terminalResult("Fresh owner session.", "replacement-session")); + const { context, setGatewaySnapshot } = createContext(request); + const hello = context.gateway.snapshot.hello!; + const { page } = await mountPage(context); + await vi.advanceTimersByTimeAsync(0); + + setGatewaySnapshot({ + client: { request } as unknown as GatewayBrowserClient, + hello: { + ...hello, + auth: { ...hello.auth, recoveryScope: "different-owner" }, + }, + }); + await page.updateComplete; + await vi.advanceTimersByTimeAsync(0); + + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]?.[1]).not.toHaveProperty("pollStepId"); + expect(request.mock.calls[1]?.[1]?.sessionId).not.toBe(SESSION_ID); + expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false); + }); + + it("does not poll while replacement-client ownership is unresolved", async () => { + vi.useFakeTimers(); + const request = vi + .fn() + .mockResolvedValueOnce(qrResult()) + .mockResolvedValueOnce(terminalResult("Fresh unowned session.", "replacement-session")); + let recoveryScopeReady = false; + const replacement = { + request, + get recoveryScopeReady() { + return recoveryScopeReady; + }, + get recoveryScope() { + return ""; + }, + } as unknown as GatewayBrowserClient; + const { context, setGatewaySnapshot } = createContext(request); + const hello = context.gateway.snapshot.hello!; + const { page } = await mountPage(context); + await vi.advanceTimersByTimeAsync(0); + + setGatewaySnapshot({ + client: replacement, + hello: { ...hello, auth: { role: "operator", scopes: ["operator.admin"] } }, + }); + await vi.advanceTimersByTimeAsync(2_000); + + expect(request).toHaveBeenCalledOnce(); + expect(page.store.messages.some((message) => message.step?.qrDataUrl)).toBe(false); + + recoveryScopeReady = true; + setGatewaySnapshot({}); + await vi.advanceTimersByTimeAsync(0); + + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]?.[1]).not.toHaveProperty("pollStepId"); + expect(request.mock.calls[1]?.[1]?.sessionId).not.toBe(SESSION_ID); + }); + it("scrubs the QR and starts fresh after poll session invalidation", async () => { vi.useFakeTimers(); const request = vi diff --git a/ui/src/pages/custodian/custodian-page.test-harness.ts b/ui/src/pages/custodian/custodian-page.test-harness.ts index 966363e70d2f..07e173bee0ca 100644 --- a/ui/src/pages/custodian/custodian-page.test-harness.ts +++ b/ui/src/pages/custodian/custodian-page.test-harness.ts @@ -51,7 +51,11 @@ export function createContext( hello: { type: "hello-ok" as const, protocol: 1, - auth: { role: "operator", scopes: ["operator.admin"] }, + auth: { + role: "operator", + scopes: ["operator.admin"], + recoveryScope: "custodian-test-owner", + }, features: { methods, capabilities: options.gatewayCapabilities ?? [ diff --git a/ui/src/pages/custodian/custodian-session-state.ts b/ui/src/pages/custodian/custodian-session-state.ts index a2c38ce00758..4fbf8cf3fef2 100644 --- a/ui/src/pages/custodian/custodian-session-state.ts +++ b/ui/src/pages/custodian/custodian-session-state.ts @@ -43,34 +43,27 @@ export function resolveCustodianConfiguredInferenceState( return selectedAgent.model?.primary?.trim() ? "ready" : "required"; } -function resolveCustodianSessionOwnership(params: { - context: ApplicationContext | null; - lastHelloDeviceToken: string; -}): { key: string; lastHelloDeviceToken: string } { - const context = params.context; - if (!context) { - return { key: "", lastHelloDeviceToken: params.lastHelloDeviceToken }; +/** + * Resolve the Gateway-authoritative reconnect owner. `undefined` means the + * browser is still deriving a legacy scope; `null` means no durable owner exists. + */ +export function resolveCustodianSessionOwnershipKey( + context: ApplicationContext | null, +): string | null | undefined { + if (!context || context.gateway.snapshot.phase !== "connected") { + return undefined; } + const snapshot = context.gateway.snapshot; + const serverScope = snapshot.hello?.auth.recoveryScope?.trim(); + const client = snapshot.client; + if (!serverScope && !client?.recoveryScopeReady) { + return undefined; + } + const recoveryScope = serverScope || client?.recoveryScope.trim(); const { gatewayUrl, token, password, bootstrapToken } = context.gateway.connection; - const auth = context.gateway.snapshot.hello?.auth; - const lastHelloDeviceToken = auth ? (auth.deviceToken ?? "") : params.lastHelloDeviceToken; - return { - key: JSON.stringify([gatewayUrl, token, password, bootstrapToken, lastHelloDeviceToken]), - lastHelloDeviceToken, - }; -} - -export class CustodianSessionState { - private lastHelloDeviceToken = ""; - - ownershipKey(context: ApplicationContext | null): string { - const ownership = resolveCustodianSessionOwnership({ - context, - lastHelloDeviceToken: this.lastHelloDeviceToken, - }); - this.lastHelloDeviceToken = ownership.lastHelloDeviceToken; - return ownership.key; - } + return recoveryScope + ? JSON.stringify([gatewayUrl, token, password, bootstrapToken, recoveryScope]) + : null; } export function resetCustodianWizardState(state: CustodianWizardState): void { diff --git a/ui/src/pages/custodian/custodian-session-store.ts b/ui/src/pages/custodian/custodian-session-store.ts index c9ff05d85326..f8553cc2f2ca 100644 --- a/ui/src/pages/custodian/custodian-session-store.ts +++ b/ui/src/pages/custodian/custodian-session-store.ts @@ -20,8 +20,8 @@ import { hasCustodianUserInput, resetCustodianWizardState, resolveCustodianConfiguredInferenceState, + resolveCustodianSessionOwnershipKey, type CustodianConfiguredInferenceState, - CustodianSessionState, } from "./custodian-session-state.ts"; import { custodianWizardSubmission } from "./custodian-wizard-step.ts"; import * as eventNudgeState from "./event-nudge.ts"; @@ -79,7 +79,6 @@ export class CustodianSessionStore { private sessionOwnershipKey: string | null = null; private sessionStarted = false; private configuredInferenceState: CustodianConfiguredInferenceState = "unresolved"; - private readonly sessionState = new CustodianSessionState(); private eventNudgeClosed = false; private gatewayCleanup: (() => void) | null = null; private agentCleanup: (() => void) | null = null; @@ -416,7 +415,7 @@ export class CustodianSessionStore { this.sessionId = createCustodianSessionId(); this.sessionVariant = variant; this.sessionClient = client; - this.sessionOwnershipKey = this.sessionState.ownershipKey(this.context); + this.sessionOwnershipKey = resolveCustodianSessionOwnershipKey(this.context) ?? null; this.sessionStarted = true; void this.initializeSession( client, @@ -461,14 +460,16 @@ export class CustodianSessionStore { const inferenceStateChanged = configuredInferenceState !== this.configuredInferenceState; this.configuredInferenceState = configuredInferenceState; const variantChanged = this.sessionStarted && this.sessionVariant !== this.variant; - const ownershipKey = this.sessionState.ownershipKey(this.context); + const ownershipKey = resolveCustodianSessionOwnershipKey(this.context); const clientReplaced = this.sessionStarted && client !== null && this.sessionClient !== null && client !== this.sessionClient; const ownershipChanged = - this.sessionOwnershipKey !== null && ownershipKey !== this.sessionOwnershipKey; + this.sessionStarted && + ownershipKey !== undefined && + (ownershipKey !== this.sessionOwnershipKey || (clientReplaced && ownershipKey === null)); const pendingQrStepId = this.qrSession.pendingStepId( this.wizardInputPending || this.wizardSettling, ); @@ -483,31 +484,29 @@ export class CustodianSessionStore { return; } this.qrSession.clearAndScrub(); - const requestWasPending = this.sending && this.retryParams !== null; - const pendingParams = requestWasPending ? this.retryParams : null; - this.activeClient = client; + const pendingParams = this.sending && this.retryParams ? this.retryParams : null; + [this.activeClient, this.sending, this.chatAvailable] = [client, false, false]; this.requestEpoch += 1; - this.sending = false; - this.chatAvailable = false; + if (clientReplaced && !chatSupported) { + this.sessionStarted = false; + this.abandonPendingUserTurn(pendingParams); + this.error = t("custodian.unsupportedGateway"); + return; + } + if (client && ownershipKey === undefined) { + this.abandonPendingUserTurn(pendingParams); + return; + } if (variantChanged || ownershipChanged) { - // A different operator or route mode must never inherit retained live context. [this.eventNudge, this.eventNudgePending] = [null, null]; this.eventNudgeClosed = false; this.abandonedTurnOutcomeUnknown = false; this.sessionStarted = false; this.clearConversation(); } else if (client && clientReplaced) { - if (!chatSupported) { - this.sessionStarted = false; - this.abandonPendingUserTurn(pendingParams); - this.error = t("custodian.unsupportedGateway"); - return; - } this.chatAvailable = true; this.abandonPendingUserTurn(pendingParams); if (pendingQrStepId) { - // The Gateway owns reconnect authorization. Resume observation with the retained - // session id and rotate only if it returns the typed invalidation response. this.retryParams = null; this.error = null; this.sessionClient = client; @@ -516,7 +515,7 @@ export class CustodianSessionStore { } this.rotateVolatileSession(client, this.variant); return; - } else if (requestWasPending) { + } else if (pendingParams) { if (pendingParams?.message === undefined) { this.error = t("custodian.connectionChanged"); } @@ -544,7 +543,7 @@ export class CustodianSessionStore { } if (this.sessionStarted) { if (!this.retryParams) { - this.error = requestWasPending ? this.error : null; + this.error = pendingParams ? this.error : null; } const pendingStep = this.wizardInputPending || this.wizardSettling