返回 CodeWhale
session-lifecycle.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / session-lifecycle.test.mjs
1 // Exercise the actual helper socket and MCP lifecycle with recording backends.
2 // Child-process cancellation must prevent delayed input, not just hide replies.
3 import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
4 import { test, before, after } from "node:test";
5 import assert from "node:assert/strict";
6 import fs from "node:fs";
7 import os from "node:os";
8 import path from "node:path";
9 import { fileURLToPath } from "node:url";
10 import { spawn } from "node:child_process";
11
12 const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-session-"));
14 const log = path.join(dir, "calls.jsonl");
15 const env = {
16 ...process.env,
17 CODEWHALE_CU_STATE_DIR: dir,
18 CODEWHALE_CU_APP_WARM: "off",
19 CODEWHALE_CU_TEST_BACKEND: path.join(ROOT, "tests/fixtures/session-backend.mjs"),
20 CU_SESSION_CALLS: log,
21 };
22 delete env.CODEWHALE_CU_APP;
23 delete env.CODEWHALE_CU_APP_SOCKET;
24 delete env.CODEWHALE_CU_TEST_REMOTE;
25 process.env.CODEWHALE_CU_STATE_DIR = dir;
26 delete process.env.CODEWHALE_CU_APP_SOCKET;
27 const { appRequest, appSessionRequest, openAppSession, hello } = await import("../src/app-socket.mjs");
28 const { appExec } = await import("../src/transport.mjs");
29 let daemon;
30 let daemonErrors = "";
31 const hosts = new Set();
32 const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33 const calls = () => fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse) : [];
34 async function until(check, timeoutMs = 3000) {
35 const end = Date.now() + timeoutMs;
36 do {
37 if (await check()) return;
38 await pause(20);
39 } while (Date.now() < end);
40 assert.fail(`Condition did not become true within ${timeoutMs}ms`);
41 }
42
43 function mcp() {
44 const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "pipe"] });
45 child.stdin.write(hostKeysLine());
46 hosts.add(child);
47 const replies = new Map();
48 let buf = "";
49 let id = 0;
50 child.stderr.on("data", () => {});
51 child.stdout.on("data", (chunk) => {
52 buf += chunk.toString();
53 let nl;
54 while ((nl = buf.indexOf("\n")) !== -1) {
55 const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
56 const msg = JSON.parse(line);
57 replies.set(msg.id, msg);
58 }
59 });
60 const send = (method, params, requestId) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...(requestId === undefined ? {} : { id: requestId }), method, params: attestParams(method, params) }) + "\n");
61 const start = (name, args = {}) => { const requestId = ++id; send("tools/call", { name, arguments: args }, requestId); return requestId; };
62 const response = async (requestId) => {
63 await until(() => replies.has(requestId));
64 const msg = replies.get(requestId);
65 assert.ok(msg.result, JSON.stringify(msg.error));
66 return JSON.parse(msg.result.content[0].text);
67 };
68 return { child, replies, start, response, cancel: (requestId) => send("notifications/cancelled", { requestId }), tool: (name, args) => response(start(name, args)) };
69 }
70
71 async function closeHost(host) {
72 const exit = new Promise((resolve) => host.child.once("exit", resolve));
73 host.child.stdin.end();
74 await exit;
75 hosts.delete(host.child);
76 }
77
78 before(async () => {
79 daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env: {...env, CODEWHALE_CU_CONTROL_FD: "3"}, stdio: ["ignore", "ignore", "pipe", "overlapped"] });
80 daemon.stderr.on("data", (data) => { daemonErrors += data; });
81 await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); });
82 });
83 after(async () => {
84 for (const child of hosts) child.kill("SIGTERM");
85 if (daemon?.exitCode === null) {
86 const exit = new Promise((resolve) => daemon.once("exit", resolve));
87 daemon.kill("SIGTERM");
88 await exit;
89 }
90 fs.rmSync(dir, { recursive: true, force: true });
91 });
92
93 test("helper requires session identities while compatibility probes remain available", async () => {
94 assert.equal((await hello()).sessionProtocol, 2);
95 assert.equal((await appRequest({ tool: "platform" })).ok, true);
96 for (const sessionId of [undefined, "", "bad:session", "x".repeat(129)]) {
97 const reply = await appRequest({ tool: "type", args: { text: "must not type" }, sessionId });
98 assert.equal(reply.error.code, "session_required");
99 }
100 });
101
102 test("helper accepts actions only while their original socket owner is alive", async () => {
103 const sessionId = "lease-owner";
104 const lease = await openAppSession(sessionId);
105 assert.equal((await appRequest({ tool: "open_session", sessionId })).error.code, "session_owned");
106 for (const leaseToken of [undefined, "another-owner"]) {
107 assert.equal((await appRequest({ tool: "get_app_state", sessionId, leaseToken, args: { app_ref: { name: "Spoofed owner" } } })).error.code, "session_owner_required");
108 }
109 assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Lease owner" } } })).ok, true);
110 lease.socket.destroy();
111 await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Lease owner"));
112 assert.equal((await appRequest({ tool: "type", sessionId, leaseToken: lease.token, args: { text: "stale lease" } })).error.code, "session_owner_required");
113 assert.equal((await appSessionRequest({ tool: "probe", sessionId })).ok, true, "a dropped owner socket re-leases transparently instead of bricking the session");
114 assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Re-leased owner" } } })).data.name, "Re-leased owner");
115 assert.ok(!calls().some((item) => item.appName === "Spoofed owner" || item.text === "stale lease"));
116 });
117
118 test("separate sessions keep their own bound apps and closed sessions cannot revive", async () => {
119 const a = appExec({}, "binding-a");
120 const b = appExec({}, "binding-b");
121 await a.remote({ tool: "get_app_state", args: { app_ref: { name: "Editor A" } } });
122 assert.equal((await b.remote({ tool: "type", args: { text: "unbound" } })).error.code, "target_app_required");
123 await b.remote({ tool: "get_app_state", args: { app_ref: { name: "Editor B" } } });
124 assert.equal((await a.remote({ tool: "type", args: { text: "first" } })).data.appName, "Editor A");
125 assert.equal((await b.remote({ tool: "type", args: { text: "second" } })).data.appName, "Editor B");
126 assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "binding-a" })).ok, true);
127 await appExec({}, "unrelated-new-session").remote({ tool: "probe" });
128 await assert.rejects(a.remote({ tool: "get_app_state", args: { app_ref: { name: "Revived" } } }), (err) => err.code === "app_session_closed");
129 assert.equal((await b.remote({ tool: "type", args: { text: "still alive" } })).ok, true);
130 });
131
132 test("list_sessions names live sessions content-free and drops closed ones", async () => {
133 const a = appExec({}, "ls-a");
134 const b = appExec({}, "ls-b");
135 await a.remote({ tool: "probe" });
136 await b.remote({ tool: "probe" });
137 const both = await a.remote({ tool: "list_sessions" });
138 assert.equal(both.ok, true);
139 assert.equal(both.data.control, "ready");
140 assert.ok(both.data.count >= 2, `both live sessions are listed (${both.data.count})`);
141 for (const s of both.data.sessions) {
142 assert.ok(s.target === null || (typeof s.target === "object" && Number.isInteger(s.target.pid)), "targets are app identity or null, never task text");
143 assert.ok(typeof s.ageSec === "number" && typeof s.inputHeld === "boolean" && typeof s.action !== "undefined");
144 }
145 const forged = await appRequest({ tool: "list_sessions", sessionId: "ls-a", leaseToken: "forged" });
146 assert.equal(forged.ok, false);
147 assert.equal(forged.error.code, "session_owner_required", "the registry needs the live owner lease, not a session id alone");
148 assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "ls-b" })).ok, true);
149 const one = await a.remote({ tool: "list_sessions" });
150 assert.equal(one.data.count, both.data.count - 1, "a closed session leaves the registry");
151 });
152
153 test("a capability grant narrows the daemon lease, and cleanup is never blocked", async () => {
154 const prior = process.env.CODEWHALE_CU_GRANT;
155 process.env.CODEWHALE_CU_GRANT = "probe";
156 try {
157 await openAppSession("grant-a");
158 assert.equal((await appSessionRequest({ tool: "probe", sessionId: "grant-a" })).ok, true);
159 const refused = await appSessionRequest({ tool: "get_app_state", sessionId: "grant-a", args: { app_ref: { name: "Nope" } } });
160 assert.equal(refused.ok, false);
161 assert.equal(refused.error.code, "not_granted", "the daemon refuses ungranted tools even if the server asked");
162 assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "grant-a" })).ok, true, "cleanup must never be blocked by a grant");
163 process.env.CODEWHALE_CU_GRANT = "";
164 await openAppSession("grant-b");
165 assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId: "grant-b", args: { app_ref: { name: "Open" } } })).ok, true, "a new session without a grant is unrestricted");
166 await appSessionRequest({ tool: "close_session", sessionId: "grant-b" });
167 } finally {
168 if (prior === undefined) delete process.env.CODEWHALE_CU_GRANT; else process.env.CODEWHALE_CU_GRANT = prior;
169 }
170 });
171
172 test("read-only grants survive the wire-name translation (request_access travels as probe)", async () => {
173 const prior = process.env.CODEWHALE_CU_GRANT;
174 process.env.CODEWHALE_CU_GRANT = "read-only";
175 try {
176 await openAppSession("grant-ro");
177 assert.equal((await appSessionRequest({ tool: "probe", sessionId: "grant-ro" })).ok, true, "the grant must cover the transport name the daemon actually sees");
178 assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId: "grant-ro", args: { app_ref: { name: "Visible" } } })).ok, true, "observation tools stay granted");
179 const refused = await appSessionRequest({ tool: "left_click", sessionId: "grant-ro", args: { target: { type: "coordinate", x: 1, y: 1 } } });
180 assert.equal(refused.ok, false);
181 assert.equal(refused.error.code, "not_granted");
182 await appSessionRequest({ tool: "close_session", sessionId: "grant-ro" });
183 } finally {
184 if (prior === undefined) delete process.env.CODEWHALE_CU_GRANT; else process.env.CODEWHALE_CU_GRANT = prior;
185 }
186 });
187
188 test("disconnect cancels the child process and a queued request never posts input", async () => {
189 const active = new AbortController();
190 const queued = new AbortController();
191 const held = appSessionRequest({ tool: "hold_key", sessionId: "socket-active", args: { text: "socket-cancel" } }, { signal: active.signal });
192 const heldRejection = assert.rejects(held, (err) => err.code === "cancelled");
193 await until(() => calls().some((item) => item.method === "child_started" && item.text === "socket-cancel"));
194 const waiting = appSessionRequest({ tool: "get_app_state", sessionId: "socket-queued", args: { app_ref: { name: "Cancelled queue" } } }, { signal: queued.signal });
195 const queuedRejection = assert.rejects(waiting, (err) => err.code === "cancelled");
196 queued.abort();
197 active.abort();
198 await Promise.all([heldRejection, queuedRejection]);
199 await until(() => calls().some((item) => item.method === "child_released"));
200 assert.equal((await appExec({}, "socket-check").remote({ tool: "probe" })).ok, true);
201 assert.ok(!calls().some((item) => item.appName === "Cancelled queue"));
202 assert.ok(!calls().some((item) => item.method === "late_input"));
203 });
204
205 test("MCP cancellation drains input, keeps the host alive, and isolates a second host", async () => {
206 const a = mcp();
207 const b = mcp();
208 await a.tool("consent", { action: "allow", app: "Host A" });
209 await a.tool("get_app_state", { app_ref: { name: "Host A" } });
210 assert.equal((await b.tool("type", { text: "unbound host B" })).error.code, "target_app_required");
211 await b.tool("consent", { action: "allow", app: "Host B" });
212 await b.tool("get_app_state", { app_ref: { name: "Host B" } });
213 const id = a.start("hold_key", { text: "mcp-cancel", duration: 10 });
214 await until(() => calls().some((item) => item.method === "child_started" && item.text === "mcp-cancel"));
215 a.cancel(id);
216 assert.equal((await a.tool("type", { text: "after cancellation" })).ok, true);
217 assert.ok(!a.replies.has(id), "cancelled MCP request must not reply");
218 assert.equal((await b.tool("type", { text: "other host" })).appName, "Host B");
219 await closeHost(a);
220 assert.equal((await b.tool("type", { text: "after other host exits" })).ok, true);
221 await closeHost(b);
222 });
223
224 test("stop cancels active and queued actions, releases held input, and leaves probes usable", async () => {
225 const host = mcp();
226 await host.tool("consent", { action: "allow", app: "Stopped host" });
227 await host.tool("get_app_state", { app_ref: { name: "Stopped host" } });
228 await host.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } });
229 const hold = host.start("hold_key", { text: "mcp-stop", duration: 10 });
230 await until(() => calls().some((item) => item.method === "child_started" && item.text === "mcp-stop"));
231 const queued = host.start("type", { text: "must never arrive after stop" });
232 const stopped = await host.tool("stop_computer_control");
233 assert.equal(stopped.ok, true, JSON.stringify(stopped));
234 assert.equal(stopped.inputReleased, true);
235 assert.equal((await host.response(queued)).error.code, "control_stopped");
236 assert.equal((await host.response(hold)).ok, false);
237 assert.ok(calls().some((item) => item.method === "release_input" && item.appName === "Stopped host" && item.pointerDown));
238 assert.ok(!calls().some((item) => item.text === "must never arrive after stop"));
239 assert.equal((await host.tool("request_access")).ok, true);
240 assert.equal((await host.tool("type", { text: "after stop" })).error.code, "control_stopped");
241 assert.ok(!calls().some((item)=>item.method==="session_closed"&&item.appName==="Stopped host"),"stop releases input but does not close the session's other owned resources");
242 await closeHost(host);
243 assert.ok(calls().some((item)=>item.method==="session_closed"&&item.appName==="Stopped host"));
244 });
245
246 test("another MCP host cannot redirect the selected computer", async () => {
247 const a = mcp();
248 const b = mcp();
249 await a.tool("consent", { action: "allow", app: "Local host A" });
250 await a.tool("get_app_state", { app_ref: { name: "Local host A" } });
251 await b.tool("computer_register", { computer: "session-test-pad", transport: "hdc" });
252 await b.tool("computer_switch", { computer: "session-test-pad" });
253 assert.equal((await a.tool("computer_list")).active, "local");
254 assert.equal((await b.tool("computer_list")).active, "session-test-pad");
255 const input = await a.tool("type", { text: "stay on local host A" });
256 assert.equal(input.ok, true, JSON.stringify(input));
257 assert.equal(input.computer.id, "local");
258 await b.tool("computer_remove", { computer: "session-test-pad" });
259 await closeHost(a);
260 await closeHost(b);
261 });
262
263 test("retiring a helper-backed local alias closes only that MCP host's session", async () => {
264 const retiring = mcp();
265 const survivor = mcp();
266 let retiringErrors = "";
267 let survivorErrors = "";
268 retiring.child.stderr.on("data", chunk => { retiringErrors += chunk; });
269 survivor.child.stderr.on("data", chunk => { survivorErrors += chunk; });
270 const alias = "retiring-helper-alias";
271 assert.equal((await retiring.tool("computer_register", { computer: alias, transport: "local" })).ok, true);
272 await retiring.tool("consent", { action: "allow", app: "Retiring alias owner", computer: alias });
273 assert.equal((await retiring.tool("get_app_state", { computer: alias, app_ref: { name: "Retiring alias owner" } })).ok, true);
274 await survivor.tool("consent", { action: "allow", app: "Alias retirement survivor" });
275 assert.equal((await survivor.tool("get_app_state", { app_ref: { name: "Alias retirement survivor" } })).ok, true);
276 const owner = calls().find(item => item.method === "get_app_state" && item.appName === "Retiring alias owner").instance;
277 const other = calls().find(item => item.method === "get_app_state" && item.appName === "Alias retirement survivor").instance;
278 assert.notEqual(owner, other);
279 assert.equal((await retiring.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } })).ok, true);
280 assert.equal((await survivor.tool("type", { text: "blocked by retiring owner" })).error.code, "input_busy");
281
282 // Registration only changes the private fixture catalog. No HDC observation
283 // or backend operation is requested, so no device command can run here.
284 const registered = await retiring.tool("computer_register", { computer: alias, transport: "hdc", target: "unobserved-fixture-device" });
285 assert.equal(registered.ok, true, JSON.stringify(registered));
286 assert.equal(registered.registered.transport, "hdc");
287 assert.ok(calls().some(item => item.instance === owner && item.method === "release_input" && item.pointerDown), "retiring the route releases the old helper's held pointer");
288 assert.ok(calls().some(item => item.instance === owner && item.method === "session_closed"), "retiring the route closes its helper backend");
289 assert.ok(!calls().some(item => item.instance === other && item.method === "session_closed"), "the second MCP host keeps its helper session");
290
291 for (const [name, args] of [
292 ["request_access", {}],
293 ["type", { text: "must not revive retired helper" }],
294 ]) {
295 const reply = await retiring.tool(name, { computer: "local", ...args });
296 assert.equal(reply.ok, false);
297 assert.equal(reply.error.code, "app_session_closed");
298 assert.match(reply.error.message, /new MCP session/);
299 }
300 assert.ok(!calls().some(item => item.text === "must not revive retired helper"));
301 assert.equal((await survivor.tool("type", { text: "survives alias retirement" })).appName, "Alias retirement survivor");
302 assert.equal((await retiring.tool("computer_remove", { computer: alias })).ok, true);
303
304 const retiringClosed = new Promise(resolve => retiring.child.once("close", resolve));
305 await closeHost(retiring);
306 await retiringClosed;
307 assert.equal(retiringErrors, "", "shutdown must not retry an already closed helper as a cleanup failure");
308 assert.equal((await survivor.tool("type", { text: "survives retiring host shutdown" })).appName, "Alias retirement survivor");
309 const survivorClosed = new Promise(resolve => survivor.child.once("close", resolve));
310 await closeHost(survivor);
311 await survivorClosed;
312 assert.equal(survivorErrors, "");
313 });
314
315 test("MCP forced exit releases idle held input without waiting for another client", async () => {
316 const dead = mcp();
317 const survivor = mcp();
318 await dead.tool("consent", { action: "allow", app: "Killed idle host" });
319 await dead.tool("get_app_state", { app_ref: { name: "Killed idle host" } });
320 await survivor.tool("consent", { action: "allow", app: "Surviving host" });
321 await survivor.tool("get_app_state", { app_ref: { name: "Surviving host" } });
322 await dead.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } });
323 assert.equal((await survivor.tool("type", {text:"must wait for held pointer"})).error.code,"input_busy");
324 assert.equal((await survivor.tool("request_access")).ok,true,"observation stays available while another session holds input");
325 const exit = new Promise((resolve) => dead.child.once("exit", resolve));
326 dead.child.kill("SIGKILL");
327 await exit;
328 hosts.delete(dead.child);
329 await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Killed idle host" && item.pointerDown));
330 assert.equal((await survivor.tool("type", { text: "surviving binding" })).appName, "Surviving host");
331 await closeHost(survivor);
332 });
333
334 test("MCP forced exit cancels its active child before delayed input can post", async () => {
335 const host = mcp();
336 await host.tool("consent", { action: "allow", app: "Killed active host" });
337 await host.tool("get_app_state", { app_ref: { name: "Killed active host" } });
338 host.start("hold_key", { text: "killed-active", duration: 10 });
339 await until(() => calls().some((item) => item.method === "child_started" && item.text === "killed-active"));
340 const instance = calls().find((item) => item.method === "child_started" && item.text === "killed-active").instance;
341 const exit = new Promise((resolve) => host.child.once("exit", resolve));
342 host.child.kill("SIGKILL");
343 await exit;
344 hosts.delete(host.child);
345 await until(() => calls().some((item) => item.method === "child_released" && item.instance === instance));
346 await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Killed active host"));
347 assert.ok(!calls().some((item) => item.method === "late_input"));
348 });
349
350 test("an app update re-leases live sessions transparently; an absent app still fails without bricking", async () => {
351 const sessionId = "upgrade-survivor";
352 assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor" } } })).ok, true);
353 const exit = new Promise((resolve) => daemon.once("exit", resolve));
354 daemon.kill("SIGTERM");
355 await exit;
356 // Mid-update the app is genuinely absent: the request fails, and that
357 // failure is not cached against the session.
358 await assert.rejects(appSessionRequest({ tool: "probe", sessionId }), (err) => err.code === "app_unavailable");
359 daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env: {...env, CODEWHALE_CU_CONTROL_FD: "3"}, stdio: ["ignore", "ignore", "pipe", "overlapped"] });
360 daemon.stderr.on("data", (data) => { daemonErrors += data; });
361 await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); });
362 const reply = await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor again" } } });
363 assert.equal(reply.ok, true, JSON.stringify(reply));
364 assert.equal(reply.data.name, "Survivor again", "the same session id works on the replacement daemon without a host reload");
365 });
366
367 test("MCP EOF releases a completed mouse-down and helper shutdown aborts active children", async () => {
368 const host = mcp();
369 await host.tool("consent", { action: "allow", app: "Disconnected host" });
370 await host.tool("get_app_state", { app_ref: { name: "Disconnected host" } });
371 await host.tool("left_mouse_down", { target: { type: "coordinate", space: "screen", x: 10, y: 10 } });
372 await closeHost(host);
373 assert.ok(calls().some((item) => item.method === "release_input" && item.appName === "Disconnected host" && item.pointerDown));
374 const held = appSessionRequest({ tool: "hold_key", sessionId: "helper-exit", args: { text: "helper-exit" } });
375 const result = held.catch((err) => ({ error: err.code }));
376 await until(() => calls().some((item) => item.method === "child_started" && item.text === "helper-exit"));
377 const instance = calls().find((item) => item.method === "child_started" && item.text === "helper-exit").instance;
378 const exit = new Promise((resolve) => daemon.once("exit", resolve));
379 daemon.stdio[3].destroy(); // Closing the human owner is graceful on every OS.
380 assert.equal(await exit, 0, daemonErrors);
381 await result;
382 assert.ok(calls().some((item) => item.method === "child_released" && item.instance === instance));
383 assert.ok(!calls().some((item) => item.method === "late_input"));
384 });
385
385 lines Plain Text