返回 CodeWhale
app-script.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / app-script.test.mjs
1 // app_script: the programmatic interface into apps with a scripting
2 // dictionary. Local-computer only — a remote channel must never become a
3 // shell, so ssh/hdc computers refuse before dispatch and the remote agent
4 // refuses again at its own handler boundary.
5 import { test } from "node:test";
6 import assert from "node:assert/strict";
7 import fs from "node:fs";
8 import os from "node:os";
9 import path from "node:path";
10 import url from "node:url";
11 import { spawn } from "node:child_process";
12 import { handle } from "../src/app-handler.mjs";
13
14 const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
15
16 async function boot(t, env = {}) {
17 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-script-"));
18 const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-script-rec-"));
19 const child = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
20 env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir, CODEWHALE_CU_APP: "off", ...env },
21 stdio: ["pipe", "pipe", "pipe"],
22 });
23 t.after(() => { try { child.stdin.end(); } catch {} child.kill("SIGTERM"); fs.rmSync(stateDir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); });
24 let buf = "";
25 const pending = new Map();
26 let nextId = 1;
27 child.stdout.on("data", (c) => {
28 buf += c.toString();
29 let i;
30 while ((i = buf.indexOf("\n")) !== -1) {
31 const line = buf.slice(0, i).trim();
32 buf = buf.slice(i + 1);
33 if (!line) continue;
34 const msg = JSON.parse(line);
35 if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
36 }
37 });
38 const rpc = (method, params) => {
39 const id = nextId++;
40 return new Promise((resolve, reject) => {
41 const timer = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000);
42 pending.set(id, (msg) => { clearTimeout(timer); resolve(msg); });
43 child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
44 });
45 };
46 const tool = async (name, args = {}) => JSON.parse((await rpc("tools/call", { name, arguments: args })).result.content[0].text);
47 return { rpc, tool };
48 }
49
50 test("tools/list advertises app_script with a required script", async (t) => {
51 const s = await boot(t);
52 const tools = (await s.rpc("tools/list", {})).result.tools;
53 const def = tools.find((x) => x.name === "app_script");
54 assert.ok(def, "app_script must be advertised");
55 assert.deepEqual(def.inputSchema.required, ["script"]);
56 assert.equal(def.annotations?.readOnlyHint, false, "scripting mutates apps; hosts must not treat it as read-only");
57 });
58
59 test("app_script runs AppleScript and JXA on the local computer", { skip: process.platform !== "darwin" }, async (t) => {
60 const s = await boot(t);
61 const as = await s.tool("app_script", { script: 'return "whole computer"' });
62 assert.equal(as.ok, true);
63 assert.equal(as.result, "whole computer");
64 const jxa = await s.tool("app_script", { script: '"ok".toUpperCase()', language: "javascript" });
65 assert.equal(jxa.ok, true);
66 assert.equal(jxa.result, "OK");
67 assert.equal(jxa.language, "javascript");
68 });
69
70 test("app_script failures are typed, never opaque", { skip: process.platform !== "darwin" }, async (t) => {
71 const s = await boot(t);
72 const bad = await s.tool("app_script", { script: "this is not applescript at all" });
73 assert.equal(bad.ok, false);
74 assert.equal(bad.error.code, "script_error");
75 assert.match(bad.error.message, /syntax error|expected/i);
76 for (const [args, match] of [
77 [{ script: " " }, /non-empty script/],
78 [{ script: "return 1", language: "perl" }, /language/],
79 [{ script: "return 1", timeout: 0 }, /timeout/],
80 [{ script: "return 1", timeout: 500 }, /timeout/],
81 ]) {
82 const r = await s.tool("app_script", args);
83 assert.equal(r.error?.code, "bad_args", JSON.stringify(args));
84 assert.match(r.error.message, match);
85 }
86 });
87
88 test("app_script is refused on remote computers before any dispatch", async (t) => {
89 const s = await boot(t);
90 const reg = await s.tool("computer", { action: "register", id: "faraway", transport: "ssh", host: "192.0.2.1", installAgent: false });
91 assert.equal(reg.ok, true);
92 const r = await s.tool("app_script", { script: "return 1", computer: "faraway" });
93 assert.equal(r.ok, false);
94 assert.equal(r.error.code, "unsupported_on_transport");
95 });
96
97 test("the remote agent refuses app_script at its own boundary", async () => {
98 const r = await handle({ tool: "app_script", args: { script: "return 1" } }, { computerId: "remote", sessionId: "ssh-serve" });
99 assert.equal(r.ok, false);
100 assert.equal(r.error.code, "unsupported_on_transport");
101 });
102
103 test("the local handler runs app_script through the normal session machinery", { skip: process.platform !== "darwin" }, async () => {
104 const r = await handle({ tool: "app_script", args: { script: 'return "via handler"' } }, { computerId: "local", sessionId: "test-script" });
105 assert.equal(r.ok, true);
106 assert.equal(r.tool, "app_script");
107 assert.equal(r.data.result, "via handler");
108 });
109
110 test("a read-only grant never advertises or calls app_script", async (t) => {
111 const s = await boot(t, { CODEWHALE_CU_GRANT: "read-only" });
112 const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name);
113 assert.ok(!names.includes("app_script"));
114 assert.equal((await s.tool("app_script", { script: "return 1" })).error?.code, "not_granted");
115 });
116
117 test("a named grant admits app_script exactly", { skip: process.platform !== "darwin" }, async (t) => {
118 const s = await boot(t, { CODEWHALE_CU_GRANT: "app_script" });
119 const names = (await s.rpc("tools/list", {})).result.tools.map((x) => x.name);
120 assert.ok(names.includes("app_script"));
121 assert.equal((await s.tool("app_script", { script: "return 42" })).result, "42");
122 });
123
124 test("the kill switch stops scripting too", async (t) => {
125 const s = await boot(t);
126 assert.equal((await s.tool("stop_computer_control", {})).ok, true);
127 const r = await s.tool("app_script", { script: "return 1" });
128 assert.equal(r.ok, false);
129 assert.equal(r.error.code, "control_stopped");
130 });
131
131 lines Plain Text