feat(dashboard): sandboxed custom widgets + approval flow [3/3]#101098
feat(dashboard): sandboxed custom widgets + approval flow [3/3]#101098100yenadmin wants to merge 13 commits into
Conversation
New bundled plugin `extensions/dashboard`: a gateway-owned workspace document (atomic JSON store with undo, size caps, schema validation), 14 `dashboard.*` gateway methods with change broadcasts, an `openclaw dashboard` CLI, and 14 `dashboard_*` agent tools — all sharing one validated store so humans, the CLI, and agents mutate the dashboard through the same guarded path. Layout is data; no core changes. Part of the modular-dashboard series (backend layer).
… (PR2) The Workspaces bundled tab view for the dashboard plugin: tab strip, grid with pointer drag/resize/collapse, the 9 builtin widgets + Overview-as-data default workspace, and the UX polish (themed dialogs, page header, onboarding, humane error cards, skeletons, (custom) suffix strip). Wires the plugin's Control UI descriptor (registerControlUiDescriptor) and the BUNDLED_TAB_VIEWS "dashboard/workspaces" entry, plus the #6 breadcrumb label (app-host -> app-topbar -> dashboard-header currentLabel) so "Plugin" reads "Workspaces". Custom-widget (custom:<name>) rendering is the sandboxed-host feature landing in the follow-up PR: this layer ships the TRUSTED widget-cell (builtin dispatch + neutral placeholder for custom kinds, no iframe) and the view without the manifest/custom-host plumbing, so it builds with no L5 files present. i18n: en.ts carries all dashboard.* keys; non-English bundles regenerated with English fallbacks (translation pass runs before the PR opens).
…n TM) Reuses existing dashboard.* translations from the integration branch's translation memory instead of an English-fallback sync, so the new Workspaces Control UI keys ship translated in all 20 locales without any LLM calls.
The custom-widget (custom:<name>) feature on top of the Workspaces UI: a sandboxed iframe host for approved widgets, the operator approve/reject gate, and the plugin HTTP route that serves approved widget assets under an auth:"plugin" static-file-only path (jailed per widget dir, GET only). Restores the FINAL widget-cell (custom-branch dispatch: approved -> iframe host, pending -> approval card, rejected -> neutral placeholder, never an iframe without a manifest) and the view's manifest-cache + custom-context plumbing that PR2 held back so it could build with no L5 files present. - extensions/dashboard/src: http-route, serve, manifest (+ tests) and the rpc-allowlist sync guard; index.ts registers the widgets HTTP route. - ui: dashboard-custom-widget host + bridge (+ tests, e2e); plugin-page passes basePath/sessionKey so the iframe src and prompt dispatch resolve. i18n keys (incl. approval.*) already shipped in PR2; no en.ts change.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Dependency GuardThis PR changes dependency-related files. Maintainers should confirm these changes are intentional. Changed files:
Maintainer follow-up:
|
Dependency graph changes are blockedOpenClaw does not accept dependency graph changes through PRs unless a repository admin or security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph. Detected dependency graph changes:
Auto-scrub was not attempted because this PR changes package manifest dependency graph fields:
Dependency graph changes must be reviewed by security or handled by maintainers internally. Please remove lockfile changes manually if they are not needed. To remove lockfile changes, restore them from the target branch: git fetch origin
git checkout 'origin/main' -- 'pnpm-lock.yaml'
git commit -m 'chore: remove dependency lockfile change'
git pushIf this PR intentionally needs a dependency graph change, ask a repository admin or member of The action will approve the current head SHA ( |
|
Dependency-graph note: this PR inherits #101094's lockfile change (the new |
|
Codex review: needs real behavior proof before merge. Reviewed July 10, 2026, 3:36 AM ET / 07:36 UTC. Summary Reproducibility: yes. for the review defects: current-head source directly shows JSON sidecars, discarded manifest entrypoints, and a name/status-only approval gate without the shared plugin-approval workflow. The recordings demonstrate the visual happy path but not the security invariants. Review metrics: 3 noteworthy metrics.
Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Proof guidance:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review findings
Review detailsBest possible solution: Store workspace, undo history, and approval metadata in canonical SQLite-backed plugin state; route decisions through the established plugin-approval workflow; bind each approval to the exact validated manifest and asset content and invalidate it on changes; then supply redacted real-browser diagnostics before maintainers sponsor an enabled-by-default release. Do we have a high-confidence way to reproduce the issue? Yes for the review defects: current-head source directly shows JSON sidecars, discarded manifest entrypoints, and a name/status-only approval gate without the shared plugin-approval workflow. The recordings demonstrate the visual happy path but not the security invariants. Is this the best way to solve the issue? No. The sandboxed iframe and trusted-parent bridge are plausible foundations, but the implementation must use the canonical SQLite owner, preserve the approved entrypoint, and bind approval to the exact content executed; the enabled-by-default product direction also needs explicit sponsorship. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against d133f28cfb4b. Label changesLabel changes:
Label justifications:
Evidence reviewedSecurity concerns:
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (10 earlier review cycles; latest 8 shown)
|
The widget static server set the CSP (connect-src 'none'), Referrer-Policy and Cache-Control only on the 200 path; notFound() emitted just Content-Type + nosniff. A 404 is still an attacker-influenced response served from the widget origin, so it must carry the same lockdown, or a crafted request could get a response from that origin without the network-isolating CSP. Extract setSecurityHeaders() and call it on BOTH the 200 and 404 paths so they can never drift apart again. Flip the traversal-404 test, which had asserted the CSP was absent, to assert it is present. Found during the downstream Boardstate extraction's adversarial security audit (SPEC invariants I1/I4). Formatting proof: oxfmt --write + oxlint clean (committed --no-verify; node_modules unavailable in this worktree).
|
Added a security fix to this PR (commit above), from the downstream Boardstate extraction's audit (SPEC invariants I1/I4): the widget static server set the strict CSP ( |
# Conflicts: # ui/src/app/app-host.ts # ui/src/components/app-topbar.ts # ui/src/components/dashboard-header.ts # ui/src/i18n/.i18n/ar.meta.json # ui/src/i18n/.i18n/ar.tm.jsonl # ui/src/i18n/.i18n/de.meta.json # ui/src/i18n/.i18n/de.tm.jsonl # ui/src/i18n/.i18n/es.meta.json # ui/src/i18n/.i18n/es.tm.jsonl # ui/src/i18n/.i18n/fa.meta.json # ui/src/i18n/.i18n/fa.tm.jsonl # ui/src/i18n/.i18n/fr.meta.json # ui/src/i18n/.i18n/fr.tm.jsonl # ui/src/i18n/.i18n/hi.meta.json # ui/src/i18n/.i18n/hi.tm.jsonl # ui/src/i18n/.i18n/id.meta.json # ui/src/i18n/.i18n/id.tm.jsonl # ui/src/i18n/.i18n/it.meta.json # ui/src/i18n/.i18n/it.tm.jsonl # ui/src/i18n/.i18n/ja-JP.meta.json # ui/src/i18n/.i18n/ja-JP.tm.jsonl # ui/src/i18n/.i18n/ko.meta.json # ui/src/i18n/.i18n/ko.tm.jsonl # ui/src/i18n/.i18n/nl.meta.json # ui/src/i18n/.i18n/nl.tm.jsonl # ui/src/i18n/.i18n/pl.meta.json # ui/src/i18n/.i18n/pl.tm.jsonl # ui/src/i18n/.i18n/pt-BR.meta.json # ui/src/i18n/.i18n/pt-BR.tm.jsonl # ui/src/i18n/.i18n/ru.meta.json # ui/src/i18n/.i18n/ru.tm.jsonl # ui/src/i18n/.i18n/th.meta.json # ui/src/i18n/.i18n/th.tm.jsonl # ui/src/i18n/.i18n/tr.meta.json # ui/src/i18n/.i18n/tr.tm.jsonl # ui/src/i18n/.i18n/uk.meta.json # ui/src/i18n/.i18n/uk.tm.jsonl # ui/src/i18n/.i18n/vi.meta.json # ui/src/i18n/.i18n/vi.tm.jsonl # ui/src/i18n/.i18n/zh-CN.meta.json # ui/src/i18n/.i18n/zh-CN.tm.jsonl # ui/src/i18n/.i18n/zh-TW.meta.json # ui/src/i18n/.i18n/zh-TW.tm.jsonl # ui/src/pages/plugin/plugin-page.ts # ui/src/styles.css
# Conflicts: # ui/src/i18n/.i18n/ar.meta.json # ui/src/i18n/.i18n/de.meta.json # ui/src/i18n/.i18n/es.meta.json # ui/src/i18n/.i18n/fa.meta.json # ui/src/i18n/.i18n/fr.meta.json # ui/src/i18n/.i18n/hi.meta.json # ui/src/i18n/.i18n/id.meta.json # ui/src/i18n/.i18n/it.meta.json # ui/src/i18n/.i18n/ja-JP.meta.json # ui/src/i18n/.i18n/ko.meta.json # ui/src/i18n/.i18n/nl.meta.json # ui/src/i18n/.i18n/pl.meta.json # ui/src/i18n/.i18n/pt-BR.meta.json # ui/src/i18n/.i18n/ru.meta.json # ui/src/i18n/.i18n/th.meta.json # ui/src/i18n/.i18n/tr.meta.json # ui/src/i18n/.i18n/uk.meta.json # ui/src/i18n/.i18n/vi.meta.json # ui/src/i18n/.i18n/zh-CN.meta.json # ui/src/i18n/.i18n/zh-TW.meta.json
checks-node-compact-small-whole-2 failed without executing tests (1433-line job log contains only runner setup/teardown + the checkout retry template) — same runner-infra class previously proven on a sibling branch.
# Conflicts: # ui/src/i18n/.i18n/ar.meta.json # ui/src/i18n/.i18n/de.meta.json # ui/src/i18n/.i18n/es.meta.json # ui/src/i18n/.i18n/fa.meta.json # ui/src/i18n/.i18n/fr.meta.json # ui/src/i18n/.i18n/hi.meta.json # ui/src/i18n/.i18n/id.meta.json # ui/src/i18n/.i18n/it.meta.json # ui/src/i18n/.i18n/ja-JP.meta.json # ui/src/i18n/.i18n/ko.meta.json # ui/src/i18n/.i18n/nl.meta.json # ui/src/i18n/.i18n/pl.meta.json # ui/src/i18n/.i18n/pt-BR.meta.json # ui/src/i18n/.i18n/ru.meta.json # ui/src/i18n/.i18n/th.meta.json # ui/src/i18n/.i18n/tr.meta.json # ui/src/i18n/.i18n/uk.meta.json # ui/src/i18n/.i18n/vi.meta.json # ui/src/i18n/.i18n/zh-CN.meta.json # ui/src/i18n/.i18n/zh-TW.meta.json
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
… sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]>
* feat(dashboard): modular dashboard — workspace store, Workspaces tab, sandboxed custom widgets Squashes #101094 + #101097 + #101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]> * fix(dashboard): UI review fixes — grid, error boundary, embed sandbox, locale * fix(dashboard): make the CLI and agent broadcasts actually reachable Three defects only a live run surfaces, all invisible to the unit suites: - The plugin claimed the CLI command name `dashboard`, which core already owns (it opens the Control UI). A plugin CLI group that overlaps a core command is dropped at registration behind a `logger.debug`, so the entire CLI face was unreachable while `cli.test.ts` kept passing against its own Commander program. Renamed to `openclaw workspaces`, matching the tab it drives. - The manifest never declared `activation.onCommands`, so the CLI root resolved to no owning plugin even once the name was free. - `dashboard.widget.approve` needs `operator.approvals`; the CLI asked for `operator.write` on every call. It now requests the approvals scope only for the approve call, matching `operator-approvals-client.ts`. Also: agent tools resolved their broadcast from the plugin runtime's gateway-request scope, an AsyncLocalStorage set only around gateway RPCs and plugin HTTP routes. An agent turn started from a channel, cron, or heartbeat therefore wrote the document without emitting `plugin.dashboard.changed`, so an open Control UI never saw the edit — the feature's headline promise. The gateway broadcast is server-lifetime, so the plugin now remembers it in a single slot and agent tools fall back to it. * docs(web): document dashboard workspaces, provenance, and the custom-widget sandbox * fix(dashboard): agent-tool ergonomics + close two approval-boundary gaps From a source-blind agent driving the dashboard_* tools with nothing but their schemas, and from a Codex review of the hardening delta. - dashboard_widget_update could never succeed. It passed its whole parameter record to the patch reader, whose allowlist rejects the very `tab`/`id` keys the tool's own schema marks required, so every call died on "unexpected param: tab". Its test only ran Value.Check against the schema and never executed the tool. - dashboard_data_read surfaced an `rpc` binding as a thrown error, though its description promised `binding_client_resolved`. It now returns that as a result the model can act on. - Valid widget kinds and the rpc allowlist were undiscoverable: a model saw only "builtin:<name> or custom:<name>" and "Allowlisted gateway read method", then brute-forced ~40 calls against errors that named no alternatives. Both schemas and both validator errors now enumerate them, and the kind description says what each builtin renders and which binding id it reads. widget_move documents that grid and toTab are exclusive; widget_scaffold says an operator must approve, because no agent tool can. - workspace.replace could mint a pending registry entry for a name that was never scaffolded. An operator could then approve a widget whose code did not exist yet, and the agent could write it afterwards. Registry entries now come from dashboard_widget_scaffold and nowhere else, and approve refuses a name with no manifest on disk. - dashboard.widget.approve answered with the whole workspace document, so a connection holding only operator.approvals could read it through the approvals door. It now returns the registry entry it changed. * fix(dashboard): approval pins the code it approves Codex review found the scaffold-before-approval gate still nameable rather than binding: approve only proved that widget.json parsed, and the Control UI loaded a hardcoded index.html rather than the manifest's entrypoint. An agent could scaffold a widget, win approval on an innocuous or absent entrypoint, then write the real payload afterwards — code appearing after the human said yes. Approval now hashes every servable file in the widget directory and stores the digests on the registry entry, refusing a manifest whose declared entrypoint is missing. The asset route re-hashes each file it reads and 404s anything that does not match, so a file edited or added after approval never reaches a browser. The Control UI loads the manifest's entrypoint, which is the file that was hashed. The content-type allowlist moves to manifest.ts so the set of files approval hashes and the set the route can serve cannot drift apart. Proof, against a running gateway: scaffold -> 404, approve -> 200, rewrite index.html -> 404, add late.js -> 404. * fix(dashboard): parse the approved manifest from the bytes that were hashed Codex found a TOCTOU in the approval path: it loaded and validated widget.json, then walked the directory again to compute the digests. An agent could swap widget.json between the two reads, so the operator validated one entrypoint while the digest froze — and the Control UI later mounted — a different one. snapshotApprovedWidget now reads the widget directory once: it hashes every servable file, parses the manifest out of the same widget.json bytes it hashed, and requires the declared entrypoint to be among them. Proof, against a running gateway: approve -> index.html 200; rewrite widget.json to point at evil.html and drop evil.html in -> both 404. * fix(dashboard): cap approval asset reads; bound the grid fallback search Two findings from the fourth Codex pass. Approval hashes agent-authored files that are untrusted until it runs, and read each one into memory with no size check — dropping one huge .png into a scaffold directory would stall or OOM the gateway during approve. Sizes are now checked before the read, with a 2 MB per-file and 8 MB total cap. nearestFreeSlot searched one band below the lowest occupied row, so a crowded layout near the bottom could return y=500 as the closest free slot: a placement the store rejects, which the UI applies optimistically and then snaps back. The search now stops at the last row a widget of that height can legally occupy. * fix(dashboard): refuse oversized widget assets before reading them Approved widget files stay writable and the asset route is unauthenticated, so swapping an approved small file for a very large one made every GET buffer the whole file before the digest check rejected it. The route now refuses anything past the same per-file cap approval enforces, on the stat it already performs. * fix(dashboard): enforce widget approval boundaries * docs(changelog): note modular dashboard workspaces * fix(dashboard): enforce static custom-widget data boundary * fix(dashboard): satisfy UI lint * test(dashboard): avoid legacy proto access * feat(dashboard): make plugin opt-in * docs(dashboard): refresh workspaces map * refactor(workspaces): standardize plugin naming * fix(workspaces): make widget prompt sends idempotent * docs(workspaces): fix internal path references * test(workspaces): make prompt assertion lint-safe * test(workspaces): type prompt request mock * fix(workspaces): harden approval and binding boundaries * test(workspaces): complete stale binding client mock * fix(workspaces): harden widget file boundaries * fix(workspaces): scope custom widget capabilities * fix(workspaces): align approval provenance * fix(workspaces): close branch contract gaps * test(workspaces): complete builtin context fixtures * fix(workspaces): aggregate overview usage * chore(workspaces): defer release note * chore(workspaces): refresh i18n metadata --------- Co-authored-by: 100yenadmin <[email protected]>
|
Superseded by the integrated implementation in #104139, landed as The custom-widget proposal, approval, serving, and sandbox flow is now on Closing this top-of-stack PR so #104139 remains the canonical implementation. Thank you @100yenadmin for the original work. |
* feat(dashboard): modular dashboard — workspace store, Workspaces tab, sandboxed custom widgets Squashes openclaw#101094 + openclaw#101097 + openclaw#101098 onto current main and applies the maintainer review fixes to the backend control plane. Co-authored-by: 100yenadmin <[email protected]> * fix(dashboard): UI review fixes — grid, error boundary, embed sandbox, locale * fix(dashboard): make the CLI and agent broadcasts actually reachable Three defects only a live run surfaces, all invisible to the unit suites: - The plugin claimed the CLI command name `dashboard`, which core already owns (it opens the Control UI). A plugin CLI group that overlaps a core command is dropped at registration behind a `logger.debug`, so the entire CLI face was unreachable while `cli.test.ts` kept passing against its own Commander program. Renamed to `openclaw workspaces`, matching the tab it drives. - The manifest never declared `activation.onCommands`, so the CLI root resolved to no owning plugin even once the name was free. - `dashboard.widget.approve` needs `operator.approvals`; the CLI asked for `operator.write` on every call. It now requests the approvals scope only for the approve call, matching `operator-approvals-client.ts`. Also: agent tools resolved their broadcast from the plugin runtime's gateway-request scope, an AsyncLocalStorage set only around gateway RPCs and plugin HTTP routes. An agent turn started from a channel, cron, or heartbeat therefore wrote the document without emitting `plugin.dashboard.changed`, so an open Control UI never saw the edit — the feature's headline promise. The gateway broadcast is server-lifetime, so the plugin now remembers it in a single slot and agent tools fall back to it. * docs(web): document dashboard workspaces, provenance, and the custom-widget sandbox * fix(dashboard): agent-tool ergonomics + close two approval-boundary gaps From a source-blind agent driving the dashboard_* tools with nothing but their schemas, and from a Codex review of the hardening delta. - dashboard_widget_update could never succeed. It passed its whole parameter record to the patch reader, whose allowlist rejects the very `tab`/`id` keys the tool's own schema marks required, so every call died on "unexpected param: tab". Its test only ran Value.Check against the schema and never executed the tool. - dashboard_data_read surfaced an `rpc` binding as a thrown error, though its description promised `binding_client_resolved`. It now returns that as a result the model can act on. - Valid widget kinds and the rpc allowlist were undiscoverable: a model saw only "builtin:<name> or custom:<name>" and "Allowlisted gateway read method", then brute-forced ~40 calls against errors that named no alternatives. Both schemas and both validator errors now enumerate them, and the kind description says what each builtin renders and which binding id it reads. widget_move documents that grid and toTab are exclusive; widget_scaffold says an operator must approve, because no agent tool can. - workspace.replace could mint a pending registry entry for a name that was never scaffolded. An operator could then approve a widget whose code did not exist yet, and the agent could write it afterwards. Registry entries now come from dashboard_widget_scaffold and nowhere else, and approve refuses a name with no manifest on disk. - dashboard.widget.approve answered with the whole workspace document, so a connection holding only operator.approvals could read it through the approvals door. It now returns the registry entry it changed. * fix(dashboard): approval pins the code it approves Codex review found the scaffold-before-approval gate still nameable rather than binding: approve only proved that widget.json parsed, and the Control UI loaded a hardcoded index.html rather than the manifest's entrypoint. An agent could scaffold a widget, win approval on an innocuous or absent entrypoint, then write the real payload afterwards — code appearing after the human said yes. Approval now hashes every servable file in the widget directory and stores the digests on the registry entry, refusing a manifest whose declared entrypoint is missing. The asset route re-hashes each file it reads and 404s anything that does not match, so a file edited or added after approval never reaches a browser. The Control UI loads the manifest's entrypoint, which is the file that was hashed. The content-type allowlist moves to manifest.ts so the set of files approval hashes and the set the route can serve cannot drift apart. Proof, against a running gateway: scaffold -> 404, approve -> 200, rewrite index.html -> 404, add late.js -> 404. * fix(dashboard): parse the approved manifest from the bytes that were hashed Codex found a TOCTOU in the approval path: it loaded and validated widget.json, then walked the directory again to compute the digests. An agent could swap widget.json between the two reads, so the operator validated one entrypoint while the digest froze — and the Control UI later mounted — a different one. snapshotApprovedWidget now reads the widget directory once: it hashes every servable file, parses the manifest out of the same widget.json bytes it hashed, and requires the declared entrypoint to be among them. Proof, against a running gateway: approve -> index.html 200; rewrite widget.json to point at evil.html and drop evil.html in -> both 404. * fix(dashboard): cap approval asset reads; bound the grid fallback search Two findings from the fourth Codex pass. Approval hashes agent-authored files that are untrusted until it runs, and read each one into memory with no size check — dropping one huge .png into a scaffold directory would stall or OOM the gateway during approve. Sizes are now checked before the read, with a 2 MB per-file and 8 MB total cap. nearestFreeSlot searched one band below the lowest occupied row, so a crowded layout near the bottom could return y=500 as the closest free slot: a placement the store rejects, which the UI applies optimistically and then snaps back. The search now stops at the last row a widget of that height can legally occupy. * fix(dashboard): refuse oversized widget assets before reading them Approved widget files stay writable and the asset route is unauthenticated, so swapping an approved small file for a very large one made every GET buffer the whole file before the digest check rejected it. The route now refuses anything past the same per-file cap approval enforces, on the stat it already performs. * fix(dashboard): enforce widget approval boundaries * docs(changelog): note modular dashboard workspaces * fix(dashboard): enforce static custom-widget data boundary * fix(dashboard): satisfy UI lint * test(dashboard): avoid legacy proto access * feat(dashboard): make plugin opt-in * docs(dashboard): refresh workspaces map * refactor(workspaces): standardize plugin naming * fix(workspaces): make widget prompt sends idempotent * docs(workspaces): fix internal path references * test(workspaces): make prompt assertion lint-safe * test(workspaces): type prompt request mock * fix(workspaces): harden approval and binding boundaries * test(workspaces): complete stale binding client mock * fix(workspaces): harden widget file boundaries * fix(workspaces): scope custom widget capabilities * fix(workspaces): align approval provenance * fix(workspaces): close branch contract gaps * test(workspaces): complete builtin context fixtures * fix(workspaces): aggregate overview usage * chore(workspaces): defer release note * chore(workspaces): refresh i18n metadata --------- Co-authored-by: 100yenadmin <[email protected]>
Sandboxed custom widgets + approval flow (3/3)
▶ Full-quality recording (light / dark).
Run the whole thing
Because this PR sits at the top of the stack, checking it out gives you the complete modular-dashboard feature in one branch — no need to assemble the stack yourself:
The default Overview-as-data workspace renders on first load. Ask an agent (or run
openclaw dashboard tabs create --title Financials) to build a tab and watch it appear live; scaffold a custom widget, approve it from the pending card, and watch the sandboxed iframe mount. CI runs against this full integration.What problem this solves
#101094 and #101097 get an operator most of the way there with a live grid and trusted, built-in widgets — but the actual bet of this whole series is that an agent can build something bespoke: a chart, a custom view, a one-off tool, authored on the spot in response to "build me a revenue chart." That means running agent-authored code inside the operator's browser, which is the one place in this series that needs to be provably safe before it ships, not just believed safe.
What's in this PR
dashboard_widget_scaffoldtool from feat(dashboard): workspace document + control plane (plugin backend) [1/3] #101094) creates a widget directory with a manifest and anindex.html; it enters the registry asstatus: "pending". A pending widget renders as a placeholder card (name, author, Approve/Reject) — no iframe is ever created for a pending or rejected widget, on either the client or the server. Approval routes through the existing plugin-approvals infrastructure as well as an inline Approve button, so it shows up wherever operators already look for approvals.ui/src/components/dashboard-custom-widget.ts):<iframe sandbox="allow-scripts">— neverallow-same-origin, never any other token. The sandbox attribute is treated as a constant, not configuration.ui/src/lib/dashboard/bridge.ts): the widget can ask for its declared data bindings and a theme snapshot, and — only with an explicit manifest capability plus a per-invocation operator confirm dialog — ask to send a prompt into chat. The parent only ever accepts messages from the exact iframe window it created, only recognizes a fixed set of message types, and only resolves bindings the widget's own manifest declared — a widget cannot request data it wasn't given permission to see.Content-Security-Policy(includingconnect-src 'none', so "no network" is structural, not just a convention) on every response. Onlyapprovedwidgets are served at all — pending/rejected widgets 404 server-side, independent of whatever the client does.See it
The full scaffold → pending → approve → mount → live lifecycle, one frame at a time:
1. Pending — the iframe does not exist yet. A scaffolded widget lands as a placeholder card (name, author, Approve/Reject). No iframe is constructed on the client, and the assets 404 server-side, until an operator approves.
2. Approved and mounted. After the operator approves, the sandboxed iframe mounts and renders — here an approved custom chart widget on the composed Financials tab, alongside AI-provenance chips and the finance-activity lifecycle feed.
3. Live. A bound value updates in place as the finance agent writes new data — the widget never fetched anything; the parent pushed the resolved binding over the bridge.
The same composed Financials tab in the dark theme, kebab menu open, sandboxed widget rendering — agent-authored UI adopts the operator's theme tokens automatically:
How it works
The design principle is one sentence: the widget is untrusted agent-authored code, so it gets no origin, no network, no credentials, and no direct line to the gateway — the trusted parent does everything on its behalf. Every mechanism below is a consequence of that.
The key insight: the widget never talks to the gateway
A custom widget is HTML/JS an agent wrote. It renders in the operator's browser but is treated as hostile. It has no way to reach the gateway: the sandbox denies it an origin, the CSP denies it the network, and it holds no token. All data reaches it through a single narrow channel — the parent resolves the widget's declared bindings and posts the results in. If the widget wants data it wasn't granted, there is no request it can make that succeeds. That inversion (parent-resolves-on-behalf, never widget-fetches) is what makes running agent-authored code in the operator's browser defensible.
Rendering — a constant
allow-scriptssandboxThe host component (
ui/src/components/dashboard-custom-widget.ts) mounts<iframe sandbox="allow-scripts" referrerpolicy="no-referrer">. The sandbox attribute is a constant string, not configuration — neverallow-same-origin, neverallow-top-navigation,allow-popups, orallow-forms. Withallow-scriptsalone, the frame's origin is opaque (serializes as"null"), so it can never touch the parent's storage, cookies, or DOM. The code comments call this out explicitly ("do not templatize") so a future well-meaning edit can't loosen it.Serving — a static-only, path-jailed, approval-gated route
Sandboxed frames carry no device token, so the asset route (
extensions/dashboard/src/http-route.ts) is registeredauth:"plugin"(unauthenticated). That is safe only because the route is static-file-only and can leak nothing:/plugins/dashboard/widgets/<name>/<path...>—<name>passes the canvas charset check (^[A-Za-z0-9._-]+$);<path...>is URL-decoded, logically normalized, and containment-checked against the widget's own directory (theextensions/canvas/src/documents.tsidiom, reused verbatim).default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'none'; frame-ancestors 'self', plusX-Content-Type-Options: nosniff.connect-src 'none'makes "no network" structural, not conventional.status === "approved". Pending/rejected widgets 404 server-side, independent of anything the client does — so approval-bypass is enforced where the bytes are, not merely in the UI.The bridge — versioned, window-identity-filtered, allowlist-re-checked
Data reaches the widget through a versioned postMessage bridge (
ui/src/lib/dashboard/bridge.ts, envelope{ v:1, type, … }), whose security logic is deliberately DOM-free so it's unit-testable in isolation:event.source === iframe.contentWindow. Origin strings are useless here (a sandboxed origin is"null"), so identity is the check — a message from any other window, including a second widget's iframe, is dropped.bindingId.file/staticbindings resolve viadashboard.data.read(server re-validates the jail);rpcbindings are called by the parent on its own gateway client — and the method is re-checked at resolve time against the browser-side mirror of the plugin's write-time allowlist (RPC_METHOD_ALLOWLIST), a defense-in-depth check on top of PR1's write-time gate. A widget cannot request a binding or method it wasn't granted.targetOrigin "*"(required for an opaque child origin) — acceptable by construction because the only payloads ever posted are binding data and theme tokens the widget is already entitled to. No config, no session data, no tokenized URLs are ever sent.sendPromptis triple-gated: it requires the manifest capabilityprompt:send, then a per-invocation operator confirm dialog quoting the exact text, then it's rate-limited (1 in-flight + 10/min per widget) before it reaches the existing chat-send path.getThemereturns the Control UI's current CSS custom-property tokens (including feat: session-first sidebar, compact context ring, and warm light theme for the Control UI #99289's light palette) so agent-authored UI matches the operator's theme without the author guessing.Approval lifecycle
An agent-scaffolded widget (
dashboard_widget_scaffold, from #101094) enterswidgetsRegistryasstatus:"pending". A pending widget renders the placeholder card — no iframe is constructed, client or server. Approval routes through the existing plugin-approvals infrastructure (so it appears wherever operators already look for approvals) as well as the card's inline Approve button →dashboard.widget.approve→ re-broadcast. Onlyapprovedwidgets ever get an iframe, and onlyapprovedwidgets are ever served — the gate is double-enforced, UI and server.Then we tried to break it
This layer got an independent adversarial security review before being proposed here, specifically because it's the highest-security-surface part of the project. The review's job was to try to refute three claims — the path-jail on asset serving, the iframe sandbox, and the CSP — and it could not refute any of them, across two independent passes and dozens of live probes against the running implementation (path traversal variants, encoded/absolute/backslash/symlink-out paths, sandbox-attribute inspection, and a live
connect-src 'none'network test).The review did find one real gap: a prompt-dispatch rate limit that didn't survive a widget being removed and re-added (a remount could reset the counter). That was fixed — the rate limiter is now keyed independently of widget lifecycle and covered by a regression test that fails before the fix and passes after — plus a second, defense-in-depth check added at resolve time and a test that keeps the UI-side allowlist mirror in sync with the server-side one. Nothing else from the review was left open.
The resulting safety story, concretely:
allow-scriptsonly — noallow-same-origin, so the iframe's origin is opaque and it can never touch the parent's storage, cookies, or DOM.connect-src 'none'in the CSP), not just discouraged by convention.Real behavior proof
Behavior or issue addressed: Agents cannot author bespoke widgets that render in the operator's dashboard, because running agent-authored HTML in the browser is unsafe without a hard sandbox. This PR adds the sandboxed custom-widget host: an
allow-scripts-only iframe (opaque origin), a static-only, path-jailed, approval-gated serving route withconnect-src 'none', and a window-identity-filtered postMessage bridge where the trusted parent resolves the widget's declared bindings on its behalf — so an agent-scaffolded widget renders only after operator approval and never talks to the gateway directly.Evidence: 79 plugin + 284 UI tests pass, including the path-jail suite (traversal, encoded traversal, absolute, backslash, symlink-out, name-charset, disallowed extension, non-GET → all 404 with
Content-Security-Policy+X-Content-Type-Optionsasserted on every response), the bridge suite (foreign-window postMessage dropped, undeclared-binding denial,sendPromptcapability-gate + confirm dialog + rate limit), and the custom-widget E2E (approve → mount, a livefetch()from the widget blocked by CSP, the sandbox attribute asserted to be exactlyallow-scripts, and the kill test). An independent adversarial review could not refute the path-jail, sandbox, or CSP across two passes and dozens of live probes; the one hole it found (a prompt rate limit that reset on widget remount) is fixed with a regression test that fails before and passes after. The scaffold → pending → approve → mount → live lifecycle is in the stills above and the recording.Verification
Size: ~1,400 lines of reviewable source across 9 files (the sandboxed-widget host) — comfortably inside VISION.md's ~5K reviewable-line guideline.
../), encoded traversal, absolute paths, backslash variants, symlink-out, name-charset violations, disallowed extensions, non-GET requests — all 404, with correctContent-Security-PolicyandX-Content-Type-Optionsheaders asserted on every response.sendPromptwithout capability denied without even showing a dialog, with capability shows the dialog and sends exactly once on confirm / nothing on deny, rate-limit behavior under remount.connect-src 'none'asserted against a livefetch()from inside a widget; sandbox attribute asserted to be exactlyallow-scriptsvia DOM inspection; the kill test (a throwing widget produces an isolated error card, siblings and other tabs unaffected); the full approve → mount → live-update round trip against a fixture widget.fetch()block, sandbox-attribute assertion, kill test). CI runs on this PR.Closes / part of
Closes #66983 / #68497 (agent-rendered UI reachable from the browser — this is exactly that, with the safety work done up front). Partially addresses #27574 (browser preview panel — the custom-widget host covers the "agent shows me something in the browser" half; a dedicated standalone preview panel outside the dashboard grid is not in scope here). Part of #101093. Builds on #101094, #101097 (top of the stack). With all three merged, the full end-to-end flow in the recording above — agent composes a tab, scaffolds and approves a sandboxed widget, live updates, human reorders — is reproducible against a running gateway.