Skip to content

Commit 0af8467

Browse files
feat(workspaces): add agent-composable Workspaces (#104139)
* 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]>
1 parent a8e2da7 commit 0af8467

112 files changed

Lines changed: 17316 additions & 80 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/labeler.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
- "extensions/workboard/**"
5353
- "docs/plugins/workboard.md"
5454
- "docs/plugins/reference/workboard.md"
55+
"plugin: workspaces":
56+
- changed-files:
57+
- any-glob-to-any-file:
58+
- "extensions/workspaces/**"
59+
- "docs/web/workspaces.md"
5560
"plugin: migrate-hermes":
5661
- changed-files:
5762
- any-glob-to-any-file:

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,6 +1721,7 @@
17211721
"pages": [
17221722
"web/index",
17231723
"web/control-ui",
1724+
"web/workspaces",
17241725
"web/dashboard",
17251726
"web/webchat",
17261727
"web/tui",

docs/docs_map.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10048,3 +10048,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
1004810048
- H2: Remote use
1004910049
- H2: Configuration reference (WebChat)
1005010050
- H2: Related
10051+
10052+
## web/workspaces.md
10053+
10054+
- Route: /web/workspaces
10055+
- Headings:
10056+
- H2: Enable Workspaces
10057+
- H2: The default workspace
10058+
- H2: Built-in widgets
10059+
- H2: Provenance
10060+
- H2: Custom widgets
10061+
- H2: CLI
10062+
- H2: Storage

docs/web/workspaces.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
---
2+
summary: "Agent-composable Workspaces in the Control UI"
3+
read_when:
4+
- Building or rearranging workspace tabs and widgets
5+
- Letting an agent compose a workspace
6+
- Reviewing the custom-widget approval and sandbox model
7+
title: "Workspaces"
8+
---
9+
10+
The **Workspaces** tab in the [Control UI](/web/control-ui) is a surface you and your
11+
agents arrange together. Tabs, widgets, their positions on a 12-column grid, and their
12+
data bindings all live in one document. Anything that can edit that document can compose
13+
the workspace: you, the `openclaw workspaces` CLI, or an agent calling `workspace_*` tools.
14+
15+
Every write goes through the same validated path, so a human's layout and an agent's
16+
layout cannot diverge. Each accepted write bumps a version and broadcasts
17+
`plugin.workspaces.changed`, so an agent's edit appears in an already-open browser without
18+
a reload.
19+
20+
## Enable Workspaces
21+
22+
The bundled Workspaces plugin is disabled by default. In the Control UI, open **Plugins**,
23+
find **Workspaces**, and select **Enable**. You can also enable it from the CLI:
24+
25+
```sh
26+
openclaw plugins enable workspaces
27+
```
28+
29+
Enabling the plugin adds the **Workspaces** tab and makes the `openclaw workspaces` CLI
30+
and `workspace_*` agent tools available. Disabling it removes those surfaces without
31+
deleting the workspace database or widget assets.
32+
33+
## The default workspace
34+
35+
On first load you get an **Overview** workspace: cost and token cards, instance health,
36+
sessions, cron status, and an activity feed. It is ordinary workspace content — drag it,
37+
collapse it, hide it, or delete it.
38+
39+
## Built-in widgets
40+
41+
Nine trusted widgets ship with the plugin and render as first-party UI:
42+
43+
`stat-card`, `markdown`, `table`, `iframe-embed`, `sessions`, `usage`, `cron`,
44+
`instances`, `activity`.
45+
46+
Widgets declare data through **bindings**, they never fetch on their own:
47+
48+
| Binding | Resolves to |
49+
| -------- | --------------------------------------------------------------------------------------------------------- |
50+
| `static` | A literal value stored in the document (8 KB max). |
51+
| `file` | A JSON, Markdown, or CSV file under `<stateDir>/workspaces/data/`, optionally narrowed by a JSON pointer. |
52+
| `rpc` | One of a fixed allowlist of read-only gateway methods, resolved by the trusted Control UI. |
53+
54+
The `file` binding is the simplest way to put your own numbers in a workspace: write a
55+
JSON file into the data directory and point a `stat-card` at it.
56+
57+
## Provenance
58+
59+
Tabs and widgets carry a `createdBy` stamp — `user`, `system`, or `agent:<id>` — set from
60+
whoever made the write. It cannot be supplied by the caller, so an agent cannot label its
61+
work as yours, and the "AI" chip on an agent-authored widget always means what it says.
62+
63+
## Custom widgets
64+
65+
An agent can author a real HTML widget with `workspace_widget_scaffold` (or you can, with
66+
`openclaw workspaces widget-scaffold <name>`). Agent-authored code is treated as hostile:
67+
68+
- A scaffolded widget enters the registry as **pending**. No iframe is created, and the
69+
asset route returns 404 for its files, until an operator approves it.
70+
- Approval is a separate decision from editing a layout: `workspaces.widget.approve`
71+
requires the `operator.approvals` scope, the same scope that guards exec approvals.
72+
- An approved widget renders in an `<iframe sandbox="allow-scripts">` — never
73+
`allow-same-origin` — so its origin is opaque and it cannot reach the parent's DOM,
74+
storage, or cookies.
75+
- Its assets are served with `connect-src 'none'`, blocking script networking such as
76+
`fetch`, XHR, and WebSockets. It holds no credential and never talks to the gateway.
77+
- Data reaches it only through a versioned `postMessage` bridge. Custom code can receive
78+
declared `static` bindings, which are already agent- or operator-authored workspace
79+
values. RPC and file bindings stay in trusted built-in widgets: browsers allow a
80+
sandboxed child to navigate its own frame, so privileged data is never posted into
81+
agent-authored HTML.
82+
83+
Sending a prompt into chat from a widget additionally requires a manifest capability, a
84+
per-invocation confirmation quoting the exact text, and passes a rate limit.
85+
86+
## CLI
87+
88+
```sh
89+
openclaw workspaces tabs list
90+
openclaw workspaces tabs create --title Financials
91+
openclaw workspaces widget-scaffold revenue-chart --title "Revenue Chart"
92+
openclaw workspaces widget-approve revenue-chart
93+
```
94+
95+
`widget-approve` needs a device paired with the `operator.approvals` scope; approving from
96+
the Control UI does not, because the browser already holds it.
97+
98+
## Storage
99+
100+
The workspace document, the custom-widget registry, and a 20-entry undo ring live in
101+
`<stateDir>/workspaces/workspaces.sqlite`. Agent-authored widget assets stay on disk under
102+
`<stateDir>/workspaces/widgets/<name>/`, and file-binding data under
103+
`<stateDir>/workspaces/data/`, because an agent authors those with ordinary file tools and
104+
the widget route serves their bytes.

extensions/workspaces/index.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
3+
import { registerWorkspaceGatewayMethods } from "./src/gateway.js";
4+
import { createWidgetHttpRouteHandler, WIDGETS_ROUTE_PREFIX } from "./src/http-route.js";
5+
import { WorkspaceStore } from "./src/store.js";
6+
import { createWorkspaceTools } from "./src/tools.js";
7+
8+
export default definePluginEntry({
9+
id: "workspaces",
10+
name: "Workspaces",
11+
description: "Agent-composable Workspaces document and control-plane backend.",
12+
register(api) {
13+
const store = new WorkspaceStore();
14+
registerWorkspaceGatewayMethods({ api, store });
15+
api.registerCli(
16+
async ({ program }) => {
17+
const { registerWorkspaceCli } = await import("./src/cli.js");
18+
registerWorkspaceCli({ program });
19+
},
20+
{
21+
descriptors: [
22+
{
23+
// Core already owns `dashboard` for opening the Control UI. A plugin
24+
// CLI group that overlaps a core command is silently skipped.
25+
name: "workspaces",
26+
description: "Manage Workspaces tabs and widgets",
27+
hasSubcommands: true,
28+
},
29+
],
30+
},
31+
);
32+
api.registerTool((context) => createWorkspaceTools({ api, context, store }), {
33+
names: [
34+
"workspace_get",
35+
"workspace_tab_create",
36+
"workspace_tab_update",
37+
"workspace_tab_delete",
38+
"workspace_tabs_reorder",
39+
"workspace_widget_add",
40+
"workspace_widget_update",
41+
"workspace_widget_move",
42+
"workspace_widget_remove",
43+
"workspace_layout_set",
44+
"workspace_replace",
45+
"workspace_widget_scaffold",
46+
"workspace_undo",
47+
"workspace_data_read",
48+
],
49+
optional: true,
50+
});
51+
52+
// Declares the Workspaces tab; the Control UI renders its bundled view
53+
// (BUNDLED_TAB_VIEWS "workspaces/workspaces") only while this plugin is
54+
// active, so no core code references the plugin id.
55+
api.session.controls.registerControlUiDescriptor({
56+
surface: "tab",
57+
id: "workspaces",
58+
label: "Workspaces",
59+
description: "Composable workspaces you and your agents build together.",
60+
icon: "puzzle",
61+
group: "control",
62+
order: -10,
63+
requiredScopes: ["operator.read"],
64+
});
65+
66+
// Sandboxed iframes cannot attach the gateway device token. The authenticated
67+
// frame RPC mints a scoped capability for one approved content snapshot; the
68+
// static route rejects every request without that capability.
69+
const widgetRoute = createWidgetHttpRouteHandler({ store });
70+
api.registerHttpRoute({
71+
path: WIDGETS_ROUTE_PREFIX,
72+
auth: "plugin",
73+
match: "prefix",
74+
handler: async (req: IncomingMessage, res: ServerResponse) =>
75+
await widgetRoute.handleHttpRequest(req, res),
76+
});
77+
78+
// L2/L5 wire tools, CLI, and HTTP routes through this same store
79+
// instance so every caller shares one validated writer.
80+
},
81+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"id": "workspaces",
3+
"name": "Workspaces",
4+
"description": "Agent-composable Workspaces document and control-plane backend.",
5+
"enabledByDefault": false,
6+
"activation": {
7+
"onStartup": true,
8+
"onCommands": [
9+
"workspaces"
10+
]
11+
},
12+
"configSchema": {
13+
"type": "object",
14+
"additionalProperties": false,
15+
"properties": {}
16+
},
17+
"contracts": {
18+
"tools": [
19+
"workspace_get",
20+
"workspace_tab_create",
21+
"workspace_tab_update",
22+
"workspace_tab_delete",
23+
"workspace_tabs_reorder",
24+
"workspace_widget_add",
25+
"workspace_widget_update",
26+
"workspace_widget_move",
27+
"workspace_widget_remove",
28+
"workspace_layout_set",
29+
"workspace_replace",
30+
"workspace_widget_scaffold",
31+
"workspace_undo",
32+
"workspace_data_read"
33+
]
34+
}
35+
}

extensions/workspaces/package.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"name": "@openclaw/workspaces-plugin",
3+
"version": "2026.7.2",
4+
"private": true,
5+
"description": "OpenClaw agent-composable Workspaces plugin",
6+
"type": "module",
7+
"dependencies": {
8+
"typebox": "1.3.3"
9+
},
10+
"devDependencies": {
11+
"@openclaw/plugin-sdk": "workspace:*",
12+
"openclaw": "workspace:*"
13+
},
14+
"peerDependencies": {
15+
"openclaw": ">=2026.7.2"
16+
},
17+
"peerDependenciesMeta": {
18+
"openclaw": {
19+
"optional": true
20+
}
21+
},
22+
"openclaw": {
23+
"extensions": [
24+
"./index.ts"
25+
]
26+
}
27+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
import { WidgetAssetTokens } from "./asset-tokens.js";
3+
4+
describe("WidgetAssetTokens", () => {
5+
it("scopes an unguessable token to one widget approval snapshot", () => {
6+
const tokens = new WidgetAssetTokens();
7+
const approved = { "index.html": "a".repeat(64) };
8+
const token = tokens.issue("chart", approved);
9+
10+
expect(token).toMatch(/^[A-Za-z0-9_-]{40,}$/);
11+
expect(tokens.allows(token, "chart", approved)).toBe(true);
12+
expect(tokens.allows(token, "other", approved)).toBe(false);
13+
expect(tokens.allows(token, "chart", { "index.html": "b".repeat(64) })).toBe(false);
14+
expect(tokens.isIssued("not-a-token", "chart")).toBe(false);
15+
});
16+
17+
it("issues distinct capabilities for repeated frame requests", () => {
18+
const tokens = new WidgetAssetTokens();
19+
const approved = { "index.html": "a".repeat(64) };
20+
21+
expect(tokens.issue("chart", approved)).not.toBe(tokens.issue("chart", approved));
22+
});
23+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { createHash, randomBytes } from "node:crypto";
2+
3+
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
4+
const MAX_TOKENS = 512;
5+
6+
type TokenEntry = { name: string; approval: string; expiresAt: number };
7+
8+
function approvalFingerprint(files: Record<string, string>): string {
9+
const canonical = Object.entries(files).toSorted(([left], [right]) => left.localeCompare(right));
10+
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
11+
}
12+
13+
/** In-memory capabilities for one approved widget snapshot. Restart revokes all tokens. */
14+
export class WidgetAssetTokens {
15+
private readonly tokens = new Map<string, TokenEntry>();
16+
17+
issue(name: string, approvedFiles: Record<string, string>): string {
18+
const now = Date.now();
19+
this.prune(now);
20+
while (this.tokens.size >= MAX_TOKENS) {
21+
const oldest = this.tokens.keys().next().value;
22+
if (typeof oldest !== "string") {
23+
break;
24+
}
25+
this.tokens.delete(oldest);
26+
}
27+
const token = randomBytes(32).toString("base64url");
28+
this.tokens.set(token, {
29+
name,
30+
approval: approvalFingerprint(approvedFiles),
31+
expiresAt: now + TOKEN_TTL_MS,
32+
});
33+
return token;
34+
}
35+
36+
expiresAt(token: string, name: string): number | null {
37+
return this.isIssued(token, name) ? (this.tokens.get(token)?.expiresAt ?? null) : null;
38+
}
39+
40+
isIssued(token: string, name: string): boolean {
41+
const entry = this.tokens.get(token);
42+
if (!entry || entry.name !== name || entry.expiresAt <= Date.now()) {
43+
this.tokens.delete(token);
44+
return false;
45+
}
46+
return true;
47+
}
48+
49+
allows(token: string, name: string, approvedFiles: Record<string, string>): boolean {
50+
const entry = this.tokens.get(token);
51+
return this.isIssued(token, name) && entry?.approval === approvalFingerprint(approvedFiles);
52+
}
53+
54+
private prune(now: number): void {
55+
for (const [token, entry] of this.tokens) {
56+
if (entry.expiresAt <= now) {
57+
this.tokens.delete(token);
58+
}
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)