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