返回 CodeWhale
app-socket.mjs
根目录 / crates / tui / plugins / computer-use / src / app-socket.mjs
1 // The desktop app is the local computer's out-of-process runner: a long-lived
2 // daemon that owns the OS permissions (macOS Accessibility / Screen Recording
3 // are granted to *it*, not to whichever terminal hosts the MCP server) and
4 // answers {tool, args} requests over a per-user local socket. This module is
5 // the client side plus the shared naming; app/daemon.mjs is the server side.
6 //
7 // Wire format: one JSON object per line, request then reply, same shape as
8 // the ssh remote agent. Only ALLOWED tools (src/app-handler.mjs) execute.
9 import fs from "node:fs";
10 import net from "node:net";
11 import os from "node:os";
12 import path from "node:path";
13 import url from "node:url";
14 import crypto from "node:crypto";
15 import { spawn } from "node:child_process";
16 import { stateDir } from "./registry.mjs";
17 import { parseGrant, BACKEND_METHOD } from "./tools.mjs";
18 import { ExecError, currentSignal, throwIfAborted, wait } from "./exec.mjs";
19
20 export const PLUGIN_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
21 export const APP_ID = "net.codewhale.computer-use";
22 export const APP_NAME = "Codewhale Computer Use";
23 export const APP_VERSION = JSON.parse(fs.readFileSync(path.join(PLUGIN_ROOT, "plugin.json"), "utf8")).version;
24
25 function shortHash(s) {
26 return crypto.createHash("sha256").update(s).digest("hex").slice(0, 12);
27 }
28
29 /** Per-user socket endpoint, keyed by the state dir so isolated state dirs get isolated apps. */
30 export function socketPath() {
31 if (process.env.CODEWHALE_CU_APP_SOCKET) return process.env.CODEWHALE_CU_APP_SOCKET;
32 const dir = stateDir();
33 if (process.platform === "win32") return `\\\\.\\pipe\\codewhale-cu-${shortHash(dir)}`;
34 const preferred = path.join(dir, "app.sock");
35 // sun_path is 104 bytes on macOS / 108 on Linux; fall back to a short tmp name.
36 return Buffer.byteLength(preferred) < 100 ? preferred : path.join(os.tmpdir(), `codewhale-cu-${shortHash(dir)}.sock`);
37 }
38
39 /** Where the app records how to launch itself (written by the app on first launch). */
40 export function registrationPath() { return path.join(stateDir(), "app.json"); }
41 /** Where the running daemon records its pid/socket (written on listen, removed on exit). */
42 export function runInfoPath() { return path.join(stateDir(), "app-run.json"); }
43
44 export function readRegistration() {
45 try {
46 const reg = JSON.parse(fs.readFileSync(registrationPath(), "utf8"));
47 if (!Array.isArray(reg?.launch) || reg.launch.length === 0 || typeof reg.launch[0] !== "string") return null;
48 return reg;
49 } catch { return null; }
50 }
51
52 export function writeRegistration(reg) {
53 fs.mkdirSync(stateDir(), { recursive: true });
54 fs.writeFileSync(registrationPath(), JSON.stringify({ ...reg, registeredAt: new Date().toISOString() }, null, 2) + "\n");
55 }
56
57 /** Send one request to the app and await its single-line reply. */
58 function requestConnection(request, { timeoutMs = 30_000, signal = currentSignal(), keepOpen = false } = {}) {
59 throwIfAborted(signal);
60 return new Promise((resolve, reject) => {
61 const sock = net.connect(socketPath());
62 let buf = "";
63 let settled = false;
64 const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); if (!keepOpen || fn === reject) sock.destroy(); fn(v); };
65 const abort = () => done(reject, Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
66 const timer = setTimeout(() => done(reject, Object.assign(new ExecError(`${APP_NAME}: request timed out after ${timeoutMs}ms`), { code: "app_timeout" })), timeoutMs);
67 signal?.addEventListener("abort", abort, { once: true });
68 sock.on("error", (err) => done(reject, Object.assign(new ExecError(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`), { code: "app_unavailable" })));
69 sock.on("connect", () => sock.write(JSON.stringify(request) + "\n"));
70 sock.on("data", (d) => {
71 buf += d.toString("utf8");
72 const nl = buf.indexOf("\n");
73 if (nl === -1) return;
74 try { const reply = JSON.parse(buf.slice(0, nl)); done(resolve, keepOpen ? { reply, socket: sock } : reply); }
75 catch { done(reject, Object.assign(new ExecError(`${APP_NAME}: malformed reply`), { code: "app_bad_reply" })); }
76 });
77 sock.on("close", () => done(reject, Object.assign(new ExecError(`${APP_NAME}: connection closed before a reply`), { code: "app_unavailable" })));
78 });
79 }
80
81 export function appRequest(request, options) { return requestConnection(request, options); }
82
83 // A live socket is the session owner, independent of short-lived cancellable
84 // request sockets. The OS closes it even if the MCP process is killed; no PID
85 // lookup or reuse-prone process identity is needed to release held input.
86 // When the socket dies without close_session (an app update replaces the
87 // daemon and every socket it owned), the dead lease is dropped so the next
88 // request re-opens one instead of failing forever.
89 const sessionLeases = new Map();
90 export function openAppSession(sessionId) {
91 if (!sessionLeases.has(sessionId)) {
92 // Carry the capability grant to the daemon so a narrowed server cannot
93 // smuggle ungranted tools past the boundary that actually sends input.
94 // The daemon sees transport method names (probe, recordingStart, …), so
95 // the grant is normalized through BACKEND_METHOD before it travels.
96 const grant = parseGrant(process.env.CODEWHALE_CU_GRANT);
97 const transportGrant = grant ? [...new Set([...grant].map((name) => BACKEND_METHOD[name] ?? name))] : null;
98 const pending = requestConnection({ tool: "open_session", sessionId, ...(transportGrant ? { grant: transportGrant } : {}) }, { timeoutMs: 3_000, signal: null, keepOpen: true }).then(({ reply, socket }) => {
99 if (!reply?.ok || typeof reply.leaseToken !== "string") {
100 socket.destroy();
101 throw Object.assign(new ExecError(reply?.error?.message ?? "Computer session lease was refused"), { code: reply?.error?.code ?? "app_session_closed" });
102 }
103 const lease = { token: reply.leaseToken, socket, closed: socket.destroyed, deliberate: false };
104 socket.once("close", () => {
105 lease.closed = true;
106 if (sessionLeases.get(sessionId) === pending && !lease.deliberate) sessionLeases.delete(sessionId);
107 });
108 // Library clients need not keep Node alive solely for an idle lease.
109 socket.unref();
110 return lease;
111 });
112 // A refused or unreachable open is retried on the next request, not cached.
113 pending.catch(() => { if (sessionLeases.get(sessionId) === pending) sessionLeases.delete(sessionId); });
114 sessionLeases.set(sessionId, pending);
115 }
116 return sessionLeases.get(sessionId);
117 }
118
119 export async function appSessionRequest(request, options = {}) {
120 throwIfAborted(options.signal === undefined ? currentSignal() : options.signal);
121 let lease = await openAppSession(request.sessionId);
122 if (lease.closed) {
123 if (lease.deliberate) throw Object.assign(new ExecError("Computer session was closed; start a new session to continue"), { code: "app_session_closed" });
124 // The fresh lease is on a daemon that holds no input for this session,
125 // so nothing the old lease held can replay across the reconnect.
126 lease = await openAppSession(request.sessionId);
127 if (lease.closed) throw Object.assign(new ExecError("Computer session lease could not be re-established with the helper; retry the request"), { code: "app_session_closed" });
128 }
129 try { return await appRequest({ ...request, leaseToken: lease.token }, options); }
130 finally { if (request.tool === "close_session") { lease.deliberate = true; lease.socket.destroy(); } }
131 }
132
133 /** App identity if it is running, else null. Cheap: one connect. */
134 export async function hello({ timeoutMs = 2_000 } = {}) {
135 try {
136 const r = await appRequest({ tool: "hello" }, { timeoutMs });
137 return r?.ok && r.app ? r.app : null;
138 } catch { return null; }
139 }
140
141 /** How each OS re-launches an installed bundle so it is its own responsible process. */
142 export function defaultLaunch(bundlePath, platform = process.platform) {
143 if (platform === "darwin") return ["open", "-g", "-a", bundlePath];
144 if (platform === "win32") return ["powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", path.join(bundlePath, "launch.ps1")];
145 return [path.join(bundlePath, "bin", "codewhale-computer-use")];
146 }
147
148 /** Start the registered app detached (LaunchServices on macOS so TCC attributes it to the app). */
149 export function launchApp(reg) {
150 const [cmd, ...args] = reg.launch;
151 const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
152 child.on("error", () => {});
153 child.unref();
154 return child.pid ?? null;
155 }
156
157 let lastLaunchAt = 0;
158
159 /**
160 * Decide how the local computer is driven this call: through the app when it
161 * is running (or registered and launchable), otherwise directly from this
162 * process. Set CODEWHALE_CU_APP=off to force direct.
163 */
164 export async function ensureApp({ launch = true } = {}) {
165 if (process.env.CODEWHALE_CU_APP === "off") return { via: "direct", reason: "CODEWHALE_CU_APP=off" };
166 let app = await hello();
167 throwIfAborted();
168 if (app) return { via: "app", app };
169 const reg = readRegistration();
170 if (!reg) {
171 const standalone = fs.existsSync(path.join(PLUGIN_ROOT, "scripts", "build-app.mjs"));
172 return { via: "direct", reason: standalone
173 ? `${APP_NAME} is not installed. Input and screen permissions belong to the current host. To use a standalone permission-owning helper, run "npm run build:app && npm run install:app" in the plugin checkout.`
174 : "Using the Computer Use helper included with Codewhale. Input and screen permissions belong to the current host app; grant them in your operating system's privacy settings when requested." };
175
176 }
177 if (typeof reg.path === "string" && !fs.existsSync(reg.path)) {
178 throw Object.assign(new ExecError(`${APP_NAME} is registered at ${reg.path}, but that app is missing. Reinstall it and open it once to refresh ${registrationPath()}.`), { code: "app_missing" });
179 }
180 if (!launch || Date.now() - lastLaunchAt < 15_000) {
181 throw Object.assign(new ExecError(`${APP_NAME} is installed but not responding. Open it from Applications and retry; its controls must remain in charge of input.`), { code: "app_unavailable" });
182 }
183 lastLaunchAt = Date.now();
184 launchApp(reg);
185 const deadline = Date.now() + 8_000;
186 while (Date.now() < deadline) {
187 await wait(250);
188 app = await hello({ timeoutMs: 1_000 });
189 throwIfAborted();
190 if (app) return { via: "app", app, launched: true };
191 }
192 throw Object.assign(new ExecError(`${APP_NAME} did not answer within 8s. Open it from Applications and check its status before retrying.`), { code: "app_unavailable" });
193 }
194
194 lines Plain Text