| 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 |