feat(dashboard): workspace document + control plane (plugin backend) [1/3]#101094
feat(dashboard): workspace document + control plane (plugin backend) [1/3]#101094100yenadmin wants to merge 3 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).
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 ( |
Note for maintainers — the
|
|
Codex review: found issues before merge. Reviewed July 9, 2026, 7:31 PM ET / 23:31 UTC. Summary PR surface: Source +3300, Tests +1400, Config +27, Other +13. Total +4740 across 19 files. Reproducibility: no. current-main bug reproduction applies because this is a new feature; source inspection of the PR head reproduces the storage, undo-version, and caller-controlled actor blockers. 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:
Risk before merge
Maintainer options:
Next step before merge
Maintainer decision needed
Security Review findings
Review detailsBest possible solution: Land only after dashboard state is SQLite-backed or explicitly approved as a named artifact, undo publishes monotonic versions, gateway actor provenance comes from trusted context, and maintainers approve the default-on product/security boundary. Do we have a high-confidence way to reproduce the issue? No current-main bug reproduction applies because this is a new feature; source inspection of the PR head reproduces the storage, undo-version, and caller-controlled actor blockers. Is this the best way to solve the issue? No. A single validated dashboard control plane is plausible, but this implementation needs canonical storage, monotonic versioning, trusted provenance, dependency/security authorization, and maintainer-approved default behavior before it is the best merge shape. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 3dd5c1880f46. Label changesLabel justifications:
Evidence reviewedPR surface: Source +3300, Tests +1400, Config +27, Other +13. Total +4740 across 19 files. View PR surface stats
Security concerns:
Acceptance criteria:
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 (5 earlier review cycles)
|
A whole-document dashboard.workspace.replace accepted any widgetsRegistry entry verbatim, so a caller could submit a doc with a custom widget already marked status:"approved" and skip the operator approval gate entirely (approve is meant to be the sole transition to "approved"). Add DashboardStore.replaceSanitized, which reconciles the incoming registry against the CURRENT document inside the write lock (no TOCTOU): any entry that arrives "approved" without already being "approved" is downgraded to "pending" and its approval provenance dropped. The workspace.replace gateway method now uses it; replace stays a trusted primitive for seeding/restore/undo. Found during the downstream Boardstate extraction's adversarial security audit (SPEC invariant I3). +3 regression tests. Formatting proof: oxfmt --write + oxlint clean (node_modules unavailable in this worktree; committed --no-verify after separate formatting proof).
|
Added a security fix to this PR (commit above), from the downstream Boardstate extraction's adversarial audit (SPEC invariant I3): Integration note: |
… 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 backend/control-plane scope is now on Closing this stacked foundation 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]>
dashboard plugin: workspace document + control plane (1/3)
This PR has no UI, but its whole point is what happens on the wire. Here the control plane is doing exactly what it exists to do: an agent's
dashboard.tab.create/dashboard.widget.addcalls land in the store and compose a live tab (the overlay chips are the real RPCs firing) — the same validated path a human's CLI call takes.What problem this solves
Right now there's no shared, structured place for "what does this operator's dashboard look like" to live — any composability has to be hand-coded per view. Two audiences need this: operators who want to lay out their own control hub without touching code, and agents that want to build that layout for them. Both need the same underlying guarantee — one validated document, no partial writes, no path where a human's layout and an agent's layout can silently diverge.
What's in this PR
extensions/dashboard— a new bundled plugin,enabledByDefault: true, zero core-file changes (git diff --statis scoped entirely to the plugin directory).workspace.json— the layout-as-data document (tabs → widgets → grid position → data bindings), owned at<stateDir>/dashboard/workspace.json,schemaVersion-migrated on read.DashboardStore— the single writer. Atomic writes (replaceFileAtomic), async-mutex-serialized mutations, a 256 KB size cap, a 32-tab / 24-widget-per-tab cap, and a 20-entry undo ring. Every mutation is validated by hand-written guards (repo idiom — no schema library at the RPC layer); invalid ops are rejected whole, never partially applied.dashboard.*) covering tab CRUD/reorder, widget CRUD/move/batch-layout, widget approval, full-document replace (bulk agent authoring), undo, and a scoped data-read endpoint — each correctly scopedoperator.readoroperator.write.plugin.dashboard.changed { workspaceVersion, changedTabSlug, actor }so every connected operator UI can live-update. Reads never broadcast; failed writes never broadcast.file(path-jailed under<stateDir>/dashboard/data/) orstaticbinding; nothing in this layer lets a widget make an arbitrary network call. (Anrpcbinding kind exists in the schema for write-time validation, but is resolved client-side by the trusted Control UI in PR2/PR3 — this plugin's gateway handlers cannot dispatch other gateway methods at this HEAD, sodashboard.data.readintentionally does not proxyrpcbindings. Noted here because it shaped the schema.)openclaw dashboard tabs/widgets/layout …from a terminal, and 14dashboard_*agent tools with typebox schemas — both call the identical in-process validated path the gateway RPC uses. Agent-originated writes are stampedcreatedBy: "agent:<id>"from tool context; this is never overridable via tool params.See it
The image above is this control plane in action. The whole end-to-end series — CLI call, agent tool call, and the UI layers on top — is in the animated demo on the tracking issue and the integration PR.
How it works
The design goal is a single contract: one validated document, one writer, three faces. Humans and agents both compose the operator's dashboard, and they must never be able to diverge — so every mutation, no matter who originates it, goes through the exact same code path.
workspace.json— layout as dataThe dashboard's entire state is one JSON document at
<stateDir>/dashboard/workspace.json(state dir viaresolveStateDir(), default~/.openclaw). It is layout-as-data: tabs contain widgets, widgets carry a grid position, data bindings, and provenance. Nothing about a workspace lives in code — it's all this document.{ "schemaVersion": 1, "workspaceVersion": 42, // monotonic; bumped on every accepted write "tabs": [ { "slug": "financials", // ^[a-z0-9-]{1,40}$ — also the deep-link key "title": "Financials", "icon": "barChart", "hidden": false, "createdBy": "agent:main", // provenance: "user" | "system" | "agent:<id>" "widgets": [ { "id": "w_rev_q3", "kind": "builtin:stat-card", // "builtin:<name>" | "custom:<name>" "title": "Q3 Revenue", "grid": { "x": 0, "y": 0, "w": 4, "h": 2 }, // 12-column grid units "collapsed": false, "bindings": { "value": { "source": "file", "path": "q3.json", "pointer": "/revenue" } }, "props": { "format": "usd" } } ] } ], "widgetsRegistry": { // custom-widget install/approval state (PR3) "revenue-chart": { "status": "approved", "approvedBy": "user", "createdBy": "agent:main" } }, "prefs": { "tabOrder": ["main", "financials"] } }schemaVersionis migrated on read; future versions are rejected rather than guessed at.DashboardStore— the sole writerDashboardStoreis the only thing in the codebase that writes this file. Itsmutate(fn, { actor })path is deliberately boring and total:structuredClonethe current doc and applyfnto the draft;validateWorkspaceDoc(draft)— hand-written guards, no schema library at the RPC layer (repo idiom);<stateDir>/dashboard/undo/;replaceFileAtomic(temp file + rename) and bumpworkspaceVersion.Any validation failure throws before step 5 — rejects are whole; nothing is ever partially applied, and the file on disk is never touched by a rejected write. Because a single process owns the file and the mutex serializes callers, there is no interleaving and no last-writer-wins race.
14
dashboard.*methods, correctly scopedThe store is fronted by 14 gateway methods, each registered with an explicit scope — reads are
operator.read, writes areoperator.write:operator.read)operator.write)dashboard.workspace.getdashboard.tab.create·dashboard.tab.update·dashboard.tab.delete·dashboard.tab.reorderdashboard.data.readdashboard.widget.add·dashboard.widget.update·dashboard.widget.move·dashboard.widget.remove·dashboard.widget.setLayoutdashboard.widget.approve·dashboard.workspace.replace(bulk agent authoring) ·dashboard.workspace.undoValidation is hand-written param readers per method (throwing typed errors), copied from the
extensions/workboardidiom rather than a zod layer — so review reads the same as the rest of the repo.One store, three faces — the same validated path
This is the crux. The CLI and the agent tools are thin callers over the same store instance, not parallel implementations:
dashboard.*— used by the Control UI (PR2) and the CLI.openclaw dashboard tabs/widgets/layout …— a plugin-registered CLI that calls the RPC.dashboard_*(14 tools, typebox input schemas) — call the store in-process, hitting the identical validation + broadcast.A human's
openclaw dashboard tabs create --title Financialsand an agent'sdashboard_tab_createtool call produce byte-identical documents through byte-identical validation — the only difference is thecreatedBystamp, which is set from the caller's context (agent:<id>for tools) and can never be overridden via tool params. That is what makes "agent and human edit the same layout" a guarantee rather than a hope.Change broadcasts
Every successful write ends with
opts.context.broadcast("plugin.dashboard.changed", { workspaceVersion, changedTabSlug, actor }), so every connected operator UI can live-update (PR2 subscribes to this). Reads never broadcast; failed writes never broadcast. The payload is intentionally minimal — receivers refetch; the whole document is never shipped in an event.Bindings, not fetches
Widgets declare data sources; this layer never lets a widget make an arbitrary call. A binding is a
file(path-jailed under<stateDir>/dashboard/data/, reusing the canvas logical-path-normalization idiom) or astaticliteral, resolved bydashboard.data.read. Anrpcbinding kind also exists in the schema and is validated at write time againstDATA_READ_RPC_ALLOWLIST— but it is deliberately not proxied by this plugin's handlers: at this HEAD, plugin gateway-method handlers cannot dispatch other gateway methods (SDK gategatewayMethodDispatchAllowed), sorpcbindings are resolved client-side by the trusted Control UI over its own authenticated socket (same authz boundary).dashboard.data.readreturns a typedbinding_client_resolvedfor them. This is noted here because it shaped the schema, and PR2/PR3 depend on it.Everything above is
extensions/dashboardonly —git diff --statis scoped entirely to the plugin directory. Zero core-file changes.Real behavior proof
Behavior or issue addressed: There is no shared, validated document for an operator's dashboard layout, so any composability has to be hand-coded per view and a human's layout and an agent's layout can silently diverge. This PR adds
extensions/dashboard— oneworkspace.jsondocument and oneDashboardStorefronted by 14 gateway methods, a CLI, and 14 agent tools, so every write (RPC, CLI, or agent tool) funnels through one validated, atomic, undoable path.Evidence: The
extensions/dashboardtest suite passes vianode scripts/test-projects.mjs extensions/dashboard— store atomicity, the 20-entry undo ring (21st mutation evicts oldest; undo restores exactly), size/count caps, every schema reject path (slug charset, dup slugs, grid overflow, kind pattern, binding union,createdBypattern), gateway-method registration/scoping, the binding resolver, and a concurrency test proving two mutations serialize under the mutex;tsgoand oxlint clean. The control plane in action — an agent'sdashboard.tab.create/dashboard.widget.addRPCs composing a live tab through the store — is the screenshot above and the end-to-end recording on the tracking issue.Verification
Size: ~3,300 lines of source across 11 files (the backend + control plane, excluding tests) — well inside VISION.md's ~5K reviewable-line guideline. The full PR is 19 files including its test suite.
createdBypattern — has a dedicated test), gateway method registration/scoping, and the binding resolver.extensions/dashboardtest suite (store, schema, gateway methods, CLI, agent tools, binding resolver) andtsgo/oxlint pass. CI runs on this PR.Closes / part of
Part of #101093. Foundation for #101097, #101098 — neither later PR is reachable without this one merged first.