返回 CodeWhale
transport.mjs
根目录 / crates / tui / plugins / computer-use / src / transport.mjs
1 // Transport: turn a registered computer into an executor.
2 // - local: the Codewhale Computer Use app when it is running or registered
3 // (it owns the OS permissions), otherwise spawn directly
4 // - ssh: run the codewhale-cu remote agent over ssh (args travel as base64 JSON,
5 // so no tool argument can ever become remote shell syntax)
6 // - hdc: HarmonyOS device over `hdc` shell / file push-pull
7 import { run, runOk, runInputLease, ExecError, currentSignal } from "./exec.mjs";
8 import { ensureApp, appSessionRequest } from "./app-socket.mjs";
9 import { spawn } from "node:child_process";
10 import crypto from "node:crypto";
11 import fs from "node:fs";
12 import path from "node:path";
13 import os from "node:os";
14 import url from "node:url";
15
16 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
17 export const PLUGIN_ROOT = path.resolve(__dirname, "..");
18 // A new MCP process always starts a fresh app/input/raster binding, even when
19 // the permission-owning desktop helper remains running across tasks.
20 export const SESSION_ID = crypto.randomUUID();
21 let usedApp = false;
22 let appSessionClosed = false;
23 export function closeAppSession({ releaseOnly = false } = {}) {
24 if (!usedApp || appSessionClosed) return Promise.resolve();
25 return appSessionRequest({ tool: releaseOnly ? "release_session_input" : "close_session", sessionId: SESSION_ID }, { timeoutMs: 2_500, signal: null }).then((reply) => {
26 if (!reply?.ok) throw Object.assign(new ExecError(reply?.error?.message ?? "Computer input cleanup failed"), { code: reply?.error?.code ?? "input_release_failed" });
27 if (!releaseOnly) appSessionClosed = true;
28 });
29 }
30
31 export function b64(obj) {
32 return Buffer.from(JSON.stringify(obj), "utf8").toString("base64");
33 }
34
35 /**
36 * Validate a remote-side filesystem path we construct ourselves.
37 * Blocks shell metacharacters and traversal outside the agent dir.
38 */
39 export function safeRemotePath(p) {
40 if (typeof p !== "string" || !/^[A-Za-z0-9.][A-Za-z0-9/._-]{0,511}$/.test(p) || p.includes("..")) {
41 throw new ExecError(`refusing unsafe remote path: ${JSON.stringify(p)}`);
42 }
43 return p;
44 }
45
46 /**
47 * Local executor bound to a platform backend name.
48 * All backends receive this shape.
49 */
50 export function localExec() {
51 return {
52 kind: "local",
53 run,
54 runOk,
55 runInputLease,
56 async readFile(p) { return fs.promises.readFile(p); },
57 async writeFile(p, data) { return fs.promises.writeFile(p, data); },
58 tmpFile(prefix) {
59 return path.join(fs.mkdtempSync(path.join(os.tmpdir(), prefix)), "out");
60 },
61 };
62 }
63
64 /**
65 * App executor: the local computer driven through the desktop app's socket.
66 * Same `remote()` contract as ssh, but files the app writes are on this disk.
67 */
68 export function appExec(app, sessionId = SESSION_ID) {
69 return {
70 ...localExec(),
71 kind: "app",
72 app,
73 filesLocal: true,
74 remote(request, opts = {}) {
75 if (sessionId === SESSION_ID && appSessionClosed) throw Object.assign(new ExecError("Computer session was closed; start a new MCP session to use the local helper again"), { code: "app_session_closed" });
76 usedApp = true;
77 return appSessionRequest({ ...request, sessionId }, { timeoutMs: opts.timeoutMs ?? 30_000 });
78 },
79 };
80 }
81
82 /**
83 * A persistent ssh agent channel: one `ssh host node agent.mjs --serve`
84 * process carrying base64-JSON request lines in and JSON receipt lines out.
85 * Unlike the one-shot agent it keeps its backend alive between calls, so an
86 * open_application binding survives into later raw-input calls and held
87 * input/recording can be owned by the session. Requests written before the
88 * channel dies may already have run remotely — their failures are marked
89 * requestDispatched so the server reports outcome_unknown instead of
90 * inviting a blind retry.
91 */
92 export function ensureSshChannel(binding, argv) {
93 let ch = binding.sshChannel;
94 if (ch?.alive) return ch;
95 const next = { alive: false, restarted: !!ch, everReplied: false, pending: new Map(), seq: 1, buf: "", proc: null };
96 binding.sshChannel = next;
97 const failAll = (err) => {
98 for (const [, p] of next.pending) { clearTimeout(p.timer); p.reject(err); }
99 next.pending.clear();
100 };
101 let proc;
102 try {
103 proc = spawn(argv[0], argv.slice(1), { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
104 } catch (err) {
105 next.spawnError = err;
106 return next;
107 }
108 next.proc = proc;
109 next.alive = true;
110 proc.stdin.on("error", () => {});
111 proc.stderr.on("data", () => {}); // drain; stderr is never parsed
112 proc.stdout.setEncoding("utf8");
113 proc.stdout.on("data", (d) => {
114 next.buf += d;
115 let i;
116 while ((i = next.buf.indexOf("\n")) !== -1) {
117 const line = next.buf.slice(0, i).trim();
118 next.buf = next.buf.slice(i + 1);
119 if (!line.startsWith("{")) continue; // MOTD/banner noise
120 let msg;
121 try { msg = JSON.parse(line); } catch { continue; }
122 next.everReplied = true;
123 const p = next.pending.get(msg.id);
124 if (!p) continue; // timed-out or unknown request: drop the late reply
125 next.pending.delete(msg.id);
126 clearTimeout(p.timer);
127 p.resolve(msg);
128 }
129 });
130 const dead = (why) => {
131 if (!next.alive) return;
132 next.alive = false;
133 failAll(Object.assign(new ExecError(`ssh agent channel closed${why ? `: ${why}` : ""}`), { code: "remote_session_lost", requestDispatched: true }));
134 };
135 proc.on("error", (err) => dead(String(err?.message ?? err)));
136 proc.on("close", (code, sig) => dead(code != null ? `exited ${code}` : `signal ${sig}`));
137 return next;
138 }
139
140 export function closeSshChannel(binding) {
141 const ch = binding.sshChannel;
142 if (!ch) return;
143 binding.sshChannel = null;
144 ch.alive = false;
145 try { ch.proc?.stdin.end(); } catch {}
146 try { ch.proc?.kill("SIGTERM"); } catch {}
147 for (const [, p] of ch.pending ?? []) {
148 clearTimeout(p.timer);
149 p.reject(Object.assign(new ExecError("ssh agent channel closed"), { code: "remote_session_lost", requestDispatched: true }));
150 }
151 ch.pending?.clear();
152 }
153
154 export function channelRequest(ch, request, timeoutMs) {
155 return new Promise((resolve, reject) => {
156 if (!ch.alive) {
157 reject(Object.assign(new ExecError("ssh agent channel is closed"), { code: "remote_session_lost" }));
158 return;
159 }
160 const id = ch.seq++;
161 const timer = setTimeout(() => {
162 ch.pending.delete(id);
163 // The request was written; the remote may still be executing it.
164 reject(Object.assign(new ExecError(`ssh agent timed out after ${timeoutMs}ms`), { code: "remote_timeout", requestDispatched: true }));
165 }, timeoutMs);
166 ch.pending.set(id, { resolve, reject, timer });
167 ch.proc.stdin.write(b64({ id, tool: request.tool, args: request.args ?? {} }) + "\n");
168 });
169 }
170
171 /**
172 * Shared persistent-channel front for executors whose requests ride one
173 * long-lived `<argv> --serve` process (ssh agent, docker exec). Read-only and
174 * identity requests may run on a restarted channel; input tools may not —
175 * the fresh remote agent no longer holds this session's open_application
176 * binding.
177 */
178 const SAFE_AFTER_RESTART = new Set([
179 "platform", "probe", "list_displays", "switch_display", "list_apps", "list_windows",
180 "get_app_state", "resolve_element", "screenshot", "zoom", "cursor_position",
181 "read_clipboard", "recordingList", "recordingStatus", "open_application", "preview",
182 ]);
183 function attachPersistentChannel(ex, binding, serveArgv) {
184 if (!binding) return;
185 ex.persistent = (request, opts = {}) => {
186 const ch = ensureSshChannel(binding, serveArgv);
187 if (ch.spawnError) {
188 return Promise.reject(Object.assign(new ExecError(`remote agent channel failed to start: ${ch.spawnError.message}`), { code: "remote_session_lost" }));
189 }
190 if (ch.restarted) {
191 ch.restarted = false;
192 binding.needsObservation = true;
193 if (!SAFE_AFTER_RESTART.has(request.tool)) {
194 return Promise.reject(Object.assign(new ExecError("the remote agent session restarted — rebind with open_application and observe before acting"), { code: "remote_session_restarted" }));
195 }
196 }
197 return channelRequest(ch, request, opts.timeoutMs ?? 25_000);
198 };
199 ex.closeChannel = () => closeSshChannel(binding);
200 }
201
202 /** ssh executor: speaks to the remote agent installed by installRemoteAgent(). */
203 export function sshExec(computer, binding) {
204 const userHost = computer.user ? `${computer.user}@${computer.host}` : computer.host;
205 const portArgs = computer.port ? ["-p", String(computer.port)] : [];
206 const remoteAgent = safeRemotePath(computer.agentPath ?? ".codewhale-cu/agent/agent.mjs");
207 const base = ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8", "-o", "StrictHostKeyChecking=accept-new", ...portArgs, userHost];
208 const ex = {
209 kind: "ssh",
210 base,
211 userHost,
212 remoteAgent,
213 run(cmd, args = [], opts = {}) {
214 // Local side commands (e.g. ssh itself) run directly.
215 return run(cmd, args, opts);
216 },
217 async remote(request, opts = {}) {
218 const r = await run("ssh", [...base, "node", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
219 timeoutMs: opts.timeoutMs ?? 25_000,
220 });
221 if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
222 if (r.timedOut) throw new ExecError(`ssh ${userHost}: timed out`, r);
223 if (r.code !== 0) throw new ExecError(`ssh ${userHost} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
224 // The agent prints exactly one JSON line; anything before it is MOTD noise.
225 const line = r.stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop();
226 const reply = line ? JSON.parse(line) : null;
227 if (!reply) throw new ExecError(`ssh ${userHost}: agent returned no JSON receipt`, r);
228 return reply;
229 },
230 };
231 attachPersistentChannel(ex, binding, ["ssh", ...base, "node", remoteAgent, "--serve"]);
232 return ex;
233 }
234
235 /**
236 * docker executor: a spawned task-owned desktop container. Same agent contract
237 * as ssh, but the channel is `docker exec` — no sshd, no keys, the container
238 * boundary itself is the isolation. Every call goes through
239 * docker/agent-exec.sh, which joins the desktop session env (display + bus)
240 * the container entrypoint recorded before serving requests.
241 */
242 export function dockerExec(computer, binding) {
243 const container = safeRemotePath(computer.container);
244 const remoteAgent = "/app/docker/agent-exec.sh";
245 const ex = {
246 kind: "docker",
247 container,
248 remoteAgent,
249 run(cmd, args = [], opts = {}) {
250 // Local side commands (docker itself) run directly.
251 return run(cmd, args, opts);
252 },
253 async remote(request, opts = {}) {
254 const r = await run("docker", ["exec", container, "/bin/sh", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
255 timeoutMs: opts.timeoutMs ?? 25_000,
256 });
257 if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
258 if (r.timedOut) throw new ExecError(`docker exec ${container}: timed out`, r);
259 if (r.code !== 0) throw new ExecError(`docker exec ${container} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
260 const line = r.stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop();
261 const reply = line ? JSON.parse(line) : null;
262 if (!reply) throw new ExecError(`docker exec ${container}: agent returned no JSON receipt`, r);
263 return reply;
264 },
265 };
266 attachPersistentChannel(ex, binding, ["docker", "exec", "-i", container, "/bin/sh", remoteAgent, "--serve"]);
267 return ex;
268 }
269
270 /** Push the self-contained remote agent + src tree to an ssh computer. */
271 export async function installRemoteAgent(computer) {
272 const ex = sshExec(computer);
273 const srcDir = path.join(PLUGIN_ROOT, "src");
274 const rels = ["agent.mjs"];
275 for (const dir of ["", "backends"]) {
276 const full = path.join(srcDir, dir);
277 for (const f of fs.readdirSync(full)) {
278 if (f.endsWith(".mjs") || f.endsWith(".m") || f.endsWith(".h")) rels.push(`src/${dir ? dir + "/" : ""}${f}`);
279 }
280 }
281 const marker = ".codewhale-cu/agent";
282 let r = await run("ssh", [...ex.base, "mkdir", "-p", `${marker}/src/backends`], { timeoutMs: 15_000 });
283 if (r.code !== 0) throw new ExecError(`ssh ${ex.userHost}: mkdir failed: ${r.stderr.trim().slice(0, 300)}`, r);
284 for (const rel of rels) {
285 const localPath = rel === "agent.mjs" ? path.join(PLUGIN_ROOT, "agent.mjs") : path.join(srcDir, rel.slice(4));
286 const dest = safeRemotePath(`${marker}/${rel}`);
287 r = await run("scp", [...(computer.port ? ["-P", String(computer.port)] : []), localPath, `${ex.userHost}:${dest}`], { timeoutMs: 30_000 });
288 if (r.code !== 0) throw new ExecError(`scp ${rel} failed: ${r.stderr.trim().slice(0, 300)}`, r);
289 }
290 // Probe remote platform via the agent itself.
291 const reply = await ex.remote({ tool: "platform" });
292 return { installed: rels.length, remotePlatform: reply.platform, agentPath: `${marker}/agent.mjs` };
293 }
294
295 /** hdc (HarmonyOS) executor. Commands run on-device; files pull to local tmp. */
296 export function hdcExec(computer) {
297 const targetArgs = computer.target ? ["-t", computer.target] : [];
298 const shell = (args, opts = {}) => run("hdc", [...targetArgs, "shell", ...args], opts);
299 return {
300 kind: "hdc",
301 targetArgs,
302 run,
303 runOk,
304 shell,
305 async pullFile(remotePath, localPath, opts = {}) {
306 // HDC device captures use absolute paths; SSH agent paths are relative.
307 // Validate the remaining path with the same traversal/metacharacter guard.
308 safeRemotePath(typeof remotePath === "string" ? remotePath.replace(/^\//, "") : remotePath);
309 const r = await run("hdc", [...targetArgs, "file", "recv", remotePath, localPath], opts);
310 if (r.code !== 0) throw new ExecError(`hdc file recv failed: ${r.stderr.trim().slice(0, 300)}`, r);
311 return localPath;
312 },
313 async readFile(remotePath, opts = {}) {
314 // Containment: pull into a private mkdtemp dir and remove exactly that
315 // dir. Never rm() the parent of a file placed directly in os.tmpdir() —
316 // that recursively deletes the entire user temp directory.
317 const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "cu-hdc-"));
318 try {
319 const tmp = path.join(dir, "out");
320 await this.pullFile(remotePath, tmp, opts);
321 return await fs.promises.readFile(tmp);
322 } finally {
323 // Cleanup must not replace downloaded bytes or the original I/O error.
324 await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
325 }
326 },
327 };
328 }
329
330 export async function executorFor(computer, binding) {
331 if (computer.transport === "local") {
332 // Test hook: exercise the out-of-process wire path (desktop app / ssh
333 // agent) in-process, so wire argument preparation is covered by tests.
334 if (process.env.CODEWHALE_CU_TEST_REMOTE === "1") {
335 const { handle } = await import("./app-handler.mjs");
336 return { ...appExec({ id: "test", name: "test app" }), remote: (request) => handle(request, { sessionId: SESSION_ID, signal: currentSignal() }) };
337 }
338 const status = await ensureApp();
339 if (status.via === "app") {
340 if (status.app.sessionProtocol !== 2) throw Object.assign(new ExecError("The installed Computer Use helper needs an update for isolated sessions and disconnect cleanup. Rebuild/reinstall it, then retry."), { code: "app_upgrade_required" });
341 if (process.platform === "darwin" && status.app.backgroundProtocol !== 1) throw Object.assign(new ExecError("The installed Computer Use app predates background scrolling, scoped observations and foreground preemption. Update and restart the helper before using it."), { code: "app_upgrade_required" });
342 return appExec(status.app);
343 }
344 return { ...localExec(), appReason: status.reason };
345 }
346 if (computer.transport === "ssh") return sshExec(computer, binding);
347 if (computer.transport === "docker") return dockerExec(computer, binding);
348 if (computer.transport === "hdc") return hdcExec(computer);
349 throw new ExecError(`unknown transport ${computer.transport}`);
350 }
351
352 /**
353 * Map a computer to its backend module. Local platform is fixed; ssh
354 * computers may carry platformHint (probed at registration).
355 */
356 function effectivePlatform(computer) {
357 let platform = computer.platform ?? computer.platformHint;
358 if (!platform) {
359 if (computer.transport === "local") platform = process.platform;
360 else if (computer.transport === "hdc") platform = "harmonyos";
361 else if (computer.transport === "docker") platform = "linux"; // spawned containers are always the Linux desktop image
362 else platform = "linux"; // conservative default for ssh; registration probes it
363 }
364 return platform;
365 }
366
367 /** Identity of the effective route, excluding catalog presentation metadata. */
368 export function routeFingerprint(computer) {
369 const route = [computer.transport, effectivePlatform(computer)];
370 if (computer.transport === "docker") route.push(computer.container || null);
371 if (computer.transport === "hdc") route.push(computer.target || null);
372 if (computer.transport === "ssh") route.push(computer.host, computer.user || null,
373 computer.port || null, computer.agentPath ?? ".codewhale-cu/agent/agent.mjs");
374 return JSON.stringify(route);
375 }
376
377 export async function backendFor(computer) {
378 const platform = effectivePlatform(computer);
379 // Test hook: inject a fake local backend by absolute path to an .mjs
380 // module exporting `create` (used by tests/, never set in production).
381 const testBackend = computer.transport === "local" && process.env.CODEWHALE_CU_TEST_BACKEND;
382 const mod = await import(testBackend ? url.pathToFileURL(testBackend).href : `./backends/${platform}.mjs`);
383 // Backends always get a direct executor; routing through the app happens
384 // one level up (the server dispatches to `executor.remote` when present).
385 const exec = computer.transport === "local" ? localExec() : await executorFor(computer);
386 return { backend: mod.create({ exec, computer, platform }), platform };
387 }
388
388 lines Plain Text