返回 CodeWhale
daemon.mjs
根目录 / crates / tui / plugins / computer-use / app / daemon.mjs
1 #!/usr/bin/env node
2 // Codewhale Computer Use — the desktop app process.
3 //
4 // A long-lived daemon that runs the platform backend on this machine and
5 // answers one-line JSON requests over a per-user local socket (see
6 // src/app-socket.mjs). The app bundles built by scripts/build-app.mjs launch
7 // exactly this file, so the OS attributes every osascript / screencapture /
8 // UI-automation call to the app: grant Accessibility and Screen Recording to
9 // "Codewhale Computer Use" once and every host that speaks to the plugin
10 // inherits it.
11 //
12 // Env (set by the launchers inside the bundles):
13 // CODEWHALE_CU_APP_BUNDLE absolute path of the installed bundle
14 // CODEWHALE_CU_APP_LAUNCH JSON argv that re-launches the bundle detached
15 // CODEWHALE_CU_STATE_DIR state dir (defaults to ~/.codewhale-cu)
16 import fs from "node:fs";
17 import net from "node:net";
18 import crypto from "node:crypto";
19 import path from "node:path";
20 import { handle, closeSession, closeAllSessions, releaseSessionInput, reopenSession, ALLOWED, controlStatus, setControlMode, summarizeSessions } from "../src/app-handler.mjs";
21 import { runBackgroundCheck } from "./background-check.mjs";
22 import { checkForUpdate, prepareUpdate, restartWithUpdate, readUpdateResult } from "./updates.mjs";
23 import { APP_ID, APP_NAME, APP_VERSION, socketPath, runInfoPath, writeRegistration, defaultLaunch, hello } from "../src/app-socket.mjs";
24 import { stateDir } from "../src/registry.mjs";
25
26 const startedAt = new Date().toISOString();
27 const bundle = process.env.CODEWHALE_CU_APP_BUNDLE || null;
28 let controlOwner = false;
29 const log = (msg) => process.stderr.write(`${new Date().toISOString()} ${APP_NAME}: ${msg}\n`);
30
31 function appInfo() {
32 return { id: APP_ID, name: APP_NAME, version: APP_VERSION, sessionProtocol: 2, backgroundProtocol: 1, controlProtocol: 1, controlOwner, pid: process.pid, platform: process.platform, node: process.version, bundle, startedAt, socket: socketPath() };
33 }
34
35 if (await hello({ timeoutMs: 1_500 })) {
36 log(`already running on ${socketPath()}; exiting`);
37 process.exit(0);
38 }
39
40 const sock = socketPath();
41 fs.mkdirSync(stateDir(), { recursive: true });
42 if (process.platform !== "win32") {
43 try { fs.unlinkSync(sock); } catch {} // stale file from an unclean exit; nobody answered hello above
44 }
45
46 const leases = new Map();
47 let shuttingDown = false;
48 let backgroundCheck = null;
49 let checking = false;
50 let update = readUpdateResult();
51 let updating = false;
52 let controlError = null;
53 const controlFile = path.join(stateDir(), "control.json");
54 async function userControl(mode) {
55 const work = setControlMode(mode);
56 if (mode === "ready") await work;
57 const temporary = `${controlFile}.${process.pid}.tmp`;
58 let storageError;
59 try {
60 fs.writeFileSync(temporary, JSON.stringify({ mode }), { mode: 0o600 });
61 fs.renameSync(temporary, controlFile);
62 } catch (error) { storageError = error; }
63 const result = await work;
64 if (storageError) throw new Error("Control changed, but its restart preference could not be saved. Check disk space before reopening the app.");
65 return result;
66 }
67 try {
68 const saved = JSON.parse(fs.readFileSync(controlFile, "utf8"));
69 if (saved.mode !== "ready") await setControlMode(["paused", "stopped"].includes(saved.mode) ? saved.mode : "stopped");
70 } catch (error) { if (error.code !== "ENOENT") await setControlMode("stopped"); }
71
72 // An inherited socketpair joins the menu-bar owner and its child. This has
73 // no filesystem endpoint, no reusable credential and no MCP equivalent.
74 // Losing the human control process fails closed before accepting more work.
75 if (process.env.CODEWHALE_CU_CONTROL_FD === "3") {
76 const control = new net.Socket({ fd: 3, readable: true, writable: true });
77 controlOwner = true;
78 delete process.env.CODEWHALE_CU_CONTROL_FD;
79 let input = "";
80 control.setEncoding("utf8");
81 const status = () => ({ ...controlStatus(), version: APP_VERSION, checking, backgroundCheck, error: controlError,
82 update: update ? { available: update.available, version: update.version, message: update.message, busy: updating } : null });
83 const send = (id, error) => {
84 if (error) controlError = String(error.message ?? error).slice(0, 400);
85 if (!control.destroyed) control.write(JSON.stringify({ id, ...status() }) + "\n");
86 };
87 control.on("data", chunk => {
88 input += chunk;
89 if (input.length > 8192) { control.destroy(); return; }
90 let newline;
91 while ((newline = input.indexOf("\n")) >= 0) {
92 const line = input.slice(0, newline); input = input.slice(newline + 1);
93 let request;
94 try { request = JSON.parse(line); } catch { continue; }
95 const { id, command } = request;
96 if (command === "status") send(id);
97 else if (["pause", "resume", "stop"].includes(command)) {
98 const mode = { pause: "paused", resume: "ready", stop: "stopped" }[command];
99 userControl(mode).then(() => {
100 controlError = null;
101 if (mode === "stopped") {
102 // Keep old owner sockets alive but invalidate their leases. An
103 // already queued request can never silently obtain a fresh one.
104 for (const lease of leases.values()) lease.stopped = true;
105 }
106 send(id);
107 }).catch(error => send(id, error));
108 } else if (command === "check") {
109 if (checking || controlStatus().sessions.some(s => s.action)) { send(id, new Error("Wait for the current action to finish before running the check.")); continue; }
110 checking = true; backgroundCheck = null; send(id);
111 runBackgroundCheck({ bundle }).then(result => { backgroundCheck = result; }).catch(error => {
112 backgroundCheck = { ok: false, message: error.message };
113 }).finally(() => { checking = false; send(id); });
114 } else if (command === "updates" && !updating) {
115 updating = true; update = { available: false, message: "Checking for updates…" }; send(id);
116 checkForUpdate().then(result => { update = result; }).catch(error => { update = { available: false, message: error.message }; }).finally(() => { updating = false; send(id); });
117 } else if (command === "install_update" && update?.available && !updating && bundle) {
118 updating = true; update.message = "Downloading and verifying the update…"; send(id);
119 prepareUpdate(update).then(async prepared => {
120 await userControl("stopped");
121 await restartWithUpdate(prepared, bundle);
122 update.message = "Restarting Computer Use…"; send(id);
123 }).catch(error => { updating = false; update.message = error.message; send(id); });
124 } else send(id, new Error("Unknown or unavailable control command"));
125 }
126 });
127 control.on("error", () => {});
128 control.on("close", () => {
129 controlOwner = false;
130 // Persist the stop and abort active input synchronously, then retire the
131 // listener. Reopening the menu app must be able to start a new owner.
132 userControl("stopped").catch(error => log(`control owner cleanup: ${error.message}`));
133 shutdown("control owner disconnected");
134 });
135 }
136 async function serve(conn) {
137 let buf = "";
138 let chain = Promise.resolve();
139 const controller = new AbortController();
140 let ownedSession = null;
141 conn.on("close", () => {
142 controller.abort();
143 if (ownedSession && leases.get(ownedSession)?.socket === conn) {
144 leases.delete(ownedSession);
145 closeSession(ownedSession).catch((err) => log(`disconnected session input cleanup failed: ${err.message}`));
146 }
147 });
148 conn.setEncoding("utf8");
149 conn.on("error", () => {});
150 conn.on("data", (chunk) => {
151 buf += chunk;
152 let nl;
153 while ((nl = buf.indexOf("\n")) !== -1) {
154 const line = buf.slice(0, nl).trim();
155 buf = buf.slice(nl + 1);
156 if (!line) continue;
157 chain = chain.then(async () => {
158 if (controller.signal.aborted) return;
159 let req;
160 try { req = JSON.parse(line); } catch { return conn.write(JSON.stringify({ ok: false, error: { code: "bad_payload", message: "request is not JSON" } }) + "\n"); }
161 let reply;
162 if (shuttingDown) reply = { ok: false, error: { code: "app_shutting_down", message: "Computer Use helper is shutting down" } };
163 else if (req?.tool === "hello") reply = { ok: true, app: appInfo() };
164 else if (req?.tool === "platform") reply = await handle(req);
165 else if (!ALLOWED.has(req?.tool) && !["open_session", "close_session", "release_session_input"].includes(req?.tool)) reply = await handle(req);
166 else if (typeof req?.sessionId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(req.sessionId)) {
167 reply = { ok: false, error: { code: "session_required", message: "Update the MCP server: every computer request must carry its session identity." } };
168 } else if (req.tool === "open_session") {
169 if (ownedSession || leases.has(req.sessionId)) reply = { ok: false, error: { code: "session_owned", message: "Computer session already has an owner" } };
170 else if (leases.size >= 256) reply = { ok: false, error: { code: "session_limit", message: "Too many active computer sessions" } };
171 else {
172 ownedSession = req.sessionId;
173 const leaseToken = crypto.randomUUID();
174 // A capability grant (set by the MCP server from CODEWHALE_CU_GRANT)
175 // narrows this lease for its whole life: the daemon refuses tools
176 // the grant does not name, so narrowing survives the socket.
177 const grant = Array.isArray(req.grant) && req.grant.length ? new Set(req.grant.map((t) => String(t))) : null;
178 leases.set(ownedSession, { socket: conn, token: leaseToken, grant });
179 reopenSession(ownedSession);
180 reply = { ok: true, leaseToken, ...(grant ? { grant: [...grant] } : {}) };
181 }
182 } else if (!leases.has(req.sessionId) || leases.get(req.sessionId).token !== req.leaseToken) {
183 reply = { ok: false, error: { code: "session_owner_required", message: "Computer request needs its live session owner lease; update or restart the MCP server" } };
184 } else if (leases.get(req.sessionId).grant && !leases.get(req.sessionId).grant.has(req.tool) && !["close_session", "release_session_input"].includes(req.tool)) {
185 // Cleanup must never be blocked by a grant; everything else is.
186 reply = { ok: false, error: { code: "not_granted", message: `this session's capability grant does not include "${req.tool}"` } };
187 } else if (req.tool === "list_sessions") {
188 // Content-free registry view over the live sessions. Available even
189 // when the user has paused or stopped other sessions: seeing who is
190 // driving is exactly what a model needs to explain machine state.
191 // Same envelope as every backend reply so the server reads `data`.
192 reply = { ok: true, platform: process.platform, tool: req.tool, data: summarizeSessions() };
193 } else if (leases.get(req.sessionId).stopped && !["close_session", "release_session_input"].includes(req.tool)) {
194 reply = { ok: false, error: { code: "control_stopped", message: "The user stopped this computer session. Start a new task after they allow control in the menu bar." } };
195 } else if (["close_session", "release_session_input"].includes(req.tool)) {
196 try {
197 if (req.tool === "close_session") await closeSession(req.sessionId);
198 else await releaseSessionInput(req.sessionId);
199 reply = { ok: true, closed: req.tool === "close_session", inputReleased: true };
200 } catch (err) {
201 reply = { ok: false, error: { code: "input_release_failed", message: String(err?.message ?? err) } };
202 }
203 } else reply = await handle(req, { computerId: "local", sessionId: req.sessionId, signal: controller.signal, persistentInputOwner: true });
204 if (!conn.destroyed) conn.write(JSON.stringify(reply) + "\n");
205 });
206 }
207 });
208 }
209
210 const connections = new Set();
211 const server = net.createServer(conn => {
212 connections.add(conn);
213 conn.once("close", () => connections.delete(conn));
214 serve(conn);
215 });
216 server.on("error", (err) => { log(`socket error: ${err.message}`); process.exit(1); });
217 server.listen(sock, () => {
218 if (process.platform !== "win32") { try { fs.chmodSync(sock, 0o600); } catch {} }
219 fs.writeFileSync(runInfoPath(), JSON.stringify(appInfo(), null, 2) + "\n");
220 if (bundle) {
221 // Launching the bundle once is what registers it: the MCP server reads this
222 // record to bring the app up on demand.
223 try {
224 const launch = process.env.CODEWHALE_CU_APP_LAUNCH ? JSON.parse(process.env.CODEWHALE_CU_APP_LAUNCH) : defaultLaunch(bundle);
225 writeRegistration({ id: APP_ID, path: bundle, launch });
226 } catch (err) { log(`could not record launch command: ${err.message}`); }
227 }
228 log(`v${APP_VERSION} listening on ${sock}${bundle ? ` (bundle ${bundle})` : " (bare, no bundle identity)"}`);
229 });
230
231 async function shutdown(signal) {
232 if (shuttingDown) return;
233 shuttingDown = true;
234 log(`${signal}; shutting down`);
235 server.close();
236 // Windows keeps a named-pipe instance bound while accepted connections
237 // remain open. Retire those owners before allowing a replacement listener.
238 // Their close handlers abort work; closeAllSessions still awaits cleanup.
239 for (const conn of connections) conn.destroy();
240 const timer = setTimeout(() => process.exit(1), 3_000);
241 const results = await closeAllSessions();
242 clearTimeout(timer);
243 for (const result of results) {
244 if (result.status === "rejected") log(`input cleanup failed: ${result.reason?.message ?? result.reason}`);
245 }
246 try {
247 if (JSON.parse(fs.readFileSync(runInfoPath(), "utf8")).pid === process.pid) fs.unlinkSync(runInfoPath());
248 } catch {}
249 // net.Server owns its Unix socket and removes it on close. Cleanup may
250 // finish after a replacement has bound the path; never unlink its socket.
251 process.exit(0);
252 }
253 for (const s of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(s, () => shutdown(s));
254
254 lines Plain Text