Files
openclaw/extensions/workspaces/src/tools.ts
Peter Steinberger f81f9d8570 chore: enforce max-lines suppression ratchet (#107315)
* ci: enforce max-lines suppression ratchet

* chore: prune stale max-lines suppression

* fix: close max-lines ratchet enforcement gaps

* fix: harden max-lines ratchet checks

* fix(ci): satisfy max-lines ratchet checks

* style: format max-lines declarations

* fix(ci): match oxlint suppression grammar

* test: isolate max-lines git fixtures

* chore: prune resolved max-lines debt

* test: skip newline path fixture on Windows

* fix: harden max-lines suppression ratchet

* chore: refresh max-lines baseline

* fix: close max-lines ratchet bypasses

* fix: derive ratchet base from PR merge tree

* fix: support older Git in staged ratchet

* fix: align max-lines declarations and baseline

* chore: refresh max-lines baseline for current main

* fix: exclude generated wizard locales from max-lines

* chore: prune resolved max-lines debt
2026-07-14 09:27:02 -07:00

907 lines
31 KiB
TypeScript

import { jsonResult } from "openclaw/plugin-sdk/core";
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginToolContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { getPluginRuntimeGatewayRequestScope } from "openclaw/plugin-sdk/plugin-runtime";
import { Type } from "typebox";
import { workspaceBroadcast, type WorkspaceBroadcast } from "./broadcast.js";
import {
WorkspaceBindingResolutionError,
DATA_READ_RPC_ALLOWLIST,
resolveBinding,
type ResolveBindingOptions,
} from "./data-read.js";
import { scaffoldWorkspaceWidget } from "./scaffold.js";
import {
BUILTIN_WIDGET_KINDS,
isWorkspaceActor,
validateWorkspaceDoc,
type WorkspaceActor,
type WorkspaceBinding,
type WorkspaceGrid,
type WorkspaceTab,
type WorkspaceWidget,
type JsonValue,
type WorkspaceDoc,
} from "./schema.js";
import { WorkspaceStore } from "./store.js";
type WorkspaceToolParams = {
api: OpenClawPluginApi;
context?: OpenClawPluginToolContext;
store?: WorkspaceStore;
broadcast?: WorkspaceBroadcast;
dataRead?: ResolveBindingOptions;
};
type MutationParams = {
store: WorkspaceStore;
actor: WorkspaceActor;
broadcast?: WorkspaceBroadcast;
changedTabSlug?: string;
mutate: (draft: WorkspaceDoc) => void | WorkspaceDoc;
};
const TAB_SLUG_PATTERN = /^[a-z0-9-]{1,40}$/;
const WIDGET_ID_PATTERN = /^[A-Za-z0-9_-]{1,48}$/;
const TOOL_DESCRIPTION_SUFFIX = " Call workspace_get first when you need the current document.";
/**
* Both lists below exist because a model can only see tool schemas. An agent that
* has to brute-force the valid `kind` values or the rpc allowlist burns dozens of
* round-trips against "kind is invalid" / "method is not allowlisted".
*/
const WIDGET_KIND_DESCRIPTION = [
`Widget kind: custom:<name>, or one of ${BUILTIN_WIDGET_KINDS.join(", ")}.`,
"builtin:stat-card (big number; props {label?, format?: usd|percent|int}; binding id `value`),",
"builtin:markdown (props {markdown} or {text}, or a file binding of a .md file),",
"builtin:table (binding id `rows`; props {columns: string[]}),",
"builtin:iframe-embed (props {url}),",
"builtin:sessions, builtin:usage, builtin:cron, builtin:instances, builtin:activity",
"(each reads its own rpc binding; see workspace_get for a worked example).",
"Charts are not builtins — author one with workspace_widget_scaffold and use custom:<name>.",
].join(" ");
const JsonSchema = Type.Unknown({
description: "JSON-compatible value. Per-kind shapes are described on `kind`.",
});
const GridSchema = Type.Object(
{
x: Type.Integer({ minimum: 0, maximum: 11, description: "Grid x column, 0-11." }),
y: Type.Integer({ minimum: 0, maximum: 499, description: "Grid row, 0-499." }),
w: Type.Integer({ minimum: 1, maximum: 12, description: "Grid width, 1-12." }),
h: Type.Integer({ minimum: 1, maximum: 20, description: "Grid height, 1-20." }),
},
{ additionalProperties: false },
);
const BindingSchema = Type.Union([
Type.Object(
{
source: Type.Literal("rpc"),
method: Type.String({
description: `Allowlisted gateway read method, one of: ${DATA_READ_RPC_ALLOWLIST.join(", ")}.`,
}),
params: Type.Optional(
Type.Record(Type.String(), JsonSchema, {
description: "Bounded JSON parameters required by the selected gateway method.",
}),
),
},
{ additionalProperties: false },
),
Type.Object(
{
source: Type.Literal("file"),
path: Type.String({ description: "Relative path under workspace/data." }),
pointer: Type.Optional(Type.String({ description: "Optional JSON pointer." })),
},
{ additionalProperties: false },
),
Type.Object(
{
source: Type.Literal("static"),
value: JsonSchema,
},
{ additionalProperties: false },
),
]);
const BindingsRecordSchema = Type.Record(Type.String(), BindingSchema, {
description: "Widget binding map keyed by binding id.",
});
const WidgetPatchSchema = Type.Object(
{
title: Type.Optional(Type.String({ description: "Widget title, 80 chars max." })),
grid: Type.Optional(GridSchema),
collapsed: Type.Optional(Type.Boolean({ description: "Collapse widget body." })),
hidden: Type.Optional(Type.Boolean({ description: "Hide widget." })),
bindings: Type.Optional(BindingsRecordSchema),
props: Type.Optional(JsonSchema),
},
{ additionalProperties: false },
);
const WidgetInputSchema = Type.Object(
{
id: Type.Optional(Type.String({ description: "Optional unique widget id." })),
kind: Type.String({ description: WIDGET_KIND_DESCRIPTION }),
title: Type.Optional(Type.String({ description: "Widget title." })),
grid: GridSchema,
collapsed: Type.Optional(Type.Boolean({ description: "Initial collapsed state." })),
hidden: Type.Optional(Type.Boolean({ description: "Initial hidden state." })),
bindings: Type.Optional(BindingsRecordSchema),
props: Type.Optional(JsonSchema),
},
{ additionalProperties: false },
);
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readRecord(params: unknown, allowedKeys: readonly string[]): Record<string, unknown> {
if (!isRecord(params)) {
throw new Error("params must be an object");
}
for (const key of Object.keys(params)) {
if (!allowedKeys.includes(key)) {
throw new Error(`unexpected param: ${key}`);
}
}
return params;
}
function readRequiredString(
record: Record<string, unknown>,
key: string,
description = key,
): string {
const value = record[key];
if (typeof value !== "string" || !value.trim()) {
throw new Error(`${description} is required`);
}
return value.trim();
}
function readOptionalString(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key];
if (value === undefined) {
return undefined;
}
if (typeof value !== "string") {
throw new Error(`${key} must be a string`);
}
return value.trim();
}
function readOptionalBoolean(record: Record<string, unknown>, key: string): boolean | undefined {
const value = record[key];
if (value === undefined) {
return undefined;
}
if (typeof value !== "boolean") {
throw new Error(`${key} must be a boolean`);
}
return value;
}
function readSlug(record: Record<string, unknown>, key = "slug"): string {
const slug = readRequiredString(record, key, key);
if (!TAB_SLUG_PATTERN.test(slug)) {
throw new Error(`${key} is invalid`);
}
return slug;
}
function readWidgetId(record: Record<string, unknown>, key = "id"): string {
const id = readRequiredString(record, key, key);
if (!WIDGET_ID_PATTERN.test(id)) {
throw new Error(`${key} is invalid`);
}
return id;
}
function readGrid(value: unknown, pathName = "grid"): WorkspaceGrid {
if (!isRecord(value)) {
throw new Error(`${pathName} must be an object`);
}
for (const key of Object.keys(value)) {
if (!["x", "y", "w", "h"].includes(key)) {
throw new Error(`${pathName}.${key} is not allowed`);
}
}
return {
x: readGridInt(value.x, `${pathName}.x`, 0, 11),
y: readGridInt(value.y, `${pathName}.y`, 0, 499),
w: readGridInt(value.w, `${pathName}.w`, 1, 12),
h: readGridInt(value.h, `${pathName}.h`, 1, 20),
};
}
function readGridInt(value: unknown, pathName: string, min: number, max: number): number {
if (!Number.isInteger(value) || (value as number) < min || (value as number) > max) {
throw new Error(`${pathName} must be an integer from ${min} to ${max}`);
}
return value as number;
}
function readBindings(value: unknown): Record<string, WorkspaceBinding> | undefined {
if (value === undefined) {
return undefined;
}
if (!isRecord(value)) {
throw new Error("bindings must be an object");
}
return value as Record<string, WorkspaceBinding>;
}
function slugBase(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40)
.replace(/-+$/g, "");
}
function makeUniqueSlug(title: string, tabs: WorkspaceTab[]): string {
const used = new Set(tabs.map((tab) => tab.slug));
const base = slugBase(title) || "tab";
if (!used.has(base)) {
return base;
}
for (let index = 2; index < 1000; index += 1) {
const suffix = `-${index}`;
const candidate = `${base.slice(0, 40 - suffix.length)}${suffix}`;
if (!used.has(candidate)) {
return candidate;
}
}
throw new Error("could not generate a unique tab slug");
}
function makeWidgetIdBase(value: string): string {
return (
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48)
.replace(/-+$/g, "") || "widget"
);
}
function makeUniqueWidgetId(widget: Record<string, unknown>, doc: WorkspaceDoc): string {
const existing = new Set(doc.tabs.flatMap((tab) => tab.widgets.map((entry) => entry.id)));
const explicit = widget.id;
if (explicit !== undefined) {
if (typeof explicit !== "string" || !WIDGET_ID_PATTERN.test(explicit)) {
throw new Error("widget.id is invalid");
}
if (existing.has(explicit)) {
throw new Error(`duplicate widget id: ${explicit}`);
}
return explicit;
}
const title =
typeof widget.title === "string"
? widget.title
: typeof widget.kind === "string"
? widget.kind
: "widget";
const base = makeWidgetIdBase(title);
if (!existing.has(base)) {
return base;
}
for (let index = 2; index < 1000; index += 1) {
const suffix = `-${index}`;
const candidate = `${base.slice(0, 48 - suffix.length)}${suffix}`;
if (!existing.has(candidate)) {
return candidate;
}
}
throw new Error("could not generate a unique widget id");
}
function findTab(doc: WorkspaceDoc, slug: string): WorkspaceTab {
const tab = doc.tabs.find((entry) => entry.slug === slug);
if (!tab) {
throw new Error(`workspace tab not found: ${slug}`);
}
return tab;
}
function findWidget(tab: WorkspaceTab, id: string): WorkspaceWidget {
const widget = tab.widgets.find((entry) => entry.id === id);
if (!widget) {
throw new Error(`workspace widget not found: ${id}`);
}
return widget;
}
function readWidgetInput(
value: unknown,
doc: WorkspaceDoc,
actor: WorkspaceActor,
): WorkspaceWidget {
const record = readRecord(value, [
"id",
"kind",
"title",
"grid",
"collapsed",
"hidden",
"bindings",
"props",
]);
const title = readOptionalString(record, "title");
const bindings = readBindings(record.bindings);
return {
id: makeUniqueWidgetId(record, doc),
kind: readRequiredString(record, "kind", "kind"),
...(title !== undefined ? { title } : {}),
grid: readGrid(record.grid),
collapsed: readOptionalBoolean(record, "collapsed") ?? false,
hidden: readOptionalBoolean(record, "hidden") ?? false,
createdBy: actor,
...(bindings !== undefined ? { bindings } : {}),
...(record.props !== undefined ? { props: record.props as JsonValue } : {}),
};
}
function readWidgetPatch(value: unknown): Partial<WorkspaceWidget> {
const record = readRecord(value, ["title", "grid", "collapsed", "hidden", "bindings", "props"]);
const title = readOptionalString(record, "title");
const collapsed = readOptionalBoolean(record, "collapsed");
const hidden = readOptionalBoolean(record, "hidden");
const bindings = readBindings(record.bindings);
return {
...(title !== undefined ? { title } : {}),
...(record.grid !== undefined ? { grid: readGrid(record.grid) } : {}),
...(collapsed !== undefined ? { collapsed } : {}),
...(hidden !== undefined ? { hidden } : {}),
...(bindings !== undefined ? { bindings } : {}),
...(record.props !== undefined ? { props: record.props as JsonValue } : {}),
};
}
function readLayout(value: unknown): Array<{ id: string; grid: WorkspaceGrid }> {
if (!Array.isArray(value)) {
throw new Error("layout must be an array");
}
return value.map((entry, index) => {
const record = readRecord(entry, ["id", "grid"]);
return {
id: readWidgetId(record),
grid: readGrid(record.grid, `layout[${index}].grid`),
};
});
}
function readOrder(value: unknown): string[] {
if (!Array.isArray(value)) {
throw new Error("order must be an array");
}
const seen = new Set<string>();
return value.map((entry, index) => {
if (typeof entry !== "string" || !TAB_SLUG_PATTERN.test(entry)) {
throw new Error(`order[${index}] is invalid`);
}
if (seen.has(entry)) {
throw new Error(`order contains duplicate slug: ${entry}`);
}
seen.add(entry);
return entry;
});
}
function appendMissingTabsToOrder(doc: WorkspaceDoc): void {
const seen = new Set(doc.prefs.tabOrder);
for (const tab of doc.tabs) {
if (!seen.has(tab.slug)) {
doc.prefs.tabOrder.push(tab.slug);
}
}
}
function contextOwner(ctx: OpenClawPluginToolContext | undefined): string {
const record = (ctx ?? {}) as Record<string, unknown>;
return (
(typeof record.agentId === "string" && record.agentId) ||
(typeof record.sessionKey === "string" && record.sessionKey) ||
(typeof record.sessionId === "string" && record.sessionId) ||
"agent"
);
}
function actorFromContext(ctx: OpenClawPluginToolContext | undefined): WorkspaceActor {
const normalized =
contextOwner(ctx)
.replace(/[^A-Za-z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 64) || "agent";
const actor = `agent:${normalized}`;
if (!isWorkspaceActor(actor)) {
throw new Error("tool context owner cannot be used as workspace actor");
}
return actor;
}
function broadcastChange(
broadcast: WorkspaceBroadcast | undefined,
params: { doc: WorkspaceDoc; actor: WorkspaceActor; changedTabSlug?: string },
) {
broadcast?.("plugin.workspaces.changed", {
workspaceVersion: params.doc.workspaceVersion,
...(params.changedTabSlug ? { changedTabSlug: params.changedTabSlug } : {}),
actor: params.actor,
});
}
function resolveWorkspaceBroadcast(
broadcast: WorkspaceBroadcast | undefined,
): WorkspaceBroadcast | undefined {
// The request scope only exists when the turn originated from a gateway RPC.
// A channel/cron/heartbeat turn falls back to the remembered server handle, so
// an agent edit still reaches every open Control UI.
return (
broadcast ?? getPluginRuntimeGatewayRequestScope()?.context?.broadcast ?? workspaceBroadcast()
);
}
async function runMutation(params: MutationParams) {
const result = params.store.mutate(params.mutate, { actor: params.actor });
broadcastChange(params.broadcast, {
doc: result.doc,
actor: params.actor,
changedTabSlug: params.changedTabSlug,
});
return jsonResult({ doc: result.doc, workspaceVersion: result.doc.workspaceVersion });
}
// Scaffold template (spec-50 §Scaffold): demonstrates the v1 handshake, getData +
// onData(=push), theme tokens applied to CSS vars, ZERO external requests, and a
// visible "built by <createdBy>" footer. Framework-free and < 100 lines.
function toolDescription(text: string): string {
return `${text}${TOOL_DESCRIPTION_SUFFIX}`;
}
export function createWorkspaceTools(params: WorkspaceToolParams): AnyAgentTool[] {
const store = params.store ?? new WorkspaceStore();
const actor = actorFromContext(params.context);
const broadcast = resolveWorkspaceBroadcast(params.broadcast);
const mutationBase = {
store,
actor,
broadcast,
};
return [
{
name: "workspace_get",
label: "Workspace Get",
description: "Read the full Workspaces document so an agent can diff before mutating it.",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
const doc = store.read();
return jsonResult({ doc, workspaceVersion: doc.workspaceVersion });
},
},
{
name: "workspace_tab_create",
label: "Workspace Tab Create",
description: toolDescription(
"Create a workspace tab. Slugs are lowercase letters, digits, and dashes, max 40 chars.",
),
parameters: Type.Object(
{
title: Type.String({ description: "Tab title, 1-80 chars." }),
slug: Type.Optional(Type.String({ description: "Optional tab slug." })),
icon: Type.Optional(Type.String({ description: "Optional icon name." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["title", "slug", "icon"]);
const title = readRequiredString(record, "title", "title");
const icon = readOptionalString(record, "icon");
let changedTabSlug: string | undefined;
return await runMutation({
...mutationBase,
mutate: (draft) => {
const slug =
record.slug === undefined ? makeUniqueSlug(title, draft.tabs) : readSlug(record);
if (draft.tabs.some((tab) => tab.slug === slug)) {
throw new Error(`workspace tab already exists: ${slug}`);
}
changedTabSlug = slug;
draft.tabs.push({
slug,
title,
...(icon !== undefined ? { icon } : {}),
hidden: false,
createdBy: actor,
widgets: [],
});
draft.prefs.tabOrder.push(slug);
},
get changedTabSlug() {
return changedTabSlug;
},
});
},
},
{
name: "workspace_tab_update",
label: "Workspace Tab Update",
description: toolDescription("Update a workspace tab title, icon, or hidden state."),
parameters: Type.Object(
{
slug: Type.String({ description: "Tab slug." }),
title: Type.Optional(Type.String({ description: "New title." })),
icon: Type.Optional(Type.String({ description: "New icon." })),
hidden: Type.Optional(Type.Boolean({ description: "Hide or show the tab." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["slug", "title", "icon", "hidden"]);
const slug = readSlug(record);
const title = readOptionalString(record, "title");
const icon = readOptionalString(record, "icon");
const hidden = readOptionalBoolean(record, "hidden");
return await runMutation({
...mutationBase,
changedTabSlug: slug,
mutate: (draft) => {
Object.assign(findTab(draft, slug), {
...(title !== undefined ? { title } : {}),
...(icon !== undefined ? { icon } : {}),
...(hidden !== undefined ? { hidden } : {}),
});
},
});
},
},
{
name: "workspace_tab_delete",
label: "Workspace Tab Delete",
description: toolDescription("Delete a workspace tab and all widgets inside it."),
parameters: Type.Object(
{ slug: Type.String({ description: "Tab slug." }) },
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["slug"]);
const slug = readSlug(record);
return await runMutation({
...mutationBase,
changedTabSlug: slug,
mutate: (draft) => {
const nextTabs = draft.tabs.filter((tab) => tab.slug !== slug);
if (nextTabs.length === draft.tabs.length) {
throw new Error(`workspace tab not found: ${slug}`);
}
draft.tabs = nextTabs;
draft.prefs.tabOrder = draft.prefs.tabOrder.filter((entry) => entry !== slug);
},
});
},
},
{
name: "workspace_tabs_reorder",
label: "Workspace Tabs Reorder",
description: toolDescription("Set workspace tab order. Missing existing tabs are appended."),
parameters: Type.Object(
{ order: Type.Array(Type.String({ description: "Tab slug." })) },
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["order"]);
const order = readOrder(record.order);
return await runMutation({
...mutationBase,
mutate: (draft) => {
const slugs = new Set(draft.tabs.map((tab) => tab.slug));
for (const slug of order) {
if (!slugs.has(slug)) {
throw new Error(`workspace tab not found: ${slug}`);
}
}
draft.prefs.tabOrder = order;
appendMissingTabsToOrder(draft);
},
});
},
},
{
name: "workspace_widget_add",
label: "Workspace Widget Add",
description: toolDescription(
"Add a widget to a tab. Grid x+w must fit within the 12-column workspace grid.",
),
parameters: Type.Object(
{
tab: Type.String({ description: "Target tab slug." }),
...WidgetInputSchema.properties,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, [
"tab",
"id",
"kind",
"title",
"grid",
"collapsed",
"hidden",
"bindings",
"props",
]);
const tabSlug = readSlug(record, "tab");
const widgetInput = { ...record };
delete widgetInput.tab;
return await runMutation({
...mutationBase,
changedTabSlug: tabSlug,
mutate: (draft) => {
findTab(draft, tabSlug).widgets.push(readWidgetInput(widgetInput, draft, actor));
},
});
},
},
{
name: "workspace_widget_update",
label: "Workspace Widget Update",
description: toolDescription("Patch a widget title, grid, visibility, bindings, or props."),
parameters: Type.Object(
{
tab: Type.String({ description: "Tab slug." }),
id: Type.String({ description: "Widget id." }),
...WidgetPatchSchema.properties,
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, [
"tab",
"id",
"title",
"grid",
"collapsed",
"hidden",
"bindings",
"props",
]);
const tabSlug = readSlug(record, "tab");
const id = readWidgetId(record);
// `tab` and `id` address the widget; everything else is the patch. Passing
// the whole record to readWidgetPatch made this tool uncallable: its reader
// rejects `tab`/`id` as unexpected params.
const { tab: _tab, id: _id, ...patchInput } = record;
const patch = readWidgetPatch(patchInput);
return await runMutation({
...mutationBase,
changedTabSlug: tabSlug,
mutate: (draft) => {
Object.assign(findWidget(findTab(draft, tabSlug), id), patch);
},
});
},
},
{
name: "workspace_widget_move",
label: "Workspace Widget Move",
description: toolDescription(
"Move a widget by changing its grid OR moving it to another tab — exactly one of " +
"`grid` and `toTab`, never both. A cross-tab move keeps the widget's old grid " +
"position, so follow it with a second call to reposition.",
),
parameters: Type.Object(
{
tab: Type.Optional(Type.String({ description: "Current tab slug for grid moves." })),
id: Type.String({ description: "Widget id." }),
grid: Type.Optional(GridSchema),
toTab: Type.Optional(Type.String({ description: "Destination tab slug." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["tab", "id", "grid", "toTab"]);
if (record.grid !== undefined && record.toTab !== undefined) {
throw new Error("workspace_widget_move accepts either grid or toTab, not both");
}
if (record.grid === undefined && record.toTab === undefined) {
throw new Error("workspace_widget_move requires grid or toTab");
}
const id = readWidgetId(record);
const changedTabSlug =
typeof record.toTab === "string"
? record.toTab
: typeof record.tab === "string"
? record.tab
: undefined;
return await runMutation({
...mutationBase,
changedTabSlug,
mutate: (draft) => {
if (record.grid !== undefined) {
const tabSlug = readSlug(record, "tab");
findWidget(findTab(draft, tabSlug), id).grid = readGrid(record.grid);
return;
}
const toTab = readSlug(record, "toTab");
const destination = findTab(draft, toTab);
for (const tab of draft.tabs) {
const index = tab.widgets.findIndex((widget) => widget.id === id);
if (index >= 0) {
destination.widgets.push(tab.widgets.splice(index, 1)[0]!);
return;
}
}
throw new Error(`workspace widget not found: ${id}`);
},
});
},
},
{
name: "workspace_widget_remove",
label: "Workspace Widget Remove",
description: toolDescription("Remove a widget from a tab."),
parameters: Type.Object(
{
tab: Type.String({ description: "Tab slug." }),
id: Type.String({ description: "Widget id." }),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["tab", "id"]);
const tabSlug = readSlug(record, "tab");
const id = readWidgetId(record);
return await runMutation({
...mutationBase,
changedTabSlug: tabSlug,
mutate: (draft) => {
const tab = findTab(draft, tabSlug);
const next = tab.widgets.filter((widget) => widget.id !== id);
if (next.length === tab.widgets.length) {
throw new Error(`workspace widget not found: ${id}`);
}
tab.widgets = next;
},
});
},
},
{
name: "workspace_layout_set",
label: "Workspace Layout Set",
description: toolDescription("Batch-update widget grids for one tab."),
parameters: Type.Object(
{
tab: Type.String({ description: "Tab slug." }),
layout: Type.Array(
Type.Object(
{ id: Type.String({ description: "Widget id." }), grid: GridSchema },
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["tab", "layout"]);
const tabSlug = readSlug(record, "tab");
const layout = readLayout(record.layout);
return await runMutation({
...mutationBase,
changedTabSlug: tabSlug,
mutate: (draft) => {
const tab = findTab(draft, tabSlug);
for (const entry of layout) {
findWidget(tab, entry.id).grid = entry.grid;
}
},
});
},
},
{
name: "workspace_replace",
label: "Workspace Replace",
description: toolDescription(
"Replace the full workspace document after local validation and size/schema caps.",
),
parameters: Type.Object({ doc: Type.Unknown() }, { additionalProperties: false }),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["doc"]);
const doc = validateWorkspaceDoc(record.doc);
const result = store.replace(doc, { actor });
broadcastChange(broadcast, { doc: result.doc, actor });
return jsonResult({ doc: result.doc, workspaceVersion: result.doc.workspaceVersion });
},
},
{
name: "workspace_widget_scaffold",
label: "Workspace Widget Scaffold",
description: toolDescription(
"Create a custom widget scaffold and register it as pending. Agent-authored widget code " +
"never renders until a human approves it, and there is no tool to approve it — ask the " +
"operator to approve from the Workspaces tab, or to run `openclaw workspaces " +
"widget-approve <name>`. Edit the scaffolded index.html to build the widget.",
),
parameters: Type.Object(
{
name: Type.String({ description: "Custom widget name, A-Z a-z 0-9 . _ - only." }),
title: Type.Optional(Type.String({ description: "Widget display title." })),
},
{ additionalProperties: false },
),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["name", "title"]);
const scaffold = await scaffoldWorkspaceWidget({
name: readRequiredString(record, "name", "name"),
title: readOptionalString(record, "title"),
stateDir: store.stateDir,
createdBy: actor,
});
const result = store.mutate(
(draft) => {
draft.widgetsRegistry[scaffold.name] = {
status: "pending",
createdBy: actor,
};
},
{ actor },
);
broadcastChange(broadcast, { doc: result.doc, actor });
return jsonResult({
...scaffold,
registry: result.doc.widgetsRegistry[scaffold.name],
workspaceVersion: result.doc.workspaceVersion,
});
},
},
{
name: "workspace_undo",
label: "Workspace Undo",
description: "Restore the newest workspace undo snapshot.",
parameters: Type.Object({}, { additionalProperties: false }),
execute: async () => {
const doc = store.undo();
broadcastChange(broadcast, { doc, actor });
return jsonResult({ doc, workspaceVersion: doc.workspaceVersion });
},
},
{
name: "workspace_data_read",
label: "Workspace Data Read",
description:
"Resolve a workspace binding exactly as a widget sees it. `file` and `static` bindings " +
'return their data; an `rpc` binding returns { status: "binding_client_resolved" } ' +
"because only the trusted Control UI may call the gateway on a widget's behalf.",
parameters: Type.Object({ binding: BindingSchema }, { additionalProperties: false }),
execute: async (_toolCallId, rawParams) => {
const record = readRecord(rawParams, ["binding"]);
try {
return jsonResult({ data: await resolveBinding(record.binding, params.dataRead) });
} catch (error) {
// `rpc` bindings are resolved by the trusted Control UI over its own
// authenticated socket. That is the documented answer, not an error, so
// return it as a result the model can act on.
if (
error instanceof WorkspaceBindingResolutionError &&
error.code === "binding_client_resolved"
) {
return jsonResult({ status: "binding_client_resolved", message: error.message });
}
throw error;
}
},
},
];
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */