Files
openclaw/extensions/workspaces/src/cli.test.ts
wangmiao0668000666 72521492be fix(workspaces): reject non-decimal --grid segments via strict parser (#107478)
* fix(workspaces): reject non-decimal --grid segments via strict parser

Swap bare Number() in parseWorkspaceGrid for parseStrictNonNegativeInteger
from openclaw/plugin-sdk/number-runtime, so JS-coercion forms (hex,
exponent, octal, binary, negative, empty segment) are rejected at the
CLI --grid parser boundary instead of being silently persisted into
Widget.grid. Same owner-boundary shape as #106384 (google-meet --since)
and #106926 (onepassword --limit).

Co-Authored-By: Claude <noreply@anthropic.com>

* test(workspaces): prove persisted grid coercions

Co-authored-by: wangmiao0668000666 <wangmiao0668000666@users.noreply.github.com>

---------

Co-authored-by: wangmiao0668000666 <wangmiao0668000666@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-07-15 01:18:02 -07:00

428 lines
13 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 { beforeEach, describe, expect, it, vi } from "vitest";
import { registerWorkspaceCli } from "./cli.js";
import { registerWorkspaceGatewayMethods } from "./gateway.js";
import { WorkspaceStore } from "./store.js";
const gatewayRuntime = vi.hoisted(() => ({
callGatewayFromCli: vi.fn(),
}));
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,
};
});
type RegisteredMethod = {
handler: Parameters<OpenClawPluginApi["registerGatewayMethod"]>[1];
};
async function withTempStateDir<T>(run: (stateDir: string) => Promise<T>): Promise<T> {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-cli-"));
try {
return await run(stateDir);
} finally {
await fs.rm(stateDir, { recursive: true, force: true });
}
}
function createProgram(stateDir?: string): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeErr: () => {},
writeOut: () => {},
});
registerWorkspaceCli({ program, stateDir });
return program;
}
function captureStdout(run: () => Promise<void>): Promise<string> {
const chunks: string[] = [];
const write = vi.spyOn(process.stdout, "write").mockImplementation((chunk): boolean => {
chunks.push(String(chunk));
return true;
});
return run()
.then(() => chunks.join(""))
.finally(() => {
write.mockRestore();
});
}
function installGatewayMock(store: WorkspaceStore) {
const methods = new Map<string, RegisteredMethod>();
const api = {
registerGatewayMethod: vi.fn((method: string, handler: RegisteredMethod["handler"]) => {
methods.set(method, { handler });
}),
} as unknown as OpenClawPluginApi;
registerWorkspaceGatewayMethods({ api, store });
const broadcast = vi.fn();
gatewayRuntime.callGatewayFromCli.mockImplementation(
async (method: string, _opts: unknown, params: unknown) => {
const entry = methods.get(method);
if (!entry) {
throw new Error(`unknown method: ${method}`);
}
const respond = vi.fn();
await entry.handler({
params: params ?? {},
respond,
context: { broadcast },
} as never);
const [ok, result, error] = respond.mock.calls[0] ?? [];
if (ok) {
return result;
}
throw new Error(error?.message ?? "gateway error");
},
);
return { broadcast };
}
describe("workspace CLI", () => {
beforeEach(() => {
gatewayRuntime.callGatewayFromCli.mockReset();
});
it("round-trips tabs and widgets through the L1 gateway methods", async () => {
await withTempStateDir(async (stateDir) => {
const store = new WorkspaceStore({ stateDir });
const { broadcast } = installGatewayMock(store);
const program = createProgram(stateDir);
await captureStdout(async () => {
await program.parseAsync(
["workspaces", "tabs", "create", "--title", "Finance Ops", "--slug", "finance"],
{ from: "user" },
);
});
await captureStdout(async () => {
await program.parseAsync(
[
"workspaces",
"widgets",
"add",
"--tab",
"finance",
"--kind",
"builtin:stat-card",
"--title",
"Q3 Revenue",
"--binding",
"value=file:q3.json#/revenue",
"--binding",
"rows=rpc:sessions.list",
"--binding",
'summary=static:{"ok":true}',
"--props",
'{"format":"usd"}',
],
{ from: "user" },
);
});
const output = await captureStdout(async () => {
await program.parseAsync(["workspaces", "widgets", "list", "--tab", "finance", "--json"], {
from: "user",
});
});
expect(JSON.parse(output)).toMatchObject({
widgets: [
{
title: "Q3 Revenue",
grid: { x: 0, y: 0, w: 4, h: 2 },
bindings: {
value: { source: "file", path: "q3.json", pointer: "/revenue" },
rows: { source: "rpc", method: "sessions.list" },
summary: { source: "static", value: { ok: true } },
},
props: { format: "usd" },
},
],
});
expect(gatewayRuntime.callGatewayFromCli).toHaveBeenCalledWith(
"workspaces.widget.add",
expect.any(Object),
expect.objectContaining({ tab: "finance" }),
expect.objectContaining({ mode: "cli", scopes: ["operator.write", "operator.read"] }),
);
expect(broadcast).toHaveBeenCalledWith("plugin.workspaces.changed", {
workspaceVersion: 3,
changedTabSlug: "finance",
actor: "user",
});
for (const binding of ["file:q3.json", "value=static:{bad", "value=command:date"]) {
await expect(
program.parseAsync(
[
"workspaces",
"widgets",
"add",
"--tab",
"finance",
"--kind",
"builtin:stat-card",
"--binding",
binding,
],
{ from: "user" },
),
).rejects.toThrow();
}
});
});
it("uses workspace get/replace for layout set and rejects invalid local docs", async () => {
await withTempStateDir(async (stateDir) => {
const store = new WorkspaceStore({ stateDir });
installGatewayMock(store);
const program = createProgram(stateDir);
const before = store.read();
const replacement = structuredClone(before);
replacement.tabs[0]!.title = "Renamed";
const filePath = path.join(stateDir, "workspace.json");
await fs.writeFile(filePath, JSON.stringify(replacement), "utf8");
await captureStdout(async () => {
await program.parseAsync(["workspaces", "layout", "set", "--file", filePath], {
from: "user",
});
});
expect(store.read().tabs[0]?.title).toBe("Renamed");
await fs.writeFile(filePath, JSON.stringify({ schemaVersion: 1 }), "utf8");
await expect(
program.parseAsync(["workspaces", "layout", "set", "--file", filePath], {
from: "user",
}),
).rejects.toThrow("workspaceVersion");
});
});
it("round-trips every remaining subcommand through the gateway", async () => {
await withTempStateDir(async (stateDir) => {
const store = new WorkspaceStore({ stateDir });
installGatewayMock(store);
const program = createProgram(stateDir);
const run = async (args: string[]) => {
await captureStdout(async () => {
await program.parseAsync(args, { from: "user" });
});
};
await run(["workspaces", "tabs", "create", "--title", "Ops", "--slug", "ops"]);
await run(["workspaces", "tabs", "hide", "ops"]);
await run(["workspaces", "tabs", "show", "ops"]);
await run(["workspaces", "tabs", "reorder", "ops", "main"]);
await run([
"workspaces",
"widgets",
"add",
"--tab",
"ops",
"--id",
"notes",
"--kind",
"builtin:markdown",
"--title",
"Notes",
"--grid",
"0,0,4,2",
]);
await run([
"workspaces",
"widgets",
"update",
"--tab",
"ops",
"--id",
"notes",
"--title",
"Updated Notes",
"--collapsed",
"true",
"--hidden",
"false",
]);
await run([
"workspaces",
"widgets",
"move",
"--tab",
"ops",
"--id",
"notes",
"--grid",
"4,0,4,2",
]);
await run(["workspaces", "tabs", "create", "--title", "Other", "--slug", "other"]);
await run(["workspaces", "widgets", "move", "--id", "notes", "--to-tab", "other"]);
await run(["workspaces", "widgets", "remove", "--tab", "other", "--id", "notes"]);
const layout = await captureStdout(async () => {
await program.parseAsync(["workspaces", "layout", "get", "--json"], { from: "user" });
});
expect(JSON.parse(layout)).toMatchObject({ doc: { tabs: expect.any(Array) } });
await run(["workspaces", "layout", "undo"]);
await run(["workspaces", "tabs", "delete", "other"]);
const doc = store.read();
expect(doc.prefs.tabOrder).toContain("ops");
expect(doc.tabs.some((tab) => tab.slug === "other")).toBe(false);
});
});
it("rejects invalid CLI inputs before or through gateway validation", async () => {
await withTempStateDir(async (stateDir) => {
const store = new WorkspaceStore({ stateDir });
installGatewayMock(store);
const program = createProgram(stateDir);
await captureStdout(async () => {
await program.parseAsync(
["workspaces", "tabs", "create", "--title", "Ops", "--slug", "ops"],
{
from: "user",
},
);
});
await expect(
program.parseAsync(["workspaces", "tabs", "reorder", "Bad"], { from: "user" }),
).rejects.toThrow("order[0] is invalid");
await expect(
program.parseAsync(
[
"workspaces",
"widgets",
"add",
"--tab",
"ops",
"--kind",
"builtin:markdown",
"--grid",
"bad",
],
{ from: "user" },
),
).rejects.toThrow("grid must be x,y,w,h");
await expect(
program.parseAsync(["workspaces", "widgets", "update", "--tab", "ops", "--id", "missing"], {
from: "user",
}),
).rejects.toThrow("at least one patch option is required");
await expect(
program.parseAsync(
[
"workspaces",
"widgets",
"move",
"--tab",
"ops",
"--id",
"missing",
"--grid",
"0,0,4,2",
"--to-tab",
"ops",
],
{ from: "user" },
),
).rejects.toThrow("not both");
await expect(
program.parseAsync(["workspaces", "widget-scaffold", "bad/name"], { from: "user" }),
).rejects.toThrow("widget name is invalid");
await expect(
program.parseAsync(["workspaces", "widget-scaffold", "."], { from: "user" }),
).rejects.toThrow("widget name is invalid");
});
});
it.each([
["0x1,0,4,2", "hex"],
["1e0,0,4,2", "exponent"],
["0o1,0,4,2", "octal literal"],
["0b1,0,4,2", "binary literal"],
[",0,4,2", "empty segment"],
])("rejects %s (%s) --grid input", async (grid, _label) => {
const program = createProgram();
await expect(
program.parseAsync(
[
"workspaces",
"widgets",
"add",
"--tab",
"ops",
"--kind",
"builtin:markdown",
"--grid",
grid,
],
{ from: "user" },
),
).rejects.toThrow("grid must be x,y,w,h");
expect(gatewayRuntime.callGatewayFromCli).not.toHaveBeenCalled();
});
it("scaffolds operator widgets as pending and approves them through the approvals method", async () => {
await withTempStateDir(async (stateDir) => {
const store = new WorkspaceStore({ stateDir });
installGatewayMock(store);
const program = createProgram(stateDir);
await captureStdout(async () => {
await program.parseAsync(
["workspaces", "widget-scaffold", "revenue-chart", "--title", "Revenue Chart"],
{ from: "user" },
);
});
const widgetDir = path.join(stateDir, "workspaces", "widgets", "revenue-chart");
const manifest = JSON.parse(await fs.readFile(path.join(widgetDir, "widget.json"), "utf8"));
const html = await fs.readFile(path.join(widgetDir, "index.html"), "utf8");
expect(manifest).toMatchObject({
schemaVersion: 1,
name: "revenue-chart",
title: "Revenue Chart",
entrypoint: "index.html",
});
expect(html).toContain("workspace:ready");
expect(html).toContain("workspace:getData");
expect(html).toContain("function onData");
expect(html).not.toMatch(/https?:\/\//);
// An operator-scaffolded widget is pending too: the CLI cannot be a way to
// mount agent-authored code without the approvals-scoped decision.
expect(store.read().widgetsRegistry["revenue-chart"]).toEqual({
status: "pending",
createdBy: "user",
});
await captureStdout(async () => {
await program.parseAsync(["workspaces", "widget-approve", "revenue-chart"], {
from: "user",
});
});
expect(store.read().widgetsRegistry["revenue-chart"]).toMatchObject({
status: "approved",
createdBy: "user",
approvedBy: "user",
});
});
});
});