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