Skip to content

feat(dashboard): sandboxed custom widgets + approval flow [3/3]#101098

Closed
100yenadmin wants to merge 13 commits into
openclaw:mainfrom
100yenadmin:up/pr3-custom-widgets
Closed

feat(dashboard): sandboxed custom widgets + approval flow [3/3]#101098
100yenadmin wants to merge 13 commits into
openclaw:mainfrom
100yenadmin:up/pr3-custom-widgets

Conversation

@100yenadmin

@100yenadmin 100yenadmin commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

📦 Part of the modular-dashboard package — master hub #101136 (architecture diagrams · bottom-up merge order · the full per-PR test map).

🧪 Try it: this branch is the complete substrate — gh pr checkout 101098 && pnpm install && pnpm build, start the gateway with the dashboard plugin, open Control UI → Workspaces. Scaffold a custom widget and approve the pending card → the sandboxed iframe mounts (pending/rejected assets 404 server-side).


Sandboxed custom widgets + approval flow (3/3)

The dashboard your agent builds with you. This is the capability nothing else in the ecosystem has: an agent authors a real HTML widget, it renders live inside the operator's dashboard, and it does so inside a sandbox strict enough that independent security review couldn't break out of it. This is the last mile between "the agent describes a dashboard" and "the agent builds it."

End-to-end: an agent scaffolds a custom revenue-chart widget, a pending-approval card appears, the operator approves it, the sandboxed iframe mounts and renders live data through the bridge, then the human keeps working the composed grid

▶ Full-quality recording (light / dark).

Stack note. This is the top of a three-PR stack (#101094 backend → #101097 UI → #101098 here). The reviewable new work in this PR is the sandboxed custom-widget host — ~1,400 lines of source across 9 files, well under VISION.md's ~5K reviewable-line guideline. Because we don't have upstream write access to set GitHub-native stacked bases, all three PRs target main with cumulative diffs, so this branch's diff includes #101094's and #101097's commits until they merge to mainreview #101094 and #101097 first, then read this PR's extensions/dashboard/src/{http-route,serve,manifest}.ts and ui/src/{components/dashboard-custom-widget,lib/dashboard/bridge}.ts as the delta. This same branch is also the full feature end-to-end — check it out to run everything.

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:

gh pr checkout <this-PR-number>       # or: git fetch <fork> feat/modular-dashboard && git checkout FETCH_HEAD
pnpm install && pnpm build
# start the gateway with the dashboard plugin enabled, open the Control UI → the "Workspaces" tab

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

  • Scaffold → pending → approve → mount lifecycle. An agent (via the dashboard_widget_scaffold tool from feat(dashboard): workspace document + control plane (plugin backend) [1/3] #101094) creates a widget directory with a manifest and an index.html; it enters the registry as status: "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.
  • Sandboxed iframe host (ui/src/components/dashboard-custom-widget.ts): <iframe sandbox="allow-scripts"> — never allow-same-origin, never any other token. The sandbox attribute is treated as a constant, not configuration.
  • A versioned, minimal postMessage bridge (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.
  • Static-file-only asset serving: a new unauthenticated plugin HTTP route (necessary — sandboxed frames carry no device token) that serves only files inside the widget's own directory, behind a charset check on the widget name, path normalization, and a containment check, with a locked-down Content-Security-Policy (including connect-src 'none', so "no network" is structural, not just a convention) on every response. Only approved widgets are served at all — pending/rejected widgets 404 server-side, independent of whatever the client does.
  • Theme sync: widgets receive the Control UI's current theme tokens so agent-authored UI matches the operator's chosen theme (including feat: session-first sidebar, compact context ring, and warm light theme for the Control UI #99289's light palette) without the widget author having to guess.

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.

A custom widget in the pending-approval card: "Revenue Chart — awaiting approval, built by finance agent," with Approve and Reject affordances and no iframe rendered

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.

The composed Financials tab with the approved, sandboxed custom widget rendering a chart, 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 approved custom widget with a bound revenue value updating live, driven by a data push through the bridge rather than any network call from the widget

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:

Dark theme: the composed Financials dashboard with the sandboxed custom widget rendering and a widget cell's kebab menu open

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-scripts sandbox

The 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 — never allow-same-origin, never allow-top-navigation, allow-popups, or allow-forms. With allow-scripts alone, 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 registered auth:"plugin" (unauthenticated). That is safe only because the route is static-file-only and can leak nothing:

  • Name and path: /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 (the extensions/canvas/src/documents.ts idiom, reused verbatim).
  • Every rejection is a 404, never a 403 — a traversal attempt, a bad charset, a disallowed extension, or a non-GET method is indistinguishable from a miss, so the route leaks no existence information.
  • Content type is allowlisted by extension (html/js/css/json/svg/png/jpg/webp/woff2/txt/md/csv); anything else 404s.
  • Strict CSP on every response: 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', plus X-Content-Type-Options: nosniff. connect-src 'none' makes "no network" structural, not conventional.
  • Approval gate at serve time: the handler checks the registry and serves only widgets whose 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:

  • Accept filter by window identity: the parent accepts a message only when 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.
  • Fixed message set, declared bindings only: the parent recognizes a closed set of types and resolves only bindings the widget's own manifest declared, by bindingId. file/static bindings resolve via dashboard.data.read (server re-validates the jail); rpc bindings 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.
  • Parent→child posts use 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.
  • sendPrompt is triple-gated: it requires the manifest capability prompt: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.
  • Theme sync: getTheme returns 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) enters widgetsRegistry as status:"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. Only approved widgets ever get an iframe, and only approved widgets 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:

  • Rendering is allow-scripts only — no allow-same-origin, so the iframe's origin is opaque and it can never touch the parent's storage, cookies, or DOM.
  • Serving is static-file-only, path-jailed, and gated on registry approval status server-side — approval bypass isn't a client-trust question, it's enforced where the bytes are served from.
  • Networking is structurally blocked (connect-src 'none' in the CSP), not just discouraged by convention.
  • Data only ever reaches the widget through the bridge, resolved by the authenticated parent against the widget's own declared bindings — the widget itself never holds a credential and never talks to the gateway directly.
  • Prompt dispatch is capability-gated in the manifest, confirm-dialog-gated per invocation, and rate-limited independent of the widget's mount/unmount lifecycle.

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 with connect-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-Options asserted on every response), the bridge suite (foreign-window postMessage dropped, undeclared-binding denial, sendPrompt capability-gate + confirm dialog + rate limit), and the custom-widget E2E (approve → mount, a live fetch() from the widget blocked by CSP, the sandbox attribute asserted to be exactly allow-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.

  • Path-jail suite: traversal (../), encoded traversal, absolute paths, backslash variants, symlink-out, name-charset violations, disallowed extensions, non-GET requests — all 404, with correct Content-Security-Policy and X-Content-Type-Options headers asserted on every response.
  • Bridge suite: handshake, foreign-window postMessage correctly ignored, unknown message types dropped, undeclared binding requests denied, sendPrompt without 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.
  • E2E: connect-src 'none' asserted against a live fetch() from inside a widget; sandbox attribute asserted to be exactly allow-scripts via 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.
  • The plugin serve/manifest/path-jail suites and the UI bridge + custom-widget suites pass (79 plugin + 284 UI tests), plus the custom-widget E2E (approve → mount, foreign-window drop, CSP 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.

Eva added 4 commits July 6, 2026 23:57
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.
@100yenadmin
100yenadmin requested a review from a team as a code owner July 6, 2026 18:18
@chatgpt-codex-connector

Copy link
Copy Markdown
Contributor

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, add credits to your account and enable them for code reviews in your settings.

@github-actions github-actions Bot added the dependencies-changed PR changes dependency-related files label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Dependency Guard

This PR changes dependency-related files. Maintainers should confirm these changes are intentional.

Changed files:

  • extensions/dashboard/package.json
  • pnpm-lock.yaml

Maintainer follow-up:

  • Review whether the dependency changes are intentional.
  • Inspect resolved package deltas when lockfile, shrinkwrap, or workspace dependency policy changes are present.
  • Treat package-lock.json and npm-shrinkwrap.json diffs as security-review surfaces.
  • Run pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json locally for detailed release-style evidence.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Dependency graph changes are blocked

OpenClaw 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:

  • pnpm-lock.yaml changed.
  • extensions/dashboard/package.json changed dependencies, devDependencies, peerDependencies, peerDependenciesMeta, name, version.

Auto-scrub was not attempted because this PR changes package manifest dependency graph fields:

  • extensions/dashboard/package.json changed dependencies, devDependencies, peerDependencies, peerDependenciesMeta, name, version.

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 push

If this PR intentionally needs a dependency graph change, ask a repository admin or member of @openclaw/openclaw-secops to comment:

/allow-dependencies-change

The action will approve the current head SHA (386053d3e98ae2f813ed646a7f7c840f68ff0e71) when it reruns. A later push requires a fresh approval.

@100yenadmin

Copy link
Copy Markdown
Contributor Author

Dependency-graph note: this PR inherits #101094's lockfile change (the new extensions/dashboard importer + [email protected], already in the resolved graph). See #101094 for the full breakdown — no new packages enter the graph.

@clawsweeper

clawsweeper Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs real behavior proof before merge. Reviewed July 10, 2026, 3:36 AM ET / 07:36 UTC.

Summary
Adds a bundled modular dashboard stack with persistent workspaces, built-in and sandboxed custom widgets, operator approval, a constrained postMessage bridge, and Control UI integration.

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.

  • Default product surfaces: 1 enabled-by-default plugin and 1 Control UI tab added. Existing installations would gain a startup plugin and primary navigation surface after upgrade.
  • Persistent state model: 1 workspace JSON file plus a 20-file undo ring added. The durable state path conflicts with the repository's SQLite-only state policy and creates an upgrade-sensitive schema.
  • Executable-widget gates: 1 unauthenticated asset route and 1 custom approval gate added. These gates control whether agent-authored browser code can be served and executed.

Stored data model
Persistent data-model change detected: database schema: extensions/dashboard/src/default-workspace.ts, database schema: extensions/dashboard/src/manifest.test.ts, database schema: extensions/dashboard/src/manifest.ts, database schema: extensions/dashboard/src/schema.ts, database schema: ui/src/lib/dashboard/index.test.ts, database schema: ui/src/pages/plugin/dashboard-view.test.ts, and 11 more. Confirm migration or upgrade compatibility proof before merge.

Root-cause cluster
Relationship: fixed_by_candidate
Canonical: #101093
Summary: This PR is the custom-widget implementation candidate for the modular-dashboard tracking issue and cumulatively includes two lower stack layers.

Members:

Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything.

Merge readiness
Overall: 🦪 silver shellfish
Proof: 🦪 silver shellfish
Patch quality: 🦪 silver shellfish
Result: blocked until stronger real behavior proof is added.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Rank-up moves:

  • Move persistent workspace and undo state into SQLite-backed plugin state.
  • Bind approval to exact widget content, use the shared approval queue, and preserve the approved entrypoint.
  • Post redacted real-browser diagnostics proving the sandbox, CSP, network, bridge, and error-response behavior.

Proof guidance:

  • [P1] Needs stronger real behavior proof before merge: The recordings show scaffold, pending approval, rendering, and live updates, but do not visibly prove CSP headers, the constant sandbox attribute, blocked network access, bridge filtering, handled-error headers, shared approval delivery, or content-bound approval. Add redacted browser-console/network or terminal diagnostics; after updating the PR body, ClawSweeper should re-review automatically, or a maintainer can comment @clawsweeper re-review.

Risk before merge

  • [P1] The plugin is enabled by default and adds a new primary Control UI surface, so landing it intentionally changes startup and navigation behavior for existing installations.
  • [P1] The cumulative stack still needs base-first reconciliation and a fresh mergeability review against current main before any landing decision.
  • [P1] The dependency graph change remains blocked until the exact head receives repository-admin or security authorization.

Maintainer options:

  1. Fix state and approval integrity (recommended)
    Replace JSON sidecars with SQLite-backed state, use the shared approval workflow, and bind approval to the exact manifest and assets served.
  2. Narrow and disable by default
    Land only an opt-in experimental plugin after security proof to avoid changing existing installations by default.
  3. Pause the cumulative stack
    Close or defer the stack if maintainers do not want bundled agent-authored executable widgets as a maintained security boundary.

Next step before merge

  • [P1] The contributor must address concrete architectural and security defects, after which maintainers must decide whether to sponsor the enabled-by-default product and execution boundary.

Maintainer decision needed

  • Question: Should OpenClaw adopt an enabled-by-default bundled dashboard that lets agents author executable browser widgets after the storage, approval-integrity, and proof blockers are fixed?
  • Rationale: This introduces a major default Control UI surface and a new execution model for agent-authored browser code; implementation review cannot determine whether maintainers want to own that permanent product and security boundary.
  • Likely owner: steipete — Recent merged work established the bundled plugin-tab and Control UI direction that this proposal extends.
  • Options:
    • Sponsor the bundled dashboard (recommended): Accept the direction after SQLite state, immutable approval binding, dependency authorization, and real-browser security proof are complete.
    • Narrow to an opt-in plugin: Keep the plugin disabled by default while the security, approval, and operator UX model matures.
    • Pause the initiative: Defer the stack until maintainers select a different dashboard or custom-widget architecture.

Security
Needs attention: The iframe and CSP design has useful defenses, but the current approval model neither uses the shared approval workflow nor binds operator consent to the executable content subsequently served.

Review findings

  • [P1] Move dashboard state into SQLite-backed plugin storage — extensions/dashboard/src/store.ts:49-50
  • [P1] Bind approval to the exact widget content — extensions/dashboard/src/serve.ts:192-198
  • [P2] Honor the approved widget manifest entrypoint — ui/src/components/dashboard-custom-widget.ts:127-130
Review details

Best 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:

  • [P1] Move dashboard state into SQLite-backed plugin storage — extensions/dashboard/src/store.ts:49-50
    DashboardStore still persists the workspace and undo ring as JSON files under the state directory. Repository policy requires OpenClaw-owned runtime state and history to use SQLite, with one canonical runtime path; move the document and revisions into plugin KV or a plugin-owned SQLite schema and update the store tests accordingly.
    Confidence: 0.99
  • [P1] Bind approval to the exact widget content — extensions/dashboard/src/serve.ts:192-198
    The serving gate checks only widgetsRegistry[name].status, then serves whatever files currently exist in that directory. An approved widget can therefore have its manifest, capabilities, entrypoint, or scripts changed afterward without returning to pending; record and verify an immutable content digest or approved snapshot before serving. This was equally visible at the prior reviewed SHA and should have been raised earlier.
    Confidence: 0.98
    Late finding: first raised on code an earlier review cycle already covered.
  • [P2] Honor the approved widget manifest entrypoint — ui/src/components/dashboard-custom-widget.ts:127-130
    loadWidgetManifestView discards the validated entrypoint, leaving the host to mount the conventional index.html path. A valid manifest with another approved entrypoint therefore cannot render; carry the entrypoint through WidgetManifestView and use it to build the iframe URL.
    Confidence: 0.99
  • [P2] Route widget decisions through plugin approvals — extensions/dashboard/src/gateway.ts:656-658
    This registers a private dashboard approval RPC but never creates or resolves a plugin.approval.* request, so widget decisions do not appear in the established operator approval queue despite the stated behavior. Use the shared approval contract and retain the inline control as another resolver. This was equally visible at the prior reviewed SHA and is a late discovery.
    Confidence: 0.96
    Late finding: first raised on code an earlier review cycle already covered.
  • [P3] Remove the branch-carving spec from the product diff — CARVE_SPEC.md:1
    CARVE_SPEC.md is an internal branch-construction note containing contributor-local worktree paths, branch names, and orchestration instructions; it is not product documentation and should not ship in the repository.
    Confidence: 1

Overall correctness: patch is incorrect
Overall confidence: 0.96

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against d133f28cfb4b.

Label changes

Label changes:

  • add rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦪 silver shellfish and patch quality is 🦪 silver shellfish.
  • remove rating: 🧂 unranked krab: Current PR rating is rating: 🦪 silver shellfish, so this older rating label is no longer current.

Label justifications:

  • P2: This is a significant feature proposal with normal-priority product value, not an urgent regression or current outage.
  • merge-risk: 🚨 compatibility: The PR enables a bundled plugin by default and adds persistent workspace and default Control UI behavior for existing installations.
  • merge-risk: 🚨 security-boundary: The PR serves agent-authored executable assets over an unauthenticated route behind an approval gate that is not bound to immutable content.
  • rating: 🦪 silver shellfish: Overall readiness is 🦪 silver shellfish; proof is 🦪 silver shellfish and patch quality is 🦪 silver shellfish.
  • status: 📣 needs proof: The PR needs real behavior proof before ClawSweeper can clear the contributor ask. Needs stronger real behavior proof before merge: The recordings show scaffold, pending approval, rendering, and live updates, but do not visibly prove CSP headers, the constant sandbox attribute, blocked network access, bridge filtering, handled-error headers, shared approval delivery, or content-bound approval. Add redacted browser-console/network or terminal diagnostics; after updating the PR body, ClawSweeper should re-review automatically, or a maintainer can comment @clawsweeper re-review.
  • proof: 🎥 video: Contributor real behavior proof includes video or recording evidence. The recordings show scaffold, pending approval, rendering, and live updates, but do not visibly prove CSP headers, the constant sandbox attribute, blocked network access, bridge filtering, handled-error headers, shared approval delivery, or content-bound approval. Add redacted browser-console/network or terminal diagnostics; after updating the PR body, ClawSweeper should re-review automatically, or a maintainer can comment @clawsweeper re-review.
Evidence reviewed

Security concerns:

  • [high] Approved widgets remain mutable after consent — extensions/dashboard/src/serve.ts:197
    The route authorizes by widget name and status only, so changed scripts or a changed manifest can execute under an old approval without a new operator decision.
    Confidence: 0.98
  • [medium] Custom decisions bypass the central approval queue — extensions/dashboard/src/gateway.ts:657
    The dashboard's private approval RPC does not emit the established plugin approval events, weakening consistent operator visibility, scope handling, and auditing across approval surfaces.
    Confidence: 0.95

What I checked:

  • Persistent state remains file-backed: DashboardStore still creates dashboard/workspace.json and a directory of JSON undo snapshots, conflicting with the repository policy that OpenClaw-owned runtime state belongs in SQLite. (extensions/dashboard/src/store.ts:49, 386053d3e98a)
  • Approval is not bound to executable bytes: The unauthenticated serving route authorizes every current file solely from the registry's approved status; no approved manifest or asset digest is recorded or rechecked, so files or capabilities can change after approval without returning to pending. (extensions/dashboard/src/serve.ts:197, 386053d3e98a)
  • Approval records only name and status: The approval RPC stores the decision, actor, and timestamp but no immutable manifest or asset identity that the serving path could verify. (extensions/dashboard/src/gateway.ts:678, 386053d3e98a)
  • Manifest entrypoint is discarded: The browser manifest read model returns only the name, binding IDs, and capabilities, so the approved entrypoint cannot reach the iframe host and non-index.html manifests remain unsupported. (ui/src/components/dashboard-custom-widget.ts:130, 386053d3e98a)
  • Established plugin approvals are not wired: The dashboard implements its own dashboard.widget.approve method, but the feature code never emits or resolves the existing plugin.approval.* workflow; current main's Control UI approval queue listens for that established contract. (extensions/dashboard/src/gateway.ts:657, 386053d3e98a)
  • Current main has the central approval surface: The Control UI handles plugin.approval.requested and resolves decisions through the shared approval overlay, providing the supported operator-visible approval path this PR claims to reuse. (ui/src/app/overlays.ts:505, d133f28cfb4b)

Likely related people:

  • steipete: Authored and merged the recent bundled plugin-tab Control UI implementation that this dashboard stack cites and extends, and current history attributes the bundled-view registry to Peter Steinberger. (role: introduced adjacent behavior and recent area contributor; confidence: high; commits: c730d8f1f1bb, e1c144a003aa; files: ui/src/pages/plugin/plugin-page.ts, ui/src/pages/plugin/logbook-view.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

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
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (10 earlier review cycles; latest 8 shown)
  • reviewed 2026-07-07T21:44:34.150Z sha 9116bab :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P2] Send the widget CSP on handled 404 responses | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-07T22:47:29.676Z sha 928f8a9 :: needs real behavior proof before merge. :: [P1] Move dashboard state into SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P2] Apply widget CSP on route-owned 404s | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-07T22:56:16.218Z sha 928f8a9 :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P2] Apply widget CSP on handled 404 responses | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-07T23:49:07.092Z sha aa54dc0 :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P2] Apply widget CSP on handled 404 responses | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-08T00:03:25.087Z sha aa54dc0 :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P2] Send the widget CSP on handled 404 responses | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-08T04:27:20.522Z sha aa54dc0 :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P2] Send widget CSP on handled 404 responses | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-08T19:11:04.719Z sha 9356ee4 :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P3] Remove the root carve spec artifact
  • reviewed 2026-07-08T19:17:43.547Z sha 9356ee4 :: needs real behavior proof before merge. :: [P1] Persist dashboard state in SQLite-backed plugin state | [P2] Use the approved widget manifest entrypoint | [P3] Remove the root carve spec artifact

@clawsweeper clawsweeper Bot added proof: 🎥 video Contributor real behavior proof includes video or recording evidence. rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. and removed proof: 📸 screenshot Contributor real behavior proof includes screenshot evidence. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. labels Jul 8, 2026
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).
@100yenadmin

Copy link
Copy Markdown
Contributor Author

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 (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 from the widget origin, so it needs the same lockdown. Extracted setSecurityHeaders() and applied it to both the 200 and 404 paths so they can't drift again; flipped the traversal-404 test (which had asserted the CSP was absent) to assert it's present.

Eva added 4 commits July 10, 2026 06:14
# 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
@clawsweeper clawsweeper Bot added rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. and removed rating: 🧂 unranked krab Not merge-ready due to missing proof or serious correctness/safety concerns. labels Jul 10, 2026
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
… 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]>
steipete added a commit that referenced this pull request Jul 11, 2026
* 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]>
@steipete

Copy link
Copy Markdown
Contributor

Superseded by the integrated implementation in #104139, landed as 0af84671374962f1ca7f8047d655d93d96204ee1.

The custom-widget proposal, approval, serving, and sandbox flow is now on main in the canonical Workspaces implementation. The landed version preserves the original direction and contributor credit, and additionally content-pins approved assets, enforces bounded serving and CSP rules, and carries focused regression plus live lifecycle proof.

Closing this top-of-stack PR so #104139 remains the canonical implementation. Thank you @100yenadmin for the original work.

@steipete steipete closed this Jul 11, 2026
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 12, 2026
* 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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app: web-ui App: web-ui dependencies-changed PR changes dependency-related files merge-risk: 🚨 compatibility 🚨 May break existing users, config, migrations, defaults, or upgrade paths. merge-risk: 🚨 security-boundary 🚨 May affect sandboxing, authorization, credentials, or sensitive data. P2 Normal backlog priority with limited blast radius. proof: 🎥 video Contributor real behavior proof includes video or recording evidence. rating: 🦪 silver shellfish Thin PR readiness signal; proof, validation, or implementation needs work. size: XL status: 📣 needs proof The PR needs real behavior proof before ClawSweeper can clear the contributor ask.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants