Files
openclaw/extensions/workspaces/src/store.ts
Peter Steinberger 0af8467137 feat(workspaces): add agent-composable Workspaces (#104139)
* feat(dashboard): modular dashboard — workspace store, Workspaces tab, sandboxed custom widgets

Squashes openclaw/openclaw#101094 + #101097 + #101098 onto current main and
applies the maintainer review fixes to the backend control plane.

Co-authored-by: 100yenadmin <239388517+100yenadmin@users.noreply.github.com>

* 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 <239388517+100yenadmin@users.noreply.github.com>
2026-07-11 03:30:23 -07:00

281 lines
11 KiB
TypeScript

// Workspaces store: the single writer for the workspace document.
//
// Storage split (AGENTS.md "Storage default: SQLite only"):
// - the workspace document + its undo ring live in this plugin-owned SQLite DB,
// the same shape logbook uses ("frames on disk, everything else in one
// plugin-owned DB");
// - agent-authored widget assets (`workspaces/widgets/<name>/`) and file-binding
// data (`workspaces/data/`) stay on disk, because those are named product
// artifacts: the agent authors them with ordinary file tools and the widget
// route serves their bytes.
//
// Every mutation is a single BEGIN IMMEDIATE transaction, so a read-modify-write
// cycle cannot interleave with another writer. node:sqlite is synchronous, which
// is why the mutator must be synchronous too: the transaction is the lock.
//
// There is deliberately no migration from the `workspace.json` this plugin used
// while it was in review. The plugin has never been reachable from a release tag,
// so no installation can hold that file, and compatibility here is opt-in per
// AGENTS.md. Seeding the default workspace on an empty database is the only
// first-read path.
import { chmodSync, mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { configureSqliteConnectionPragmas } from "openclaw/plugin-sdk/plugin-state-runtime";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { WidgetAssetTokens } from "./asset-tokens.js";
import { DEFAULT_WORKSPACE } from "./default-workspace.js";
import {
validateWorkspaceDoc,
type WorkspaceActor,
type WorkspaceWidgetRegistryEntry,
type WorkspaceDoc,
} from "./schema.js";
export type WorkspaceMutationOptions = { actor: WorkspaceActor };
export type WorkspaceMutationResult = { doc: WorkspaceDoc; changed: boolean };
const MAX_WORKSPACE_BYTES = 256 * 1024;
const UNDO_RING_SIZE = 20;
const DIR_MODE = 0o700;
const FILE_MODE = 0o600;
const BUSY_TIMEOUT_MS = 5000;
const SCHEMA = `
CREATE TABLE IF NOT EXISTS workspace (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
doc TEXT NOT NULL,
updated_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS undo (
version INTEGER PRIMARY KEY,
doc TEXT NOT NULL,
created_ms INTEGER NOT NULL
);
`;
function serializeWorkspaceDoc(doc: WorkspaceDoc): string {
return JSON.stringify(doc);
}
function assertWorkspaceSize(serialized: string): void {
if (Buffer.byteLength(serialized, "utf8") > MAX_WORKSPACE_BYTES) {
throw new Error("workspace document exceeds 256 KB");
}
}
/**
* Reconciles a whole-document replacement against what is already stored, inside
* the write transaction. Two fields are never taken from the caller:
*
* - **the registry itself.** Entries are minted by `workspace_widget_scaffold` and
* nowhere else. Replacement preserves the complete current registry and ignores
* the incoming field: otherwise a caller could delete approval decisions or mint a `pending`
* entry for a name with no widget on disk, have an operator approve it, and only
* then write the code the operator "approved". Status likewise changes only
* through `workspaces.widget.approve` — a document that arrives already marked
* `approved` would skip the gate entirely and the asset route would serve it.
* - **provenance (`createdBy`).** Otherwise an agent could stamp its own tabs and
* widgets `user`, or an operator could stamp `agent:<id>`, and the AI-provenance
* chip would be a lie. Existing entities keep their stamp; new ones get `actor`.
*/
export function reconcileReplace(
incoming: WorkspaceDoc,
current: WorkspaceDoc,
actor: WorkspaceActor,
): WorkspaceDoc {
const widgetsRegistry: Record<string, WorkspaceWidgetRegistryEntry> = structuredClone(
current.widgetsRegistry,
);
const existingTabs = new Map(current.tabs.map((tab) => [tab.slug, tab]));
const existingWidgets = new Map(
current.tabs.flatMap((tab) => tab.widgets.map((widget) => [widget.id, widget] as const)),
);
return {
...incoming,
widgetsRegistry,
tabs: incoming.tabs.map((tab) => ({
...tab,
createdBy: existingTabs.get(tab.slug)?.createdBy ?? actor,
widgets: tab.widgets.map((widget) => ({
...widget,
createdBy: existingWidgets.get(widget.id)?.createdBy ?? actor,
})),
})),
};
}
export class WorkspaceStore {
readonly stateDir: string;
readonly workspaceDir: string;
readonly dbPath: string;
private readonly db: DatabaseSync;
readonly assetTokens = new WidgetAssetTokens();
/**
* Single-slot cache of the parsed document. This process is the only writer
* and every write goes through `commit()`, so the cache is exact rather than
* merely fresh — the capability-gated asset route can check approval status on
* every request without re-parsing a 256 KB document.
*/
private cached: WorkspaceDoc | null = null;
constructor(options: { stateDir?: string } = {}) {
this.stateDir = options.stateDir ?? resolveStateDir();
this.workspaceDir = path.join(this.stateDir, "workspaces");
this.dbPath = path.join(this.workspaceDir, "workspaces.sqlite");
mkdirSync(this.workspaceDir, { recursive: true, mode: DIR_MODE });
this.db = new DatabaseSync(this.dbPath);
try {
configureSqliteConnectionPragmas(this.db, { busyTimeoutMs: BUSY_TIMEOUT_MS });
// WAL/SHM sidecars inherit the main DB file's permissions.
chmodSync(this.dbPath, FILE_MODE);
this.db.exec(SCHEMA);
} catch (error) {
this.db.close();
throw error;
}
}
close(): void {
this.db.close();
}
read(): WorkspaceDoc {
if (this.cached) {
return structuredClone(this.cached);
}
const row = this.db.prepare("SELECT doc FROM workspace WHERE id = 1").get() as
| { doc: string }
| undefined;
if (!row) {
const seeded = validateWorkspaceDoc(structuredClone(DEFAULT_WORKSPACE));
this.commit(seeded, { snapshot: null });
return structuredClone(seeded);
}
const doc = validateWorkspaceDoc(JSON.parse(row.doc));
this.cached = doc;
return structuredClone(doc);
}
/** Registry entry for one custom widget, or null when it was never scaffolded. */
widgetEntry(name: string): WorkspaceWidgetRegistryEntry | null {
return this.read().widgetsRegistry[name] ?? null;
}
/** Approval status for one custom widget. */
widgetStatus(name: string): WorkspaceWidgetRegistryEntry["status"] | null {
return this.widgetEntry(name)?.status ?? null;
}
/**
* Applies `fn` to a draft of the current document and persists the result.
* `fn` must be synchronous: it runs inside the write transaction, which is what
* serializes concurrent RPC / CLI / agent-tool callers.
*/
mutate(
fn: (draft: WorkspaceDoc) => WorkspaceDoc | void,
_options: WorkspaceMutationOptions,
): WorkspaceMutationResult {
return this.transact((current) => {
const draft = structuredClone(current);
const returned = fn(draft);
return returned ?? draft;
});
}
/**
* Replaces the whole document (bulk authoring). Approval state and provenance
* are always reconciled against the stored document, so replace can neither
* self-approve a custom widget nor forge a `createdBy` stamp.
*/
replace(doc: WorkspaceDoc, options: WorkspaceMutationOptions): WorkspaceMutationResult {
return this.transact((current) =>
reconcileReplace(structuredClone(doc), current, options.actor),
);
}
/**
* Restores the newest undo snapshot as a NEW version. The restored document is
* a fresh write, not a rewind: `workspaceVersion` stays monotonic so connected
* UIs — which refetch only on a strictly newer version — see the undo.
*/
undo(): WorkspaceDoc {
return this.transact(
(current) => {
const row = this.db
.prepare("SELECT version, doc FROM undo ORDER BY version DESC LIMIT 1")
.get() as { version: number; doc: string } | undefined;
if (!row) {
throw new Error("no workspace undo snapshot available");
}
this.db.prepare("DELETE FROM undo WHERE version = ?").run(row.version);
// transact() stamps the next version, so the restored document lands as a
// forward write rather than a rewind.
const snapshot = validateWorkspaceDoc(JSON.parse(row.doc));
// Approval state is a separate operator decision, not layout history.
// Undo may restore tabs/widgets, but it must never revive a revoked
// approval or discard a registry decision made after the snapshot.
return { ...snapshot, widgetsRegistry: current.widgetsRegistry };
},
// An undo consumes a snapshot; it must not push one, or repeated undo would
// oscillate between the last two documents instead of walking history back.
{ snapshot: false },
).doc;
}
/**
* One BEGIN IMMEDIATE transaction: read current, derive next, snapshot the old
* document into the undo ring, write, trim. Any throw rolls the whole thing
* back, so a rejected write never leaves a partially applied document.
*/
private transact(
derive: (current: WorkspaceDoc) => WorkspaceDoc,
options: { snapshot?: boolean } = {},
): WorkspaceMutationResult {
this.db.exec("BEGIN IMMEDIATE");
try {
// Derive from what the transaction sees, never from the cache: the cache
// is a read-path accelerator, not the transaction's snapshot.
this.cached = null;
const current = this.read();
const next = validateWorkspaceDoc({
...derive(current),
workspaceVersion: current.workspaceVersion + 1,
});
this.commit(next, { snapshot: options.snapshot === false ? null : current });
this.db.exec("COMMIT");
return { doc: next, changed: true };
} catch (error) {
this.db.exec("ROLLBACK");
this.cached = null;
throw error;
}
}
/** Persists `doc` as the current workspace, pushing `snapshot` onto the undo ring. */
private commit(doc: WorkspaceDoc, params: { snapshot: WorkspaceDoc | null }): void {
const serialized = serializeWorkspaceDoc(doc);
assertWorkspaceSize(serialized);
const now = Date.now();
if (params.snapshot) {
this.db
.prepare("INSERT OR REPLACE INTO undo (version, doc, created_ms) VALUES (?, ?, ?)")
.run(doc.workspaceVersion, serializeWorkspaceDoc(params.snapshot), now);
this.db
.prepare(
"DELETE FROM undo WHERE version NOT IN (SELECT version FROM undo ORDER BY version DESC LIMIT ?)",
)
.run(UNDO_RING_SIZE);
}
this.db
.prepare(
"INSERT INTO workspace (id, version, doc, updated_ms) VALUES (1, ?, ?, ?) " +
"ON CONFLICT(id) DO UPDATE SET version = excluded.version, doc = excluded.doc, updated_ms = excluded.updated_ms",
)
.run(doc.workspaceVersion, serialized, now);
this.cached = doc;
}
}