fix(ui): fence QR reconnect ownership

This commit is contained in:
jesse-merhi
2026-08-12 13:44:26 +10:00
parent 247749b754
commit 2bb84c2787
5 changed files with 115 additions and 49 deletions
@@ -29,7 +29,11 @@ function createContext(request: ReturnType<typeof vi.fn>) {
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",
@@ -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
@@ -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 ?? [
@@ -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 {
@@ -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