fix(ui): stop stale clients from acting connected (#124772)

* fix(ui): stop stale clients from acting connected

Carry reload metadata on terminal Control UI build rejections, render an explicit refresh-required recovery state, fence reconnect-only actions, and surface disconnected approval failures with accessible modal controls.

* fix(ui): preserve reconnecting session drafts

* fix(ui): preserve offline preference intent

* fix(ui): return owned fallback digest bytes

* test(ui): follow passive approval presentation

* fix(ui): distinguish stale builds from protocol mismatches

* style(ui): format gateway recovery phase
This commit is contained in:
Peter Steinberger
2026-08-16 13:38:09 -07:00
committed by GitHub
parent 8277cb24a1
commit 486b272e8c
21 changed files with 284 additions and 37 deletions
@@ -45,10 +45,13 @@ describe("readConnectErrorDetailCode", () => {
});
describe("readControlUiBuildMismatchId", () => {
it("returns a bounded reload target", () => {
it.each([
ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
])("returns a bounded reload target for %s", (code) => {
expect(
readControlUiBuildMismatchId({
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
code,
gatewayBuildId: "gateway-build",
reloadRequired: true,
}),
@@ -232,7 +232,11 @@ export function readConnectErrorDetailCode(details: unknown): string | null {
/** Read the exact target artifact from an untrusted reload-required rejection. */
export function readControlUiBuildMismatchId(details: unknown): string | null {
if (readConnectErrorDetailCode(details) !== ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH) {
const code = readConnectErrorDetailCode(details);
if (
code !== ConnectErrorDetailCodes.PROTOCOL_MISMATCH &&
code !== ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH
) {
return null;
}
const raw = details as { gatewayBuildId?: unknown; reloadRequired?: unknown };
@@ -332,7 +332,11 @@ export async function attachAuthenticatedGatewayConnect(
});
sendHandshakeErrorResponse(ErrorCodes.UNAVAILABLE, message, {
retryable: false,
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
details: {
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
gatewayBuildId: controlUiBuildMismatch.gatewayBuildId,
reloadRequired: true,
},
});
logWsControl.warn(
`control ui build rejected conn=${connId} clientBuild=${formatForLog(controlUiBuildMismatch.clientBuildId ?? "legacy")} gatewayBuild=${formatForLog(controlUiBuildMismatch.gatewayBuildId)}; reload required`,
@@ -5,7 +5,10 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import {
ConnectErrorDetailCodes,
readControlUiBuildMismatchId,
} from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { GatewayRequestContext } from "../../server-methods/types.js";
@@ -290,9 +293,18 @@ describe("Control UI build admission over WebSocket", () => {
code: ErrorCodes.UNAVAILABLE,
message: "protocol mismatch: Control UI updated; reload this page to continue",
retryable: false,
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
details: {
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
gatewayBuildId: "gateway-build",
reloadRequired: true,
},
},
});
expect(
readControlUiBuildMismatchId(
(rejection.error as { details?: unknown } | undefined)?.details,
),
).toBe("gateway-build");
ws.send(
JSON.stringify({
type: "req",
+3 -2
View File
@@ -287,8 +287,9 @@ export class OpenClawApp extends OpenClawLightDomElement {
</openclaw-tooltip-provider>
`;
}
const showLoginGate =
!gatewayConnected && (this.loginGatePinned || gatewaySnapshot.phase !== "reconnecting");
const shellOwnsRecovery =
gatewaySnapshot.phase === "reconnecting" || gatewaySnapshot.phase === "reload-required";
const showLoginGate = !gatewayConnected && !shellOwnsRecovery;
if (showLoginGate) {
return html`
<openclaw-tooltip-provider>
+16
View File
@@ -274,6 +274,15 @@ export function renderApplicationShell(host: ShellViewHost) {
const custodianPanelAvailable =
gatewayConnected && isGatewayMethodAdvertised(gatewaySnapshot, "openclaw.chat") === true;
const activeRoute = host.routeState.routeId ?? "chat";
// Chat has an offline outbox, New Session keeps a local draft, and Appearance
// persists local preference intent for replay. Their server actions are
// independently gated; other pages cannot submit useful disconnected work.
const pageActionsBlocked =
gatewaySnapshot.phase === "reload-required" ||
(!gatewayConnected &&
activeRoute !== "chat" &&
activeRoute !== "new-session" &&
activeRoute !== "appearance");
// Plugin tabs share one route; the search picks the active item.
const activePluginRef =
activeRoute === "plugin"
@@ -595,7 +604,14 @@ export function renderApplicationShell(host: ShellViewHost) {
onRefresh: () => host.refreshControlUi(),
onHoldUpdate: () => context.overlays.holdUpdate(),
})}
${pageActionsBlocked && gatewaySnapshot.phase !== "reload-required"
? html`<div class="connection-action-block" role="status" aria-live="polite">
${t("connection.actionsUnavailable")}
</div>`
: nothing}
<openclaw-router-outlet
?inert=${pageActionsBlocked}
aria-disabled=${pageActionsBlocked ? "true" : nothing}
.router=${runtime.router}
.retryContext=${context}
.onNotFound=${() => host.replaceChatWithCurrentSession()}
+45
View File
@@ -547,6 +547,51 @@ describe("createApplicationGateway connection phase", () => {
expect(gateway.snapshot.phase).toBe("offline");
});
it("schedules the guarded reload and publishes a terminal phase for a stale build", () => {
const { gateway, current } = createStore();
gateway.start();
current().opts.onClose?.({
code: 1008,
reason: "protocol mismatch: Control UI updated; reload this page to continue",
error: {
code: "UNAVAILABLE",
message: "protocol mismatch: Control UI updated; reload this page to continue",
details: {
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
gatewayBuildId: "replacement-build",
reloadRequired: true,
},
},
willRetry: false,
});
expect(scheduleStaleChunkReloadMock).toHaveBeenCalledExactlyOnceWith({
buildId: "replacement-build",
});
expect(gateway.snapshot.phase).toBe("reload-required");
});
it("keeps a bare protocol mismatch in the login-gate path", () => {
const { gateway, current } = createStore();
gateway.start();
current().opts.onClose?.({
code: 1008,
reason: "protocol mismatch",
error: {
code: "INVALID_REQUEST",
message: "protocol mismatch",
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
},
willRetry: false,
});
expect(scheduleStaleChunkReloadMock).not.toHaveBeenCalled();
expect(gateway.snapshot.phase).toBe("stopped");
expect(gateway.snapshot.lastErrorCode).toBe(ConnectErrorDetailCodes.PROTOCOL_MISMATCH);
});
it("keeps reconnecting across event-gap recovery with a fresh client", () => {
const { gateway, clients, current } = createStore();
gateway.start();
+12 -8
View File
@@ -437,23 +437,27 @@ export function createApplicationGateway(
if (mismatchedBuildId) {
void scheduleStaleChunkReload({ buildId: mismatchedBuildId });
}
const lastErrorCode = resolveGatewayErrorDetailCode(error) ?? error?.code ?? null;
setSnapshot({
...snapshot,
client: nextClient,
phase: everConnected
? willRetry
? "reconnecting"
: "offline"
: willRetry
? "connecting"
: "stopped",
phase:
mismatchedBuildId !== null
? "reload-required"
: everConnected
? willRetry
? "reconnecting"
: "offline"
: willRetry
? "connecting"
: "stopped",
hello: null,
canvasPluginSurfaceUrl: null,
selfUser: null,
lastError: error?.message
? formatUiError(error.message)
: `disconnected (${code}): ${formatUiExternalText(reason, t("common.unknown"))}`,
lastErrorCode: resolveGatewayErrorDetailCode(error) ?? error?.code ?? null,
lastErrorCode,
});
},
onGap: ({ expected, received }) => {
+1
View File
@@ -8,6 +8,7 @@ export type ApplicationGatewayPhase =
| "connecting"
| "connected"
| "reconnecting"
| "reload-required"
| "offline";
export type ApplicationGatewaySnapshot = {
@@ -121,6 +121,9 @@ export function createGatewayHarness(
},
gateway,
connect,
replaceSnapshotWithoutPublishing(next: Partial<ApplicationGatewaySnapshot>) {
snapshot = { ...snapshot, ...next };
},
update(next: Partial<ApplicationGatewaySnapshot>) {
snapshot = { ...snapshot, ...next };
for (const listener of snapshotListeners) {
+35
View File
@@ -60,6 +60,21 @@ afterEach(() => {
});
describe("Control UI refresh nudge", () => {
it("flags a terminal build rejection without requiring a hello", () => {
const gatewayClient = client(async () => []);
const harness = createGatewayHarness(null, false);
const overlays = createApplicationOverlays(harness.gateway);
harness.update({
client: gatewayClient,
phase: "reload-required",
hello: null,
});
expect(overlays.snapshot.controlUiRefreshRequired).toBe(true);
overlays.dispose();
});
it("does not flag an independently built configured UI root", () => {
const gatewayClient = client(async () => []);
const harness = createGatewayHarness(null, false);
@@ -510,6 +525,26 @@ describe("application approval overlays", () => {
overlays.dispose();
});
it("surfaces a connection error when a rendered approval races a disconnect", async () => {
const request = vi.fn<RequestFn>((method) =>
Promise.resolve(method.endsWith(".list") ? [] : { ok: true }),
);
const harness = createGatewayHarness(client(request));
const overlays = createApplicationOverlays(harness.gateway);
harness.emitApproval("approval-disconnected", 1_000);
// The rendered modal can dispatch its click before Lit consumes the
// Gateway snapshot notification that removes the stale card.
harness.replaceSnapshotWithoutPublishing({ phase: "reconnecting" });
await overlays.decideApproval("allow-once", "approval-disconnected");
expect(overlays.snapshot.approvalErrors.get("approval-disconnected")).toBe(
"Connect to the Gateway to change sessions.",
);
expect(request).not.toHaveBeenCalledWith("exec.approval.resolve", expect.anything());
overlays.dispose();
});
it("keeps A's failure visible after deciding B successfully", async () => {
const firstResolve = deferred();
const secondResolve = deferred();
+12 -9
View File
@@ -291,7 +291,9 @@ export function createApplicationOverlays(
updateRunning: false,
};
updateCampaignPoller.stop();
if (!next.client) {
if (next.phase === "reload-required") {
snapshot = { ...snapshot, controlUiRefreshRequired: true };
} else if (!next.client) {
connectedEpoch = 0;
snapshot = { ...snapshot, controlUiRefreshRequired: false };
} else if (next.hello) {
@@ -561,14 +563,15 @@ export function createApplicationOverlays(
? promptState.execApprovalQueue.find((entry) => entry.id === approvalId)
: promptState.execApprovalQueue[0];
const client = gateway.snapshot.client;
if (
!active ||
!client ||
promptState.execApprovalBusy ||
disposed ||
gateway.snapshot.phase !== "connected" ||
!readGatewayOperatorAccess(gateway.snapshot).canGrantApprovals
) {
if (!active || promptState.execApprovalBusy || disposed) {
return;
}
if (!client || gateway.snapshot.phase !== "connected") {
promptState.execApprovalErrors.set(active.id, t("sessionsView.actionRequiresConnection"));
publish();
return;
}
if (!readGatewayOperatorAccess(gateway.snapshot).canGrantApprovals) {
return;
}
promptState.execApprovalBusy = true;
+1
View File
@@ -209,6 +209,7 @@ export function renderExecApprovalCard(props: ExecApprovalCardProps) {
return html`<button
class=${decisionClass(decision)}
type="button"
aria-label=${label}
?disabled=${props.busy}
title=${props.variant === "modal" ? `${label} (${decisionShortcut(decision)})` : label}
@click=${() => props.onDecision(active.id, decision)}
+15
View File
@@ -114,6 +114,21 @@ describe("openclaw-exec-approval", () => {
);
});
it("exposes labelled, focusable decision buttons", async () => {
await renderOpenedApproval(createExecRequest());
await getRenderedModalDialog(container);
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>(".exec-approval-actions button"),
);
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
"Allow once",
"Always allow",
"Deny",
]);
expect(buttons.every((button) => button.tabIndex === 0)).toBe(true);
});
it("does not show exec unavailable copy for restricted plugin approvals", async () => {
await renderOpenedApproval(
createExecRequest({
+2
View File
@@ -52,6 +52,8 @@ describe("openclaw-modal-dialog", () => {
expect(dialog.open).toBe(true);
expect(dialog.localName).toBe("dialog");
expect(dialog.getAttribute("role")).toBe("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-label")).toBe("Confirm action");
expect(dialog.getAttribute("aria-description")).toBe("Review the operation before continuing.");
expect(dialog.getRootNode()).toBe(webAwesomeDialog.shadowRoot);
+2
View File
@@ -204,6 +204,8 @@ export class OpenClawModalDialog extends OpenClawLitElement {
if (!dialog) {
return;
}
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
if (this.label) {
dialog.setAttribute("aria-label", this.label);
} else {
+7
View File
@@ -59,6 +59,12 @@ suite.define(() => {
approval("approval-active", "echo active", 1_000),
);
await currentPage.getByText("echo active", { exact: true }).waitFor();
await currentPage.getByRole("button", { name: "Allow once" }).focus();
expect(
await currentPage
.getByRole("button", { name: "Allow once" })
.evaluate((button) => button === document.activeElement),
).toBe(true);
await currentPage.getByRole("button", { name: "Allow once" }).click();
await gateway.emitGatewayEvent(
@@ -80,6 +86,7 @@ suite.define(() => {
.toBe("Approval failed: gateway unavailable");
await approvalAttentionChip(currentPage).click();
await currentPage.getByRole("dialog", { name: "Exec approval needed" }).waitFor();
const approvalModal = await waitForConfirmModal(currentPage);
await approvalModal.getByText("echo newer", { exact: true }).click();
await expect
+90 -11
View File
@@ -1,4 +1,6 @@
// Control UI tests cover the responsive disconnected login gate.
import { mkdir } from "node:fs/promises";
import path from "node:path";
import type { BrowserContext, Page } from "playwright";
import { expect, it } from "vitest";
import { ConnectErrorDetailCodes } from "../../../packages/gateway-protocol/src/connect-error-details.js";
@@ -11,6 +13,7 @@ const suite = createControlUiE2eSuite({
unavailableMessage: (executablePath) =>
`Playwright Chromium is not installed or cannot start at ${executablePath}. Run \`pnpm --dir ui exec playwright install --with-deps chromium\`, or set OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM=1 only when intentionally skipping this lane.`,
});
const RECOVERY_ARTIFACT_DIR = path.resolve(".artifacts/control-ui-e2e/zombie-reload");
async function renderLoginGate(page: Page): Promise<void> {
const response = await page.goto(suite.server.baseUrl);
@@ -86,7 +89,7 @@ suite.define(() => {
code: "UNAVAILABLE",
message: "Control UI updated; reload this page to continue",
details: {
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
gatewayBuildId: "replacement-build",
reloadRequired: true,
},
@@ -128,7 +131,7 @@ suite.define(() => {
code: "UNAVAILABLE",
message: "Control UI updated; reload this page to continue",
details: {
code: ConnectErrorDetailCodes.CONTROL_UI_BUILD_MISMATCH,
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
gatewayBuildId: "replacement-build",
reloadRequired: true,
},
@@ -148,12 +151,14 @@ suite.define(() => {
await gateway.waitForRequest("connect");
await gateway.rejectDeferred("connect", mismatch);
const failure = page.locator('.login-gate__failure[data-kind="build-mismatch"]');
await failure.waitFor({ timeout: 10_000 });
expect(await failure.locator(".login-gate__failure-title").textContent()).toBe(
"Server updated",
);
expect(await failure.locator(".login-gate__failure-refresh").isVisible()).toBe(true);
await page.getByRole("button", { name: /Server updated/u }).waitFor({ timeout: 10_000 });
expect(await page.locator("openclaw-login-gate").count()).toBe(0);
expect(await page.locator("openclaw-router-outlet").getAttribute("inert")).not.toBeNull();
await mkdir(RECOVERY_ARTIFACT_DIR, { recursive: true });
await page.screenshot({
path: path.join(RECOVERY_ARTIFACT_DIR, "01-reload-required.png"),
fullPage: true,
});
expect(await gateway.getRequests("terminal.open")).toHaveLength(0);
expect(
await page.evaluate(() =>
@@ -165,7 +170,7 @@ suite.define(() => {
}
});
it("shows a protocol mismatch without reconnecting", async () => {
it("shows a bare protocol mismatch as compatibility guidance without reconnecting", async () => {
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
await page.clock.install();
@@ -180,12 +185,12 @@ suite.define(() => {
details: { code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH },
});
const failure = page.locator(".login-gate__failure-summary");
const failure = page.locator('.login-gate__failure[data-kind="protocol-mismatch"]');
await failure.waitFor({ timeout: 10_000 });
expect((await failure.textContent())?.toLowerCase()).toContain(
"supported connection protocol",
);
expect(await page.locator(".login-gate__failure-refresh").isVisible()).toBe(true);
expect(await failure.locator(".login-gate__failure-refresh").isVisible()).toBe(true);
await page.clock.runFor(1_600);
expect(await gateway.getRequests("connect")).toHaveLength(1);
} finally {
@@ -193,6 +198,80 @@ suite.define(() => {
}
});
it("lets reload-required recovery outrank a manually pinned login gate", async () => {
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
await page.addInitScript(() => {
sessionStorage.setItem("openclaw.controlUi.staleChunkReloadBuildId", "replacement-build");
});
const gateway = await installMockGateway(page, { deferredMethods: ["connect"] });
try {
await page.goto(suite.server.baseUrl);
await gateway.waitForRequest("connect");
await gateway.rejectDeferred("connect", {
code: "INVALID_REQUEST",
message: "token missing",
details: { code: ConnectErrorDetailCodes.AUTH_TOKEN_MISSING },
});
await page.locator('.login-gate__failure[data-kind="auth-required"]').waitFor();
await gateway.deferNext("connect");
await page.getByRole("button", { name: "Connect" }).click();
await expect.poll(async () => (await gateway.getRequests("connect")).length).toBe(2);
await gateway.rejectDeferred("connect", {
code: "UNAVAILABLE",
message: "protocol mismatch: Control UI updated; reload this page to continue",
details: {
code: ConnectErrorDetailCodes.PROTOCOL_MISMATCH,
gatewayBuildId: "replacement-build",
reloadRequired: true,
},
retryable: false,
});
await expect
.poll(() =>
page.evaluate(() => {
const app = document.querySelector("openclaw-app") as HTMLElement & {
runtime?: { context: { gateway: { snapshot: { phase: string } } } };
};
return app.runtime?.context.gateway.snapshot.phase;
}),
)
.toBe("reload-required");
await page.getByRole("button", { name: /Server updated/u }).waitFor();
expect(await page.locator("openclaw-login-gate").count()).toBe(0);
} finally {
await closeContext(context);
}
});
it("blocks non-chat page actions visibly while reconnecting", async () => {
const context = await suite.browser.newContext({ viewport: { height: 900, width: 1280 } });
const page = await context.newPage();
const gateway = await installMockGateway(page);
try {
await page.goto(new URL("settings/connection", suite.server.baseUrl).href);
await page.locator("openclaw-app-shell").waitFor();
await gateway.deferNext("connect");
await gateway.closeLatest(1012, "test reconnect");
await page.getByText("Actions are unavailable while the Gateway reconnects.").waitFor();
const outlet = page.locator("openclaw-router-outlet");
expect(await outlet.getAttribute("inert")).not.toBeNull();
expect(await outlet.getAttribute("aria-disabled")).toBe("true");
await mkdir(RECOVERY_ARTIFACT_DIR, { recursive: true });
await page.screenshot({
path: path.join(RECOVERY_ARTIFACT_DIR, "02-reconnecting-actions-blocked.png"),
fullPage: true,
});
} finally {
await closeContext(context);
}
});
it.each([
{
name: "missing token",
+1
View File
@@ -3750,6 +3750,7 @@ export const en: TranslationMap = {
queuedCount: "{count} queued",
reconnecting: "Reconnecting…",
retryNow: "Retry now",
actionsUnavailable: "Actions are unavailable while the Gateway reconnects.",
scopeUpgrade: {
limited: "This browser has limited access.",
guidance:
+1 -1
View File
@@ -25,7 +25,7 @@ hashes.sha512Async = async (message: Uint8Array) => {
if (globalThis.crypto?.subtle && subtleSha512Async) {
return await subtleSha512Async(message);
}
return (await loadPureSha2()).sha512(message);
return Uint8Array.from((await loadPureSha2()).sha512(message));
};
type GatewayRequestClient = {
+9
View File
@@ -3867,6 +3867,15 @@ wa-dropdown.sidebar-identity-menu::part(menu) {
margin-top: 8px;
}
.connection-action-block {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--secondary);
color: var(--muted);
font-size: 13px;
}
.content--chat {
display: flex;
flex-direction: column;