mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 07:31:16 +00:00
* feat(workspaces): add full-bleed app tabs * fix(workspaces): harden full-bleed rendering * fix(workspaces): eliminate full-bleed iframe gap * fix(workspaces): keep tab layout type internal --------- Co-authored-by: Eva <eva@100yen.org>
423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { Command } from "commander";
|
|
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
|
import { Value } from "typebox/value";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { registerWorkspaceCli } from "./cli.js";
|
|
import { registerWorkspaceGatewayMethods } from "./gateway.js";
|
|
import { WorkspaceStore } from "./store.js";
|
|
import { createWorkspaceTools } from "./tools.js";
|
|
|
|
const gatewayRuntime = vi.hoisted(() => ({
|
|
callGatewayFromCli: vi.fn(),
|
|
}));
|
|
const pluginRuntime = vi.hoisted(() => ({
|
|
scope: undefined as
|
|
| { context?: { broadcast?: (event: string, payload: unknown) => void } }
|
|
| undefined,
|
|
}));
|
|
|
|
vi.mock("openclaw/plugin-sdk/gateway-runtime", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/gateway-runtime")>(
|
|
"openclaw/plugin-sdk/gateway-runtime",
|
|
);
|
|
return {
|
|
...actual,
|
|
callGatewayFromCli: gatewayRuntime.callGatewayFromCli,
|
|
};
|
|
});
|
|
|
|
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
|
|
"openclaw/plugin-sdk/plugin-runtime",
|
|
);
|
|
return {
|
|
...actual,
|
|
getPluginRuntimeGatewayRequestScope: () => pluginRuntime.scope,
|
|
};
|
|
});
|
|
|
|
async function withTempStateDir<T>(run: (stateDir: string) => Promise<T>): Promise<T> {
|
|
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-tools-"));
|
|
try {
|
|
return await run(stateDir);
|
|
} finally {
|
|
await fs.rm(stateDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function details(result: unknown): Record<string, unknown> {
|
|
return (result as { details?: Record<string, unknown> }).details ?? {};
|
|
}
|
|
|
|
function toolsByName(store: WorkspaceStore, broadcast?: (event: string, payload: unknown) => void) {
|
|
const api = {} as unknown as OpenClawPluginApi;
|
|
return new Map(
|
|
createWorkspaceTools({
|
|
api,
|
|
store,
|
|
...(broadcast ? { broadcast } : {}),
|
|
context: { agentId: "main", sessionKey: "session-1" } as never,
|
|
}).map((tool) => [tool.name, tool]),
|
|
);
|
|
}
|
|
|
|
describe("workspace tools", () => {
|
|
beforeEach(() => {
|
|
gatewayRuntime.callGatewayFromCli.mockReset();
|
|
pluginRuntime.scope = undefined;
|
|
});
|
|
|
|
it("defines strict schemas for every workspace tool", () => {
|
|
const tools = toolsByName(new WorkspaceStore({ stateDir: "/tmp/unused" }));
|
|
expect([...tools.keys()]).toEqual([
|
|
"workspace_get",
|
|
"workspace_tab_create",
|
|
"workspace_tab_update",
|
|
"workspace_tab_delete",
|
|
"workspace_tabs_reorder",
|
|
"workspace_widget_add",
|
|
"workspace_widget_update",
|
|
"workspace_widget_move",
|
|
"workspace_widget_remove",
|
|
"workspace_layout_set",
|
|
"workspace_replace",
|
|
"workspace_widget_scaffold",
|
|
"workspace_undo",
|
|
"workspace_data_read",
|
|
]);
|
|
const validSamples: Record<string, unknown> = {
|
|
workspace_get: {},
|
|
workspace_tab_create: { title: "Finance" },
|
|
workspace_tab_update: { slug: "main", hidden: true, layout: "full" },
|
|
workspace_tab_delete: { slug: "old" },
|
|
workspace_tabs_reorder: { order: ["main"] },
|
|
workspace_widget_add: {
|
|
tab: "main",
|
|
kind: "builtin:markdown",
|
|
grid: { x: 0, y: 0, w: 4, h: 2 },
|
|
},
|
|
workspace_widget_update: { tab: "main", id: "cost-today", collapsed: true },
|
|
workspace_widget_move: { tab: "main", id: "cost-today", grid: { x: 4, y: 0, w: 4, h: 2 } },
|
|
workspace_widget_remove: { tab: "main", id: "cost-today" },
|
|
workspace_layout_set: {
|
|
tab: "main",
|
|
layout: [{ id: "cost-today", grid: { x: 0, y: 0, w: 4, h: 2 } }],
|
|
},
|
|
workspace_replace: {
|
|
doc: {
|
|
schemaVersion: 1,
|
|
workspaceVersion: 1,
|
|
tabs: [
|
|
{
|
|
slug: "main",
|
|
title: "Main",
|
|
hidden: false,
|
|
createdBy: "system",
|
|
widgets: [],
|
|
},
|
|
],
|
|
widgetsRegistry: {},
|
|
prefs: { tabOrder: ["main"] },
|
|
},
|
|
},
|
|
workspace_widget_scaffold: { name: "custom-card" },
|
|
workspace_undo: {},
|
|
workspace_data_read: { binding: { source: "static", value: { ok: true } } },
|
|
};
|
|
for (const [name, tool] of tools) {
|
|
expect(Value.Check(tool.parameters, validSamples[name])).toBe(true);
|
|
expect(Value.Check(tool.parameters, { ...(validSamples[name] as object), extra: true })).toBe(
|
|
false,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("stamps tool provenance from context and rejects createdBy override params", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const broadcast = vi.fn();
|
|
const tools = toolsByName(store, broadcast);
|
|
|
|
await expect(
|
|
tools.get("workspace_tab_create")?.execute("call-1", {
|
|
title: "Bad",
|
|
createdBy: "user",
|
|
}),
|
|
).rejects.toThrow("unexpected param");
|
|
await tools.get("workspace_tab_create")?.execute("call-2", {
|
|
title: "Finance",
|
|
slug: "finance",
|
|
});
|
|
|
|
expect(store.read().tabs.find((tab) => tab.slug === "finance")).toMatchObject({
|
|
createdBy: "agent:main",
|
|
});
|
|
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
expect(broadcast).toHaveBeenCalledWith("plugin.workspaces.changed", {
|
|
workspaceVersion: 2,
|
|
changedTabSlug: "finance",
|
|
actor: "agent:main",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("broadcasts through the active runtime gateway request scope", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const broadcast = vi.fn();
|
|
pluginRuntime.scope = { context: { broadcast } };
|
|
const tools = toolsByName(store);
|
|
|
|
await tools.get("workspace_tab_create")?.execute("call-1", {
|
|
title: "Runtime Scope",
|
|
slug: "runtime-scope",
|
|
});
|
|
|
|
expect(broadcast).toHaveBeenCalledTimes(1);
|
|
expect(broadcast).toHaveBeenCalledWith("plugin.workspaces.changed", {
|
|
workspaceVersion: 2,
|
|
changedTabSlug: "runtime-scope",
|
|
actor: "agent:main",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("sanitizes agent workspace replacement provenance and approvals", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
// Seed an already-approved widget through the store primitive: `replace` is
|
|
// reconciled and by design cannot mint an approval.
|
|
store.mutate(
|
|
(draft) => {
|
|
draft.widgetsRegistry["approved-card"] = {
|
|
status: "approved",
|
|
createdBy: "user",
|
|
approvedBy: "user",
|
|
approvedAt: "2026-01-01T00:00:00.000Z",
|
|
};
|
|
},
|
|
{ actor: "user" },
|
|
);
|
|
|
|
const tools = toolsByName(store);
|
|
const replacement = structuredClone(store.read());
|
|
replacement.tabs[0]!.createdBy = "agent:forged";
|
|
replacement.tabs.push({
|
|
slug: "agent-tab",
|
|
title: "Agent Tab",
|
|
hidden: false,
|
|
createdBy: "user",
|
|
widgets: [],
|
|
});
|
|
replacement.prefs.tabOrder.push("agent-tab");
|
|
replacement.widgetsRegistry["approved-card"] = {
|
|
status: "pending",
|
|
createdBy: "agent:forged",
|
|
};
|
|
replacement.widgetsRegistry["new-card"] = {
|
|
status: "approved",
|
|
createdBy: "user",
|
|
approvedBy: "user",
|
|
approvedAt: "2026-01-02T00:00:00.000Z",
|
|
};
|
|
|
|
await tools.get("workspace_replace")?.execute("call-1", { doc: replacement });
|
|
|
|
const next = store.read();
|
|
// Provenance is immutable once stamped: the agent cannot relabel the seeded
|
|
// system tab as its own, nor claim its own new tab was authored by a human.
|
|
expect(next.tabs.find((tab) => tab.slug === "main")?.createdBy).toBe("system");
|
|
expect(next.tabs.find((tab) => tab.slug === "agent-tab")?.createdBy).toBe("agent:main");
|
|
expect(next.widgetsRegistry["approved-card"]).toEqual({
|
|
status: "approved",
|
|
createdBy: "user",
|
|
approvedBy: "user",
|
|
approvedAt: "2026-01-01T00:00:00.000Z",
|
|
});
|
|
// A registry name the agent invented is dropped: only workspace_widget_scaffold
|
|
// mints entries, so an operator can never be asked to approve a name whose code
|
|
// does not exist yet.
|
|
expect(next.widgetsRegistry["new-card"]).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
it("mutates widgets, reads data, and broadcasts one change per write", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const broadcast = vi.fn();
|
|
const tools = toolsByName(store, broadcast);
|
|
|
|
await tools.get("workspace_tab_create")?.execute("call-1", {
|
|
title: "Ops",
|
|
slug: "ops",
|
|
});
|
|
const addResult = details(
|
|
await tools.get("workspace_widget_add")?.execute("call-2", {
|
|
tab: "ops",
|
|
id: "notes",
|
|
kind: "builtin:markdown",
|
|
title: "Notes",
|
|
grid: { x: 0, y: 0, w: 4, h: 2 },
|
|
bindings: { value: { source: "static", value: "hello" } },
|
|
}),
|
|
);
|
|
expect(addResult.doc).toMatchObject({
|
|
tabs: [
|
|
expect.any(Object),
|
|
expect.objectContaining({
|
|
slug: "ops",
|
|
widgets: [expect.objectContaining({ id: "notes", title: "Notes" })],
|
|
}),
|
|
],
|
|
});
|
|
await tools.get("workspace_widget_move")?.execute("call-3", {
|
|
tab: "ops",
|
|
id: "notes",
|
|
grid: { x: 4, y: 0, w: 4, h: 2 },
|
|
});
|
|
const data = details(
|
|
await tools.get("workspace_data_read")?.execute("call-4", {
|
|
binding: { source: "static", value: { ok: true } },
|
|
}),
|
|
);
|
|
expect(data).toEqual({ data: { ok: true } });
|
|
expect(broadcast).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
it("scaffolds agent-authored widgets as pending with a standalone bridge template", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const tools = toolsByName(store);
|
|
|
|
await tools.get("workspace_widget_scaffold")?.execute("call-1", {
|
|
name: "agent-chart",
|
|
title: "Agent Chart",
|
|
});
|
|
|
|
const widgetDir = path.join(stateDir, "workspaces", "widgets", "agent-chart");
|
|
const htmlPath = path.join(widgetDir, "index.html");
|
|
const html = await fs.readFile(htmlPath, "utf8");
|
|
expect(html).toContain("workspace:ready");
|
|
expect(html).toContain("workspace:getData");
|
|
expect(html).toContain("function onData");
|
|
expect(html).not.toMatch(/https?:\/\//);
|
|
expect(store.read().widgetsRegistry["agent-chart"]).toMatchObject({
|
|
status: "pending",
|
|
createdBy: "agent:main",
|
|
});
|
|
|
|
await fs.writeFile(htmlPath, "custom implementation", "utf8");
|
|
await expect(
|
|
tools.get("workspace_widget_scaffold")?.execute("call-2", {
|
|
name: "agent-chart",
|
|
title: "Replacement",
|
|
}),
|
|
).rejects.toThrow("widget already exists");
|
|
expect(await fs.readFile(htmlPath, "utf8")).toBe("custom implementation");
|
|
expect(store.read().widgetsRegistry["agent-chart"]).toMatchObject({
|
|
status: "pending",
|
|
createdBy: "agent:main",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("workspace_widget_update applies a patch addressed by tab + id", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const tools = toolsByName(store);
|
|
|
|
// Regression: the tool passed its whole param record to the patch reader,
|
|
// whose allowlist rejects `tab`/`id` — so no call could ever succeed.
|
|
await tools.get("workspace_widget_update")?.execute("call-1", {
|
|
tab: "main",
|
|
id: "cost-today",
|
|
title: "Spend today",
|
|
collapsed: true,
|
|
});
|
|
|
|
const widget = store.read().tabs[0]?.widgets.find((entry) => entry.id === "cost-today");
|
|
expect(widget).toMatchObject({ title: "Spend today", collapsed: true });
|
|
});
|
|
});
|
|
|
|
it("workspace_data_read reports rpc bindings as client-resolved, not as a failure", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const tools = toolsByName(new WorkspaceStore({ stateDir }));
|
|
|
|
const result = await tools
|
|
.get("workspace_data_read")
|
|
?.execute("call-1", { binding: { source: "rpc", method: "usage.cost" } });
|
|
|
|
const [entry] = result?.content ?? [];
|
|
expect(entry?.type === "text" && entry.text).toContain("binding_client_resolved");
|
|
});
|
|
});
|
|
|
|
it("rejects scaffold names that would escape the widgets directory", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const tools = toolsByName(store);
|
|
|
|
await expect(
|
|
tools.get("workspace_widget_scaffold")?.execute("call-1", { name: ".." }),
|
|
).rejects.toThrow("widget name is invalid");
|
|
await expect(fs.stat(path.join(stateDir, "workspaces", "widget.json"))).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
it("shares one store between tool writes and CLI reads", async () => {
|
|
await withTempStateDir(async (stateDir) => {
|
|
const store = new WorkspaceStore({ stateDir });
|
|
const methods = new Map<string, Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1]>();
|
|
registerWorkspaceGatewayMethods({
|
|
api: {
|
|
registerGatewayMethod: vi.fn((method: string, handler) => methods.set(method, handler)),
|
|
} as unknown as OpenClawPluginApi,
|
|
store,
|
|
});
|
|
gatewayRuntime.callGatewayFromCli.mockImplementation(
|
|
async (method: string, _opts: unknown, params: unknown) => {
|
|
const respond = vi.fn();
|
|
await methods.get(method)?.({
|
|
params: params ?? {},
|
|
respond,
|
|
context: { broadcast: vi.fn() },
|
|
} as never);
|
|
const [ok, result, error] = respond.mock.calls[0] ?? [];
|
|
if (ok) {
|
|
return result;
|
|
}
|
|
throw new Error(error?.message ?? "gateway error");
|
|
},
|
|
);
|
|
const tools = toolsByName(store);
|
|
await tools.get("workspace_tab_create")?.execute("call-1", {
|
|
title: "Tool Tab",
|
|
slug: "tool-tab",
|
|
});
|
|
|
|
const program = new Command();
|
|
program.exitOverride();
|
|
program.configureOutput({ writeErr: () => {}, writeOut: () => {} });
|
|
registerWorkspaceCli({ program, stateDir });
|
|
const chunks: string[] = [];
|
|
const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => {
|
|
chunks.push(String(chunk));
|
|
return true;
|
|
});
|
|
try {
|
|
await program.parseAsync(["workspaces", "tabs", "list", "--json"], { from: "user" });
|
|
} finally {
|
|
write.mockRestore();
|
|
}
|
|
expect(JSON.parse(chunks.join(""))).toMatchObject({
|
|
tabs: [expect.any(Object), expect.objectContaining({ slug: "tool-tab" })],
|
|
});
|
|
});
|
|
});
|
|
});
|