Skip to content

Commit 5c7d4f8

Browse files
committed
fix(dashboard): close widget authority lifetime gaps
1 parent e341ea5 commit 5c7d4f8

11 files changed

Lines changed: 178 additions & 12 deletions

File tree

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,46 @@ public struct BoardWidgetAppViewResult: Codable, Sendable {
768768
}
769769
}
770770

771+
public struct BoardEventParams: Codable, Sendable {
772+
public let sessionkey: String
773+
public let widget: String
774+
public let payload: AnyCodable
775+
776+
public init(
777+
sessionkey: String,
778+
widget: String,
779+
payload: AnyCodable)
780+
{
781+
self.sessionkey = sessionkey
782+
self.widget = widget
783+
self.payload = payload
784+
}
785+
786+
private enum CodingKeys: String, CodingKey {
787+
case sessionkey = "sessionKey"
788+
case widget
789+
case payload
790+
}
791+
}
792+
793+
public struct BoardTicketEventParams: Codable, Sendable {
794+
public let ticket: String
795+
public let payload: AnyCodable
796+
797+
public init(
798+
ticket: String,
799+
payload: AnyCodable)
800+
{
801+
self.ticket = ticket
802+
self.payload = payload
803+
}
804+
805+
private enum CodingKeys: String, CodingKey {
806+
case ticket
807+
case payload
808+
}
809+
}
810+
771811
public struct BoardPromptAuthorizeParams: Codable, Sendable {
772812
public let ticket: String
773813

scripts/protocol-gen-swift.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,27 @@ function emitStruct(name: string, schema: JsonSchema): string {
408408
return lines.join("\n");
409409
}
410410

411+
function emitBoardEventParamsModels(schema: JsonSchema): string {
412+
const variants = schema.anyOf ?? schema.oneOf ?? [];
413+
const legacy = variants.find(
414+
(variant) =>
415+
variant.type === "object" &&
416+
variant.properties?.sessionKey &&
417+
variant.properties.widget &&
418+
variant.properties.payload,
419+
);
420+
const ticket = variants.find(
421+
(variant) =>
422+
variant.type === "object" && variant.properties?.ticket && variant.properties.payload,
423+
);
424+
if (!legacy || !ticket) {
425+
throw new Error("BoardEventParams must retain legacy and ticket object variants");
426+
}
427+
// BoardEventParams shipped as the legacy struct before the schema became a
428+
// union. Preserve that source API and expose the ticket form separately.
429+
return `${emitStruct("BoardEventParams", legacy)}\n${emitStruct("BoardTicketEventParams", ticket)}`;
430+
}
431+
411432
function emitStructCustomCodable(
412433
name: string,
413434
props: Record<string, JsonSchema>,
@@ -779,13 +800,17 @@ async function generate() {
779800
if (name === "GatewayFrame") {
780801
continue;
781802
}
803+
if (name === "BoardEventParams") {
804+
parts.push(emitBoardEventParamsModels(schema));
805+
continue;
806+
}
782807
if (schema.type === "object") {
783808
parts.push(emitStruct(name, schema));
784809
}
785810
}
786811

787812
for (const [name, schema] of definitions) {
788-
if (name === "GatewayFrame") {
813+
if (name === "GatewayFrame" || name === "BoardEventParams") {
789814
continue;
790815
}
791816
const union = emitDiscriminatedUnion(name, schema);

src/agents/sandbox-host.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ const SANDBOX_HOST_CSP_MAX_JSON_BYTES = 5 * 1024;
1212
const SANDBOX_HOST_CSP_MAX_HEADER_BYTES = 6 * 1024;
1313
const SANDBOX_HOST_CSP_MAX_ENCODED_BYTES = Math.ceil(SANDBOX_HOST_CSP_MAX_JSON_BYTES / 3) * 4 + 4;
1414

15-
// WebRTC traffic is not governed by CSP connect-src. This bootstrap runs as
16-
// the first script in every inner document so untrusted content cannot create
17-
// peer/data connections outside its declared network capability.
15+
// Older browsers do not implement CSP's `webrtc 'block'`. This bootstrap is a
16+
// same-realm fallback; supporting browsers enforce the header across inherited
17+
// about:blank/srcdoc realms as well.
1818
const SANDBOX_DOCUMENT_GUARD_HTML = `<script>(()=>{
1919
const fail=()=>{window.stop();document.open();document.write("<!doctype html><title>Sandbox unavailable</title>");document.close();throw new Error("sandbox WebRTC isolation failed");};
2020
const names=["RTCPeerConnection","webkitRTCPeerConnection","RTCIceGatherer","RTCIceTransport","RTCDtlsTransport","RTCSctpTransport","RTCDataChannel"];
@@ -294,6 +294,7 @@ export function buildSandboxHostContentSecurityPolicy(csp?: SandboxHostCsp): str
294294
`img-src 'self' data: ${resources.join(" ")}`.trim(),
295295
`media-src 'self' data: ${resources.join(" ")}`.trim(),
296296
`connect-src ${sources(connections)}`,
297+
"webrtc 'block'",
297298
// This policy belongs to the trusted outer document, so frame-src also
298299
// governs replacement navigations of its untrusted inner browsing context.
299300
`frame-src ${sources(frames)}`,

src/gateway/board-http.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ describe("board widget HTTP", () => {
150150
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
151151
expect(response.headers.get("content-security-policy")).toContain("default-src 'none'");
152152
expect(response.headers.get("content-security-policy")).toContain("connect-src 'none'");
153+
expect(response.headers.get("content-security-policy")).toContain("webrtc 'block'");
153154
expect(response.headers.get("content-security-policy")).toContain("sandbox allow-scripts");
154155
expect(response.headers.get("access-control-allow-origin")).toBe("*");
155156
expect(response.headers.get("cache-control")).toBe("no-cache");

src/gateway/board-sandbox.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,11 @@ describe("board widget sandbox CSP", () => {
2929

3030
expect(path).toBe("/mcp-app-sandbox");
3131
expect(buildSandboxHostContentSecurityPolicy()).toContain("connect-src 'none'");
32+
expect(buildSandboxHostContentSecurityPolicy()).toContain("webrtc 'block'");
3233
expect(buildBoardWidgetContentSecurityPolicy(document("pending"))).toContain(
3334
"connect-src 'none'",
3435
);
36+
expect(buildBoardWidgetContentSecurityPolicy(document("pending"))).toContain("webrtc 'block'");
3537
});
3638

3739
it("emits only the granted widget origins", () => {

src/gateway/board-sandbox.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export function buildBoardWidgetContentSecurityPolicy(document: BoardWidgetDocum
2323
"style-src 'unsafe-inline'",
2424
"img-src data:",
2525
`connect-src ${connectSources}`,
26+
"webrtc 'block'",
2627
"base-uri 'none'",
2728
"object-src 'none'",
2829
"form-action 'none'",

src/gateway/mcp-app-sandbox-http.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ describe("MCP App sandbox HTTP origin", () => {
2626
(call) => call[0] === "Content-Security-Policy",
2727
)?.[1];
2828
expect(String(csp)).toContain("connect-src https://api.example.com");
29+
expect(String(csp)).toContain("webrtc 'block'");
2930
expect(String(csp)).toContain("script-src 'self' 'unsafe-inline' https://cdn.example.com");
3031
expect(String(csp)).toContain("font-src 'self' https://cdn.example.com");
3132
expect(String(csp)).toContain("frame-ancestors");

ui/src/components/board/board-view.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,11 @@ describe("openclaw-board-view", () => {
269269
await vi.advanceTimersByTimeAsync(1_000);
270270
expect(frameLoadFailed).toHaveBeenCalledTimes(2);
271271
expect((cell as unknown as { frameError: string }).frameError).toBe("");
272+
273+
await vi.advanceTimersByTimeAsync(1_999);
274+
expect(frameLoadFailed).toHaveBeenCalledTimes(2);
275+
await vi.advanceTimersByTimeAsync(1);
276+
expect(frameLoadFailed).toHaveBeenCalledTimes(3);
272277
});
273278

274279
it("schedules proactive refresh from the relative ticket TTL", async () => {

ui/src/components/board/board-widget-ticket-refresh.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,12 @@ export class BoardWidgetTicketRefresh {
5050
return;
5151
}
5252
this.attempts += 1;
53-
void refresh(name).catch(() => {
53+
const retryIfUnchanged = () => {
5454
if (this.currentTicket() !== ticket || this.scheduledTicket !== ticket) {
5555
return;
5656
}
57-
// Ticket refresh is proactive. Keep the loaded widget usable and retry
58-
// transient gateway failures without turning them into a frame failure.
57+
// A fulfilled refresh may still be discarded by a superseding provider
58+
// mutation. Retry until this exact expiring ticket is actually replaced.
5959
this.clear();
6060
this.timer = window.setTimeout(
6161
() => {
@@ -64,6 +64,7 @@ export class BoardWidgetTicketRefresh {
6464
},
6565
Math.min(REFRESH_RETRY_MS * this.attempts, REFRESH_MAX_RETRY_MS),
6666
);
67-
});
67+
};
68+
void refresh(name).then(retryIfUnchanged, retryIfUnchanged);
6869
}
6970
}

ui/src/lib/board/widget-sandbox-host.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -525,6 +525,90 @@ describe("BoardWidgetSandboxHost", () => {
525525
});
526526
});
527527

528+
it("drops in-flight responses from a replaced Gateway client", async () => {
529+
const frame = document.createElement("iframe");
530+
document.body.append(frame);
531+
vi.stubGlobal(
532+
"fetch",
533+
vi.fn(async () => new Response("<!doctype html><p>weather</p>")),
534+
);
535+
let resolveOldRequest: (value: unknown) => void = () => {};
536+
const oldClient = {
537+
request: vi.fn(
538+
async () =>
539+
await new Promise<unknown>((resolve) => {
540+
resolveOldRequest = resolve;
541+
}),
542+
),
543+
};
544+
const newClient = { request: vi.fn(async () => ({ ok: true })) };
545+
const baseOptions = {
546+
frame,
547+
widget: widget(),
548+
sandboxOrigin: "https://sandbox.example",
549+
sandboxUrl: SANDBOX_URL,
550+
sourceOrigin: "https://gateway.example",
551+
client: oldClient,
552+
resolveFrameUrl: () => "/widget?bt=ticket",
553+
confirmPrompt: () => true,
554+
onFrameUrl: vi.fn(),
555+
onUnauthorized: vi.fn(),
556+
onReadyTimeout: vi.fn(),
557+
onLoaded: vi.fn(),
558+
onError: vi.fn(),
559+
};
560+
const host = new BoardWidgetSandboxHost(baseOptions);
561+
host.handleMessage(
562+
new MessageEvent("message", {
563+
source: frame.contentWindow,
564+
origin: "https://sandbox.example",
565+
data: {
566+
method: "ui/notifications/sandbox-proxy-ready",
567+
params: { sandboxUrl: SANDBOX_URL },
568+
},
569+
}),
570+
);
571+
await vi.waitFor(() => expect(baseOptions.onLoaded).toHaveBeenCalledOnce());
572+
const bridgePort = await offerBridgePort(host, frame);
573+
const responses: unknown[] = [];
574+
bridgePort.addEventListener("message", (event) => {
575+
if (event.data?.type === "openclaw:widget-bridge-response") {
576+
responses.push(event.data);
577+
}
578+
});
579+
580+
bridgePort.postMessage(
581+
{
582+
type: "openclaw:widget-bridge-request",
583+
id: "old-client",
584+
method: "data.read",
585+
params: { bindingId: "health" },
586+
ticket: "ticket",
587+
},
588+
[],
589+
);
590+
await vi.waitFor(() => expect(oldClient.request).toHaveBeenCalledOnce());
591+
host.update({ ...baseOptions, client: newClient });
592+
resolveOldRequest({ private: "old-context" });
593+
await Promise.resolve();
594+
await Promise.resolve();
595+
expect(responses).not.toContainEqual(expect.objectContaining({ id: "old-client" }));
596+
597+
await expect(
598+
sendBridgeRequest(bridgePort, {
599+
type: "openclaw:widget-bridge-request",
600+
id: "new-client",
601+
method: "state.emit",
602+
params: { payload: { phase: "reconnected" } },
603+
ticket: "ticket",
604+
}),
605+
).resolves.toMatchObject({ id: "new-client", ok: true });
606+
expect(newClient.request).toHaveBeenCalledWith("board.event", {
607+
ticket: "ticket",
608+
payload: { phase: "reconnected" },
609+
});
610+
});
611+
528612
it("reloads equal-name revisions when their board session identity changes", async () => {
529613
const frame = document.createElement("iframe");
530614
document.body.append(frame);

0 commit comments

Comments
 (0)