返回 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 /** Strict x.y.z comparison: true only when candidate is a newer release than current. */
26 export function newerVersion(candidate, current) {
27 const parse = (value) => /^\d+\.\d+\.\d+$/.test(value) ? value.split(".").map(Number) : null;
28 const a = parse(candidate), b = parse(current);
29 if (!a || !b) return false;
30 for (let i = 0; i < 3; i++) { if (a[i] !== b[i]) return a[i] > b[i]; }
31 return false;
32 }
33
34 /**
35 * Whether a running helper at `helper` is stale next to this plugin at
36 * `bundled`. The helper owns the modules it loaded at start, so only an older
37 * helper serves a previous build; a newer notarized helper beside an older
38 * built-in plugin is expected and must not be told to restart.
39 */
40 export function helperStaleness(helper, bundled = APP_VERSION) {
41 if (!newerVersion(bundled, helper)) return { stale: false, note: null };
42 return { stale: true, note: `The running helper reports ${helper} but this plugin is ${bundled} — restart the Codewhale Computer Use app to load the current build.` };
43 }
44
45 function shortHash(s) {
46 return crypto.createHash("sha256").update(s).digest("hex").slice(0, 12);
47 }
48
49 /** Per-user socket endpoint, keyed by the state dir so isolated state dirs get isolated apps. */
50 export function socketPath() {
51 if (process.env.CODEWHALE_CU_APP_SOCKET) return process.env.CODEWHALE_CU_APP_SOCKET;
52 const dir = stateDir();
53 if (process.platform === "win32") return `\\\\.\\pipe\\codewhale-cu-${shortHash(dir)}`;
54 const preferred = path.join(dir, "app.sock");
55 // sun_path is 104 bytes on macOS / 108 on Linux; fall back to a short tmp name.
56 return Buffer.byteLength(preferred) < 100 ? preferred : path.join(os.tmpdir(), `codewhale-cu-${shortHash(dir)}.sock`);
57 }
58
59 /** Where the app records how to launch itself (written by the app on first launch). */
60 export function registrationPath() { return path.join(stateDir(), "app.json"); }
61 /** Where the running daemon records its pid/socket (written on listen, removed on exit). */
62 export function runInfoPath() { return path.join(stateDir(), "app-run.json"); }
63
64 export function readRegistration() {
65 try {
66 const reg = JSON.parse(fs.readFileSync(registrationPath(), "utf8"));
67 if (!launchArgv(reg)) return null;
68 return reg;
69 } catch { return null; }
70 }
71
72 /**
73 * The argv that starts the registered app: always the bundle's own launcher,
74 * derived from its path. app.json is a file any process of this user can
75 * write, so a `launch` argv stored there is never run.
76 */
77 export function launchArgv(reg, platform = process.platform) {
78 const bundle = reg?.path;
79 if (typeof bundle !== "string" || !path.isAbsolute(bundle) || bundle.includes("\0")) return null;
80 if (platform === "darwin" && !/\.app\/?$/i.test(bundle)) return null;
81 return defaultLaunch(bundle, platform);
82 }
83
84 export function writeRegistration(reg) {
85 fs.mkdirSync(stateDir(), { recursive: true });
86 fs.writeFileSync(registrationPath(), JSON.stringify({ ...reg, registeredAt: new Date().toISOString() }, null, 2) + "\n");
87 }
88
89 /**
90 * Send one request to the app and await its single-line reply.
91 *
92 * Once the request line is written the helper may already have acted on it,
93 * so every failure after that point (timeout, cancel, a dropped connection, a
94 * malformed reply) carries `requestDispatched: true`. The MCP server turns
95 * that into `outcome_unknown` instead of a plain cancel that invites a blind
96 * retry of an input action that may have landed.
97 */
98 function requestConnection(request, { timeoutMs = 30_000, signal = currentSignal(), keepOpen = false } = {}) {
99 throwIfAborted(signal);
100 return new Promise((resolve, reject) => {
101 const sock = net.connect(socketPath());
102 let buf = "";
103 let settled = false;
104 let written = false;
105 const failure = (message, code) => Object.assign(new ExecError(message), { code }, written ? { requestDispatched: true } : {});
106 const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); if (!keepOpen || fn === reject) sock.destroy(); fn(v); };
107 const abort = () => done(reject, failure("computer request cancelled", "cancelled"));
108 const timer = setTimeout(() => done(reject, failure(`${APP_NAME}: request timed out after ${timeoutMs}ms`, "app_timeout")), timeoutMs);
109 signal?.addEventListener("abort", abort, { once: true });
110 sock.on("error", (err) => done(reject, failure(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`, "app_unavailable")));
111 sock.on("connect", () => { written = true; sock.write(JSON.stringify(request) + "\n"); });
112 sock.on("data", (d) => {
113 buf += d.toString("utf8");
114 const nl = buf.indexOf("\n");
115 if (nl === -1) return;
116 try { const reply = JSON.parse(buf.slice(0, nl)); done(resolve, keepOpen ? { reply, socket: sock } : reply); }
117 catch { done(reject, failure(`${APP_NAME}: malformed reply`, "app_bad_reply")); }
118 });
119 sock.on("close", () => done(reject, failure(`${APP_NAME}: connection closed before a reply`, "app_unavailable")));
120 });
121 }
122
123 export function appRequest(request, options) { return requestConnection(request, options); }
124
125 // A live socket is the session owner, independent of short-lived cancellable
126 // request sockets. The OS closes it even if the MCP process is killed; no PID
127 // lookup or reuse-prone process identity is needed to release held input.
128 // When the socket dies without close_session (an app update replaces the
129 // daemon and every socket it owned), the dead lease is dropped so the next
130 // request re-opens one instead of failing forever.
131 const sessionLeases = new Map();
132 export function openAppSession(sessionId) {
133 if (!sessionLeases.has(sessionId)) {
134 // Carry the capability grant to the daemon so a narrowed server cannot
135 // smuggle ungranted tools past the boundary that actually sends input.
136 // The daemon sees transport method names (probe, recordingStart, …), so
137 // the grant is normalized through BACKEND_METHOD before it travels.
138 const grant = parseGrant(process.env.CODEWHALE_CU_GRANT);
139 const transportGrant = grant ? [...new Set([...grant].map((name) => BACKEND_METHOD[name] ?? name))] : null;
140 const pending = requestConnection({ tool: "open_session", sessionId, ...(transportGrant ? { grant: transportGrant } : {}) }, { timeoutMs: 3_000, signal: null, keepOpen: true }).then(({ reply, socket }) => {
141 if (!reply?.ok || typeof reply.leaseToken !== "string") {
142 socket.destroy();
143 throw Object.assign(new ExecError(reply?.error?.message ?? "Computer session lease was refused"), { code: reply?.error?.code ?? "app_session_closed" });
144 }
145 const lease = { token: reply.leaseToken, socket, closed: socket.destroyed, deliberate: false };
146 socket.once("close", () => {
147 lease.closed = true;
148 if (sessionLeases.get(sessionId) === pending && !lease.deliberate) sessionLeases.delete(sessionId);
149 });
150 // Library clients need not keep Node alive solely for an idle lease.
151 socket.unref();
152 return lease;
153 }, (error) => {
154 // Opening a lease sends no input, so its failure is never
155 // outcome-unknown for the action that was waiting on it.
156 if (error && typeof error === "object") delete error.requestDispatched;
157 throw error;
158 });
159 // A refused or unreachable open is retried on the next request, not cached.
160 pending.catch(() => { if (sessionLeases.get(sessionId) === pending) sessionLeases.delete(sessionId); });
161 sessionLeases.set(sessionId, pending);
162 }
163 return sessionLeases.get(sessionId);
164 }
165
166 export async function appSessionRequest(request, options = {}) {
167 throwIfAborted(options.signal === undefined ? currentSignal() : options.signal);
168 let lease = await openAppSession(request.sessionId);
169 if (lease.closed) {
170 if (lease.deliberate) throw Object.assign(new ExecError("Computer session was closed; start a new session to continue"), { code: "app_session_closed" });
171 // The fresh lease is on a daemon that holds no input for this session,
172 // so nothing the old lease held can replay across the reconnect.
173 lease = await openAppSession(request.sessionId);
174 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" });
175 }
176 try { return await appRequest({ ...request, leaseToken: lease.token }, options); }
177 finally { if (request.tool === "close_session") { lease.deliberate = true; lease.socket.destroy(); } }
178 }
179
180 /** App identity if it is running, else null. Cheap: one connect. */
181 export async function hello({ timeoutMs = 2_000 } = {}) {
182 try {
183 const r = await appRequest({ tool: "hello" }, { timeoutMs });
184 return r?.ok && r.app ? r.app : null;
185 } catch { return null; }
186 }
187
188 /** How each OS re-launches an installed bundle so it is its own responsible process. */
189 export function defaultLaunch(bundlePath, platform = process.platform) {
190 if (platform === "darwin") return ["open", "-g", "-a", bundlePath];
191 if (platform === "win32") return ["powershell.exe", "-NoProfile", "-WindowStyle", "Hidden", "-ExecutionPolicy", "Bypass", "-File", path.join(bundlePath, "launch.ps1")];
192 return [path.join(bundlePath, "bin", "codewhale-computer-use")];
193 }
194
195 /** Start the registered app detached (LaunchServices on macOS so TCC attributes it to the app). */
196 export function launchApp(reg) {
197 const argv = launchArgv(reg);
198 if (!argv) return null;
199 const [cmd, ...args] = argv;
200 const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
201 child.on("error", () => {});
202 child.unref();
203 return child.pid ?? null;
204 }
205
206 let lastLaunchAt = 0;
207
208 /**
209 * Decide how the local computer is driven this call: through the app when it
210 * is running (or registered and launchable), otherwise directly from this
211 * process. Set CODEWHALE_CU_APP=off to force direct.
212 */
213 export async function ensureApp({ launch = true } = {}) {
214 if (process.env.CODEWHALE_CU_APP === "off") return { via: "direct", reason: "CODEWHALE_CU_APP=off" };
215 let app = await hello();
216 throwIfAborted();
217 if (app) return { via: "app", app };
218 const reg = readRegistration();
219 if (!reg) {
220 const standalone = fs.existsSync(path.join(PLUGIN_ROOT, "scripts", "build-app.mjs"));
221 return { via: "direct", reason: standalone
222 ? `${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.`
223 : "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." };
224
225 }
226 if (typeof reg.path === "string" && !fs.existsSync(reg.path)) {
227 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" });
228 }
229 if (!launch || Date.now() - lastLaunchAt < 15_000) {
230 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" });
231 }
232 lastLaunchAt = Date.now();
233 launchApp(reg);
234 const deadline = Date.now() + 8_000;
235 while (Date.now() < deadline) {
236 await wait(250);
237 app = await hello({ timeoutMs: 1_000 });
238 throwIfAborted();
239 if (app) return { via: "app", app, launched: true };
240 }
241 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" });
242 }
243
243 lines Plain Text