mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): fence QR reconnect ownership
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user