From a159660c3e11eeb8d9c5e54236200de9d92e2c75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn?= Date: Tue, 11 Aug 2026 21:36:04 -0300 Subject: [PATCH 1/4] Add Windows support: CUA daemon wiring, platform-gated macOS features, NSIS packaging, .cmd CLI shims - electron/cua.mjs: resolve cua-driver per platform (trycua Cua app under %LOCALAPPDATA%, PATH via where) and probe daemon liveness with 'cua-driver status' on Windows (named pipes can't be socket-probed); embedded host works on win32 via the SDK's native DLL - electron/main.mjs: gate TCC permission flows (macOS-only) and native dictation; Windows deep-links to ms-settings privacy panes; default window frame outside macOS - electron/speech.mjs + Composer: dictation stays macOS-only, mic button hidden elsewhere - electron-builder.yml: NSIS x64 target + icon.ico; mac-only Swift helpers moved to mac.extraResources so Windows builds don't require swiftc - package.json: pnpm.onlyBuiltDependencies (pnpm 10 blocks electron postinstall) + package:win script - server/drivers/cli.ts: resolve .cmd shims (npm-global CLIs on Windows can't be spawned by child_process); codex/claude drivers now detect codex.cmd correctly - README: Windows install + platform notes --- README.md | 23 +++--- build/icon.ico | Bin 0 -> 64862 bytes dist-server/drivers/claude.js | 18 ++--- dist-server/drivers/cli.js | 65 +++++++++++++++++ dist-server/drivers/codex.js | 10 +-- electron-builder.yml | 19 +++-- electron/cua.mjs | 68 ++++++++++++++++-- electron/main.mjs | 63 +++++++++++------ electron/preload.cjs | 4 +- electron/speech.mjs | 1 + package.json | 9 ++- server/drivers/claude.ts | 29 ++++---- server/drivers/cli.ts | 76 ++++++++++++++++++++ server/drivers/codex.ts | 12 ++-- src/components/Composer.tsx | 5 +- src/components/Onboarding.tsx | 128 ++++++++++++++++++---------------- src/types/ogb.d.ts | 2 + 17 files changed, 392 insertions(+), 140 deletions(-) create mode 100644 build/icon.ico create mode 100644 dist-server/drivers/cli.js create mode 100644 server/drivers/cli.ts diff --git a/README.md b/README.md index 2b332ba57f..4f22638c56 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ providers dimmed with the reason. Switch a bot's model mid-conversation. ### πŸ–₯️ Every bot gets a computer Open the Computer panel and the bot's cloud desktop spins up on its own β€” live screen preview while it -works, "Open desktop" to take over in your browser, or point the bot at *this Mac* instead. +works, "Open desktop" to take over in your browser, or point the bot at *this computer* instead. Computer panel with live screen preview @@ -134,7 +134,7 @@ flowchart LR REG[Driver registry] --> BUS[Event bus β†’ SSE] BROKER[Permission broker] end - subgraph agents ["Agents on your Mac"] + subgraph agents ["Agents on your machine"] CL[claude CLI] CX[codex CLI] end @@ -152,12 +152,13 @@ flowchart LR | Harness | `server/harness/` | Registry (configs β†’ live instances) and the fan-in event bus every client folds. | | API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, connectors, config β€” HTTP + SSE. | | App | `src/` | The chat shell. Server-backed store, one reducer, zero client-side transports. | -| Desktop | `electron/` | macOS shell: dictation helper (SFSpeechRecognizer), local screen capture, CUA bridge. | +| Desktop | `electron/` | Desktop shell: macOS dictation helper (SFSpeechRecognizer), local screen capture, CUA bridge. | ## Quick start -**Easiest:** [download the latest .dmg](https://github.com/milind-soni/openmausbot-releases/releases/latest), -drag it to Applications, open it. The harness server is embedded β€” no setup. +**Easiest:** [download the latest release](https://github.com/milind-soni/openmausbot-releases/releases/latest) +β€” `.dmg` for macOS (drag to Applications) or `.exe` for Windows (NSIS installer). The harness server is +embedded β€” no setup. **From source:** @@ -170,10 +171,15 @@ pnpm dev # app β†’ http://127.0.0.1:5199 pnpm dev:desktop # or the Electron shell ``` -Requirements: **macOS**, **Node 24+**, **pnpm**, and at least one agent CLI β€” [`claude`](https://claude.com/claude-code) +Requirements: **macOS or Windows**, **Node 24+**, **pnpm**, and at least one agent CLI β€” [`claude`](https://claude.com/claude-code) or [`codex`](https://github.com/openai/codex) β€” installed and logged in. They appear in the model picker automatically. +**Platform notes:** computer use resolves the `cua-driver` binary automatically β€” the CuaDriver app +(macOS) or the trycua Cua app / `cua-driver.exe` on PATH (Windows) β€” and reuses a running daemon when one +is up. Native dictation is macOS-only (Swift/SFSpeechRecognizer); the composer mic button is hidden +elsewhere. Windows packaging: `pnpm package:win`. + Optional, pasted once in **App Settings** (gear in the sidebar footer): | Key | Unlocks | @@ -190,8 +196,9 @@ pnpm build # typecheck + production build ## Status Early but real β€” the loop works end to end: message β†’ agent β†’ streamed reply β†’ tools β†’ approvals β†’ -computer use. Rough edges to expect: routines (scheduled tasks) are a placeholder, sidebar sections aren't -built yet, and Windows/Linux shells haven't been attempted (the harness itself is portable Node). +computer use, on macOS and Windows. Rough edges to expect: routines (scheduled tasks) are a placeholder, +sidebar sections aren't built yet, native dictation is macOS-only, and Linux packaging hasn't been +attempted (the harness itself is portable Node). Contributions welcome β€” the driver SPI in [`server/contracts.ts`](server/contracts.ts) is deliberately small; adding a provider is one file in [`server/drivers/`](server/drivers/) plus a one-line registration. diff --git a/build/icon.ico b/build/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..dd2639d06cb81230d14b3b8056e4973239f15ec7 GIT binary patch literal 64862 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 { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim())); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; const authenticated = existsSync(join(homedir(), ".claude", ".credentials.json")); @@ -430,9 +429,12 @@ export const ClaudeDriver = { return () => listeners.delete(listener); }, }, - generateText: (prompt) => new Promise((resolve, reject) => { - execFile(config.cli, ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], { timeout: 60_000, env: { ...process.env } }, (err, stdout) => (err ? reject(err) : resolve(stdout.trim()))); - }), + generateText: async (prompt) => { + const res = await cliExec(config.cli, ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], { timeout: 60_000, env: { ...process.env } }); + if (!res.ok) + throw new Error(res.stderr || `\`${config.cli}\` failed`); + return res.stdout.trim(); + }, dispose: async () => { for (const { stop } of active.values()) stop(); diff --git a/dist-server/drivers/cli.js b/dist-server/drivers/cli.js new file mode 100644 index 0000000000..830dfcded3 --- /dev/null +++ b/dist-server/drivers/cli.js @@ -0,0 +1,65 @@ +// Windows CLI-shim helpers. +// +// CLIs installed via npm/yarn/pnpm on Windows ship as .cmd batch shims +// (e.g. `codex.cmd` in %APPDATA%\npm). child_process cannot execute .cmd +// files directly β€” spawn/execFile fail with ENOENT unless the command runs +// through cmd.exe. Native installers (the claude installer β†’ claude.exe) +// are unaffected. These helpers resolve the real path via `where` and +// degrade gracefully to the raw name. +import { execFile, spawnSync } from "node:child_process"; +const IS_WIN = process.platform === "win32"; +const SHIM_RE = /\.(cmd|bat)$/i; +/** Resolve a CLI name to an executable path cmd/spawn can actually run. */ +export function resolveCli(cli) { + if (!IS_WIN) + return cli; + try { + const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000 }); + if (out.status !== 0 || !out.stdout) + return cli; + const candidates = out.stdout.split(/\r?\n/).map((c) => c.trim()).filter(Boolean); + const exe = candidates.find((c) => /\.exe$/i.test(c)); + if (exe) + return exe; + const shim = candidates.find((c) => SHIM_RE.test(c)); + return shim ?? cli; + } + catch { + return cli; + } +} +/** Spawn options that make a .cmd shim runnable (through cmd.exe). */ +export function cliSpawnShell(cli) { + return IS_WIN && SHIM_RE.test(resolveCli(cli)) ? { shell: true } : {}; +} +/** execFile equivalent that also works for .cmd shims on Windows. */ +export function cliExec(cli, args, opts = {}) { + const resolved = resolveCli(cli); + if (IS_WIN && SHIM_RE.test(resolved)) { + return new Promise((resolve) => { + try { + const out = spawnSync(resolved, args, { + encoding: "utf8", + timeout: opts.timeout, + env: opts.env, + shell: true, + }); + resolve({ + ok: out.status === 0, + stdout: out.stdout ?? "", + stderr: out.stderr ?? "", + }); + } + catch (e) { + resolve({ ok: false, stdout: "", stderr: String(e) }); + } + }); + } + return new Promise((resolve) => { + execFile(resolved, args, { timeout: opts.timeout, env: opts.env }, (err, stdout, stderr) => resolve({ ok: !err, stdout, stderr: stderr ?? "" })); + }); +} +/** `cli --version` probe; null when the CLI is missing or errors. */ +export function cliVersion(cli, timeoutMs = 8000) { + return cliExec(cli, ["--version"], { timeout: timeoutMs }).then((r) => r.ok && r.stdout.trim() ? r.stdout.trim() : null); +} diff --git a/dist-server/drivers/codex.js b/dist-server/drivers/codex.js index 2963d78b69..10c7167bcc 100644 --- a/dist-server/drivers/codex.js +++ b/dist-server/drivers/codex.js @@ -9,10 +9,11 @@ // // resumeCursor is the codex thread id; a later turn tries thread/resume // and falls back to a fresh thread/start. -import { spawn, execFile } from "node:child_process"; +import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { newEventId, newId } from "../contracts.js"; import { appendNative } from "./native.js"; +import { cliSpawnShell, cliVersion, resolveCli } from "./cli.js"; const DRIVER_KIND = "codex"; // catalog ported from upstream packages/contracts/src/model.ts const MODELS = { @@ -62,7 +63,8 @@ export const CodexDriver = { // the CLI owns its own ChatGPT login; a leaked API key silently flips // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const child = spawn(config.cli, ["app-server"], { + const child = spawn(resolveCli(config.cli), ["app-server"], { + ...cliSpawnShell(config.cli), cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -328,9 +330,7 @@ export const CodexDriver = { return { turnId }; }; const snapshot = async () => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => resolve(err ? null : stdout.trim())); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; return { state: "available", version }; diff --git a/electron-builder.yml b/electron-builder.yml index 78361dc4e0..a7092730b9 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -25,12 +25,16 @@ extraResources: to: ui - from: dist-server to: server - - from: electron/resources/speech-helper - to: speech-helper - - from: electron/resources/perm-helper - to: perm-helper +# macOS-only helpers (Swift binaries built by pnpm build:speech/build:perm); +# Windows/Linux builds don't ship them β€” dictation is macOS-only and the +# screen-permission helper is CGRequestScreenCaptureAccess (TCC). mac: + extraResources: + - from: electron/resources/speech-helper + to: speech-helper + - from: electron/resources/perm-helper + to: perm-helper target: - target: dmg arch: arm64 @@ -52,3 +56,10 @@ mac: dmg: sign: true artifactName: OpenMausBot-${version}.dmg + +win: + target: + - target: nsis + arch: + - x64 + icon: build/icon.ico diff --git a/electron/cua.mjs b/electron/cua.mjs index 360c518167..c3817bc27f 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -20,12 +20,46 @@ import fs from "node:fs"; import net from "node:net"; import path from "node:path"; -const INSTALLED_DRIVER = "/Applications/CuaDriver.app/Contents/MacOS/cua-driver"; +const HOST_BUNDLE_ID = "com.openmausbot.app"; +const IS_WIN = process.platform === "win32"; + +// Driver locations outside the app bundle. macOS ships CuaDriver.app; on +// Windows the trycua Cua app installs under %LOCALAPPDATA%\Programs, and +// cua-driver.exe may also be on PATH. const STANDALONE_SOCKET = path.join( app.getPath("home"), "Library/Caches/cua-driver/cua-driver.sock", ); -const HOST_BUNDLE_ID = "com.openmausbot.app"; +const INSTALLED_DRIVER_CANDIDATES = IS_WIN + ? [ + path.join( + process.env.LOCALAPPDATA ?? path.join(app.getPath("home"), "AppData", "Local"), + "Programs", + "Cua", + "cua-driver", + "bin", + "cua-driver.exe", + ), + path.join( + process.env.LOCALAPPDATA ?? path.join(app.getPath("home"), "AppData", "Local"), + "Programs", + "CuaDriver", + "cua-driver.exe", + ), + path.join(process.env.PROGRAMFILES ?? "C:\\Program Files", "CuaDriver", "cua-driver.exe"), + ] + : ["/Applications/CuaDriver.app/Contents/MacOS/cua-driver"]; + +function driverOnPath() { + const find = IS_WIN ? "where" : "which"; + try { + const out = spawnSync(find, ["cua-driver"], { encoding: "utf8", timeout: 5000 }); + if (out.status === 0 && out.stdout) return out.stdout.split(/\r?\n/)[0].trim(); + } catch { + /* no PATH lookup available */ + } + return null; +} let embeddedHost = null; // EmbeddedCuaDriverHost | null let connection = null; // descriptor exposed to harness + renderer @@ -36,8 +70,10 @@ export function resolveDriverBinary() { const bundled = path.join(process.resourcesPath, "cua-driver"); if (fs.existsSync(bundled)) return bundled; } - if (fs.existsSync(INSTALLED_DRIVER)) return INSTALLED_DRIVER; - return null; + for (const candidate of INSTALLED_DRIVER_CANDIDATES) { + if (fs.existsSync(candidate)) return candidate; + } + return driverOnPath(); } function socketAlive(sockPath) { @@ -69,6 +105,22 @@ async function startEmbedded(binary) { }; } +async function daemonRunning() { + // On Windows the daemon listens on a named pipe that a unix-style socket + // connect can't probe; `cua-driver status` reports liveness cross-platform. + if (IS_WIN) { + const binary = resolveDriverBinary(); + if (!binary) return false; + try { + const out = spawnSync(binary, ["status"], { encoding: "utf8", timeout: 5000 }); + return out.status === 0 && /daemon is running/i.test(out.stdout); + } catch { + return false; + } + } + return socketAlive(STANDALONE_SOCKET); +} + export async function startCua() { const binary = resolveDriverBinary(); if (!binary) { @@ -88,11 +140,13 @@ export async function startCua() { reason: `embedded host failed: ${err?.message ?? err}`, }; } - } else if (await socketAlive(STANDALONE_SOCKET)) { - // Dev machine with CuaDriver.app's daemon already running. + } else if (await daemonRunning()) { + // Dev machine with a cua-driver daemon already running (CuaDriver.app on + // macOS, the trycua Cua app / `cua-driver serve` on Windows). The MCP + // proxy discovers the daemon itself, so no socket path is needed. connection = { mode: "standalone", - socketPath: STANDALONE_SOCKET, + ...(IS_WIN ? {} : { socketPath: STANDALONE_SOCKET }), mcpCommand: binary, mcpArgs: ["mcp"], mcpEnv: {}, diff --git a/electron/main.mjs b/electron/main.mjs index ae68ee3219..5fd4ce9171 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -11,6 +11,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEV_URL = process.env.ELECTRON_START_URL ?? "http://127.0.0.1:5199"; let SERVER_PORT = 8799; const APP_ICON = path.join(__dirname, "resources/app-icon.png"); +const IS_MAC = process.platform === "darwin"; // Packaged: the harness server ships in Resources (compiled JS, zero deps) // and runs on Electron's own Node via utilityProcess. It serves the built @@ -78,7 +79,7 @@ async function startServerPackaged() { const ERROR_PAGE = "data:text/html;charset=utf-8," + encodeURIComponent( - `
🐭

Couldn't start the bot server

Something else is using its ports. Quit and reopen OpenMausBot β€” if it keeps happening, restart your Mac.

`, + `
🐭

Couldn't start the bot server

Something else is using its ports. Quit and reopen OpenMausBot β€” if it keeps happening, restart your computer.

`, ); function createWindow() { @@ -89,8 +90,9 @@ function createWindow() { minHeight: 600, icon: APP_ICON, backgroundColor: "#070707", - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 16 }, + // hiddenInset + traffic lights are macOS-only window chrome; other + // platforms get the default frame + ...(IS_MAC ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } } : {}), webPreferences: { contextIsolation: true, preload: path.join(__dirname, "preload.cjs"), @@ -109,8 +111,8 @@ function createWindow() { } } -// "This Mac" screen preview β€” served from the main process so the Screen -// Recording permission prompt attributes to the app, never the server +// "This computer" screen preview β€” served from the main process so the +// Screen Recording permission prompt attributes to the app, never the server ipcMain.handle("screen:frame", async () => { const sources = await desktopCapturer.getSources({ types: ["screen"], @@ -119,14 +121,15 @@ ipcMain.handle("screen:frame", async () => { return sources[0]?.thumbnail.toDataURL() ?? null; }); -// Onboarding permission checks. Status reads are free; the mic request -// pops the real TCC prompt attributed to the app. Screen Recording has no -// programmatic request β€” the first desktopCapturer call prompts. +// Onboarding permission checks. macOS: TCC prompts attributed to the app. +// Windows has no TCC β€” devices are gated by Windows itself at first use, so +// the checks report the OS state and the request handlers are no-ops. ipcMain.handle("perm:status", () => ({ mic: systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown", screen: systemPreferences.getMediaAccessStatus?.("screen") ?? "unknown", })); ipcMain.handle("perm:request-mic", async () => { + if (!IS_MAC) return true; // no programmatic prompt on Windows/Linux try { return await systemPreferences.askForMediaAccess("microphone"); } catch { @@ -137,37 +140,51 @@ ipcMain.handle("perm:request-mic", async () => { // registers a capture attempt, and Electron's thumbnail API doesn't always // register one on newer macOS. A child `screencapture` probe inherits the // app's TCC identity β€” it registers OpenMausBot in the pane and triggers -// the system dialog on first use. +// the system dialog on first use. macOS-only (CGRequestScreenCaptureAccess). const PERM_HELPER = app.isPackaged ? path.join(process.resourcesPath, "perm-helper") : path.join(__dirname, "resources", "perm-helper"); ipcMain.handle("perm:request-screen", async () => { - // CGRequestScreenCaptureAccess via the helper β€” registers the app in the - // pane and shows the system dialog; child inherits the app's TCC identity - await new Promise((resolve) => { - execFile(PERM_HELPER, ["request"], { timeout: 15_000 }, () => resolve()); - }); + if (IS_MAC) { + await new Promise((resolve) => { + execFile(PERM_HELPER, ["request"], { timeout: 15_000 }, () => resolve()); + }); + } return systemPreferences.getMediaAccessStatus?.("screen") ?? "unknown"; }); // macOS never re-prompts a denied permission β€” the only path is System -// Settings; deep-link straight to the right privacy pane. +// Settings; deep-link straight to the right privacy pane. Windows deep-links +// into the corresponding Settings page instead. ipcMain.handle("perm:open-settings", (_event, pane) => { - const panes = { - mic: "Privacy_Microphone", - screen: "Privacy_ScreenCapture", - speech: "Privacy_SpeechRecognition", + if (IS_MAC) { + const panes = { + mic: "Privacy_Microphone", + screen: "Privacy_ScreenCapture", + speech: "Privacy_SpeechRecognition", + }; + return shell.openExternal( + `x-apple.systempreferences:com.apple.preference.security?${panes[pane] ?? "Privacy"}`, + ); + } + const winPanes = { + mic: "ms-settings:privacy-microphone", + screen: "ms-settings:privacy-broadcasting", + speech: "ms-settings:privacy-speech", }; - return shell.openExternal( - `x-apple.systempreferences:com.apple.preference.security?${panes[pane] ?? "Privacy"}`, - ); + return shell.openExternal(winPanes[pane] ?? "ms-settings:privacy"); }); +// Native dictation is macOS-only (Swift + SFSpeechRecognizer); the IPC +// surface stays registered so the renderer's platform flag can rely on it. ipcMain.handle("speech:start", (event) => { + if (!IS_MAC) return; const win = BrowserWindow.fromWebContents(event.sender); if (win) startSpeech(win); }); -ipcMain.handle("speech:stop", () => stopSpeech()); +ipcMain.handle("speech:stop", () => { + if (IS_MAC) stopSpeech(); +}); app.whenReady().then(async () => { if (process.platform === "darwin") app.dock.setIcon(APP_ICON); diff --git a/electron/preload.cjs b/electron/preload.cjs index 0821e8a9b1..6a74648e1f 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -3,7 +3,9 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("ogb", { - /** One frame of this Mac's screen as a data: URL (Screen Recording TCC). */ + /** process.platform of the desktop host: darwin | win32 | linux. */ + platform: process.platform, + /** One frame of this computer's screen as a data: URL (macOS Screen Recording TCC / Windows desktop capture). */ screenFrame: () => ipcRenderer.invoke("screen:frame"), speechStart: () => ipcRenderer.invoke("speech:start"), speechStop: () => ipcRenderer.invoke("speech:stop"), diff --git a/electron/speech.mjs b/electron/speech.mjs index e71d19084d..f50c505d7d 100644 --- a/electron/speech.mjs +++ b/electron/speech.mjs @@ -27,6 +27,7 @@ function ensureBuilt() { } export function startSpeech(win) { + if (process.platform !== "darwin") return; // Swift/SFSpeechRecognizer helper is macOS-only stopSpeech(); ensureBuilt(); const proc = spawn(BIN, [], { stdio: ["ignore", "pipe", "pipe"] }); diff --git a/package.json b/package.json index 8b68dbe6fa..1344a9d8c2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,14 @@ "build:server": "tsc -p tsconfig.server.build.json", "build:speech": "swiftc -O electron/resources/speech-helper.swift -o electron/resources/speech-helper", "package": "pnpm build && pnpm build:server && pnpm build:speech && pnpm build:perm && electron-builder --mac --publish never", - "build:perm": "swiftc -O electron/resources/perm-helper.swift -o electron/resources/perm-helper" + "build:perm": "swiftc -O electron/resources/perm-helper.swift -o electron/resources/perm-helper", + "package:win": "pnpm build && pnpm build:server && electron-builder --win --publish never" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "electron", + "esbuild" + ] }, "dependencies": { "@trycua/cua-driver": "^0.19.3", diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index b1f213d6d7..2797c324e9 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -9,7 +9,6 @@ // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts // β€” screenshot/exec/open_url, the CUA-on-the-box bridge import { spawn } from "node:child_process"; -import { execFile } from "node:child_process"; import { existsSync, unlinkSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { homedir } from "node:os"; @@ -28,6 +27,7 @@ import type { SendTurnInput, } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; +import { cliExec, cliSpawnShell, cliVersion, resolveCli } from "./cli.ts"; import { appendNative } from "./native.ts"; const DRIVER_KIND = "claudeAgent"; @@ -310,7 +310,8 @@ export const ClaudeDriver: ProviderDriver = { delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const child = spawn(config.cli, args, { + const child = spawn(resolveCli(config.cli), args, { + ...cliSpawnShell(config.cli), cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -432,11 +433,7 @@ export const ClaudeDriver: ProviderDriver = { }; const snapshot = async (): Promise => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => - resolve(err ? null : stdout.trim()), - ); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; const authenticated = existsSync(join(homedir(), ".claude", ".credentials.json")); return { state: "available", version, authenticated }; @@ -471,15 +468,15 @@ export const ClaudeDriver: ProviderDriver = { return () => listeners.delete(listener); }, }, - generateText: (prompt: string) => - new Promise((resolve, reject) => { - execFile( - config.cli, - ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], - { timeout: 60_000, env: { ...process.env } }, - (err, stdout) => (err ? reject(err) : resolve(stdout.trim())), - ); - }), + generateText: async (prompt: string) => { + const res = await cliExec( + config.cli, + ["-p", prompt, "--model", "claude-haiku-4-5", "--output-format", "text"], + { timeout: 60_000, env: { ...process.env } }, + ); + if (!res.ok) throw new Error(res.stderr || `\`${config.cli}\` failed`); + return res.stdout.trim(); + }, dispose: async () => { for (const { stop } of active.values()) stop(); listeners.clear(); diff --git a/server/drivers/cli.ts b/server/drivers/cli.ts new file mode 100644 index 0000000000..906d416f11 --- /dev/null +++ b/server/drivers/cli.ts @@ -0,0 +1,76 @@ +// Windows CLI-shim helpers. +// +// CLIs installed via npm/yarn/pnpm on Windows ship as .cmd batch shims +// (e.g. `codex.cmd` in %APPDATA%\npm). child_process cannot execute .cmd +// files directly β€” spawn/execFile fail with ENOENT unless the command runs +// through cmd.exe. Native installers (the claude installer β†’ claude.exe) +// are unaffected. These helpers resolve the real path via `where` and +// degrade gracefully to the raw name. +import { execFile, spawnSync } from "node:child_process"; + +const IS_WIN = process.platform === "win32"; +const SHIM_RE = /\.(cmd|bat)$/i; + +/** Resolve a CLI name to an executable path cmd/spawn can actually run. */ +export function resolveCli(cli: string): string { + if (!IS_WIN) return cli; + try { + const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000 }); + if (out.status !== 0 || !out.stdout) return cli; + const candidates = out.stdout.split(/\r?\n/).map((c) => c.trim()).filter(Boolean); + const exe = candidates.find((c) => /\.exe$/i.test(c)); + if (exe) return exe; + const shim = candidates.find((c) => SHIM_RE.test(c)); + return shim ?? cli; + } catch { + return cli; + } +} + +/** Spawn options that make a .cmd shim runnable (through cmd.exe). */ +export function cliSpawnShell(cli: string): { shell?: true } { + return IS_WIN && SHIM_RE.test(resolveCli(cli)) ? { shell: true } : {}; +} + +/** execFile equivalent that also works for .cmd shims on Windows. */ +export function cliExec( + cli: string, + args: string[], + opts: { timeout?: number; env?: NodeJS.ProcessEnv } = {}, +): Promise<{ ok: boolean; stdout: string; stderr: string }> { + const resolved = resolveCli(cli); + if (IS_WIN && SHIM_RE.test(resolved)) { + return new Promise((resolve) => { + try { + const out = spawnSync(resolved, args, { + encoding: "utf8", + timeout: opts.timeout, + env: opts.env, + shell: true, + }); + resolve({ + ok: out.status === 0, + stdout: out.stdout ?? "", + stderr: out.stderr ?? "", + }); + } catch (e) { + resolve({ ok: false, stdout: "", stderr: String(e) }); + } + }); + } + return new Promise((resolve) => { + execFile( + resolved, + args, + { timeout: opts.timeout, env: opts.env }, + (err, stdout, stderr) => resolve({ ok: !err, stdout, stderr: stderr ?? "" }), + ); + }); +} + +/** `cli --version` probe; null when the CLI is missing or errors. */ +export function cliVersion(cli: string, timeoutMs = 8000): Promise { + return cliExec(cli, ["--version"], { timeout: timeoutMs }).then((r) => + r.ok && r.stdout.trim() ? r.stdout.trim() : null, + ); +} diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 4165958933..78eed34239 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -9,7 +9,7 @@ // // resumeCursor is the codex thread id; a later turn tries thread/resume // and falls back to a fresh thread/start. -import { spawn, execFile } from "node:child_process"; +import { spawn } from "node:child_process"; import { homedir } from "node:os"; import type { @@ -23,6 +23,7 @@ import type { } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; import { appendNative } from "./native.ts"; +import { cliSpawnShell, cliVersion, resolveCli } from "./cli.ts"; const DRIVER_KIND = "codex"; @@ -91,7 +92,8 @@ export const CodexDriver: ProviderDriver = { // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const child = spawn(config.cli, ["app-server"], { + const child = spawn(resolveCli(config.cli), ["app-server"], { + ...cliSpawnShell(config.cli), cwd: turn.cwd ?? homedir(), env, stdio: ["pipe", "pipe", "pipe"], @@ -357,11 +359,7 @@ export const CodexDriver: ProviderDriver = { }; const snapshot = async (): Promise => { - const version = await new Promise((resolve) => { - execFile(config.cli, ["--version"], { timeout: 8000 }, (err, stdout) => - resolve(err ? null : stdout.trim()), - ); - }); + const version = await cliVersion(config.cli); if (!version) return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` }; return { state: "available", version }; }; diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index d0a61b4065..ef96696d9a 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -9,6 +9,9 @@ export function Composer({ bot }: { bot: Bot }) { const [text, setText] = useState(""); const [recording, setRecording] = useState(false); const [speechError, setSpeechError] = useState(null); + // native dictation (Swift/SFSpeechRecognizer) is macOS-only; hide the mic + // button elsewhere (browser dev keeps it β€” it explains how to run the app) + const showMic = !window.ogb || window.ogb.platform === "darwin"; // what was typed before the mic went on β€” partials append after it const baseText = useRef(""); @@ -94,7 +97,7 @@ export function Composer({ bot }: { bot: Bot }) { > - ) : ( + ) : showMic && ( + ) : ( + + )} - {perms?.mic === "granted" ? ( - - ) : perms?.mic === "denied" || perms?.mic === "restricted" ? ( - - ) : ( - - )} - -
-
- -
-
Screen preview
-
- Shows this Mac’s screen in the Computer panel when a bot works locally. +
+
+ +
+
Screen preview
+
+ Shows this computer’s screen in the Computer panel when a bot works locally. +
+ {perms?.screen === "granted" ? ( + + ) : perms?.screen === "denied" || perms?.screen === "restricted" ? ( + + ) : ( + + )}
- {perms?.screen === "granted" ? ( - - ) : perms?.screen === "denied" || perms?.screen === "restricted" ? ( - - ) : ( - - )}
-
+ ) : ( +
+ On this OS, permissions are handled by the system β€” you’ll be + prompted the first time a bot needs the microphone or your screen. +
+ )} diff --git a/src/types/ogb.d.ts b/src/types/ogb.d.ts index 50374db078..b1d74a5377 100644 --- a/src/types/ogb.d.ts +++ b/src/types/ogb.d.ts @@ -4,6 +4,8 @@ export {}; declare global { interface Window { ogb?: { + /** process.platform of the desktop host: darwin | win32 | linux. */ + platform: string; screenFrame(): Promise; speechStart(): Promise; speechStop(): Promise; From 801d5bb72168b6ad67abe68bfbe1669d539a2221 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn?= Date: Tue, 11 Aug 2026 21:54:35 -0300 Subject: [PATCH 2/4] Address review findings: userData cua-connection, named-pipe broker, shell-less CLI spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/index.ts: readCuaConnection now uses the real userData path (Electron main passes it via OMB_USER_DATA) with per-platform fallbacks β€” previously hardcoded ~/Library/Application Support, so local computer use silently never connected on Windows - server/drivers/claude.ts: permission broker socket is a \\.\pipe\ name on Windows (drive-letter paths are invalid pipe names, EACCES) β€” every permission ask used to auto-deny - server/drivers/cli.ts: resolve .cmd shims to their real JS entry and spawn process.execPath directly (no cmd.exe, no shell quoting hazards); all probes async (spawnSync was blocking the harness event loop up to 60s in generateText); `where` results cached; killProcessTree uses taskkill /T /F on Windows (process.kill(-pid) throws ESRCH and orphaned the CLI) - electron/cua.mjs: windowsHide on all child processes; daemon liveness probe is async (spawn) instead of a blocking spawnSync at startup - UI copy: 'this Mac' -> 'this computer' in Onboarding + ComputerPanel --- dist-server/drivers/claude.js | 25 ++--- dist-server/drivers/cli.js | 162 +++++++++++++++++++++++-------- dist-server/drivers/codex.js | 20 +--- dist-server/index.js | 23 ++++- electron/cua.mjs | 37 +++++-- electron/main.mjs | 2 + server/drivers/claude.ts | 22 ++--- server/drivers/cli.ts | 160 ++++++++++++++++++++++-------- server/drivers/codex.ts | 18 +--- server/index.ts | 24 ++++- src/components/ComputerPanel.tsx | 10 +- src/components/Onboarding.tsx | 2 +- 12 files changed, 341 insertions(+), 164 deletions(-) diff --git a/dist-server/drivers/claude.js b/dist-server/drivers/claude.js index b7f4bf1b01..f59a878817 100644 --- a/dist-server/drivers/claude.js +++ b/dist-server/drivers/claude.js @@ -16,7 +16,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { DATA_DIR } from "../config.js"; import { newEventId, newId } from "../contracts.js"; -import { cliExec, cliSpawnShell, cliVersion, resolveCli } from "./cli.js"; +import { cliExec, cliVersion, killProcessTree, resolveCliCommand } from "./cli.js"; import { appendNative } from "./native.js"; const DRIVER_KIND = "claudeAgent"; // model catalog ported from upstream packages/contracts/src/model.ts @@ -56,6 +56,11 @@ function askSummary(ask) { } function permissionSocketPath(threadId) { const tag = threadId.replace(/[^\w-]/g, "").slice(0, 8); + // Windows has no unix sockets; Node maps a listen() path to a named pipe, + // and drive-letter paths (with ':') are invalid pipe names (EACCES). + // Use an explicit \\.\pipe\ name β€” both sides get the same string via argv. + if (process.platform === "win32") + return `\\\\.\\pipe\\ogb-perm-${tag}`; return join(DATA_DIR, `perm-${tag}.sock`); } function createPermissionBroker(opts) { @@ -272,10 +277,10 @@ export const ClaudeDriver = { delete env.ANTHROPIC_API_KEY; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const child = spawn(resolveCli(config.cli), args, { - ...cliSpawnShell(config.cli), + const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); + const child = spawn(cliCommand, [...cliPrefix, ...args], { cwd: turn.cwd ?? homedir(), - env, + env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, // own process group: killing -pid reaps child MCP servers }); @@ -371,17 +376,7 @@ export const ClaudeDriver = { settle(false, "exit_before_result"); } }); - const stop = () => { - try { - process.kill(-child.pid, "SIGTERM"); - } - catch { - try { - child.kill("SIGTERM"); - } - catch { } - } - }; + const stop = () => killProcessTree(child.pid); active.set(threadId, { stop, turnId, broker }); emit({ ...base(threadId, turnId), type: "turn.started" }); // prompt over stdin as a stream-json message β€” never argv (ARG_MAX) diff --git a/dist-server/drivers/cli.js b/dist-server/drivers/cli.js index 830dfcded3..6c449c2d6c 100644 --- a/dist-server/drivers/cli.js +++ b/dist-server/drivers/cli.js @@ -1,65 +1,145 @@ -// Windows CLI-shim helpers. +// Windows CLI helpers. // // CLIs installed via npm/yarn/pnpm on Windows ship as .cmd batch shims // (e.g. `codex.cmd` in %APPDATA%\npm). child_process cannot execute .cmd // files directly β€” spawn/execFile fail with ENOENT unless the command runs -// through cmd.exe. Native installers (the claude installer β†’ claude.exe) -// are unaffected. These helpers resolve the real path via `where` and -// degrade gracefully to the raw name. +// through cmd.exe, and going through cmd.exe re-opens argv to its quoting +// and %VAR% expansion rules. Instead we resolve the shim to the real JS +// entry and run it with process.execPath β€” no shell at all. Native +// installers (the claude installer β†’ claude.exe) resolve to their .exe. +// +// All helpers are async (or spawn async children) β€” nothing here blocks +// the harness event loop. import { execFile, spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; const IS_WIN = process.platform === "win32"; const SHIM_RE = /\.(cmd|bat)$/i; -/** Resolve a CLI name to an executable path cmd/spawn can actually run. */ +// `where` results don't change often; cache per CLI so per-turn spawns +// don't pay a fresh `where` process each time. +const whereCache = new Map(); +const WHERE_TTL = 60_000; +/** Resolve a CLI name to a path that can actually be spawned. */ export function resolveCli(cli) { if (!IS_WIN) return cli; + const hit = whereCache.get(cli); + if (hit && Date.now() - hit.at < WHERE_TTL) + return hit.path; + let resolved = cli; try { - const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000 }); - if (out.status !== 0 || !out.stdout) - return cli; - const candidates = out.stdout.split(/\r?\n/).map((c) => c.trim()).filter(Boolean); - const exe = candidates.find((c) => /\.exe$/i.test(c)); - if (exe) - return exe; - const shim = candidates.find((c) => SHIM_RE.test(c)); - return shim ?? cli; + const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000, windowsHide: true }); + if (out.status === 0 && out.stdout) { + const candidates = out.stdout + .split(/\r?\n/) + .map((c) => c.trim()) + .filter(Boolean); + const exe = candidates.find((c) => /\.exe$/i.test(c)); + const shim = candidates.find((c) => SHIM_RE.test(c)); + resolved = exe ?? shim ?? cli; + } } catch { - return cli; + /* keep the raw name */ } + whereCache.set(cli, { path: resolved, at: Date.now() }); + return resolved; } -/** Spawn options that make a .cmd shim runnable (through cmd.exe). */ -export function cliSpawnShell(cli) { - return IS_WIN && SHIM_RE.test(resolveCli(cli)) ? { shell: true } : {}; +// npm/pnpm/yarn .cmd shims are thin wrappers that exec node on a JS entry +// (e.g. `"%dp0%\node_modules\@openai\codex\bin\codex.js" %*`). Extract that +// entry so we can spawn process.execPath directly and skip cmd.exe. +function shimScriptTarget(shim) { + try { + const text = readFileSync(shim, "utf8"); + const m = text.match(/"([^"]+\.(?:[cm]?js))"/); + if (!m) + return null; + const raw = m[1].replace(/%(?:~)?dp0%/gi, dirname(shim) + "\\"); + if (raw.includes("%")) + return null; // unknown var token β€” give up + return isAbsolute(raw) ? raw : resolve(dirname(shim), raw); + } + catch { + return null; + } } -/** execFile equivalent that also works for .cmd shims on Windows. */ -export function cliExec(cli, args, opts = {}) { +/** + * How to spawn a CLI on this platform: + * - POSIX: the raw name (resolved via PATH by the shell-less spawn). + * - Windows: the resolved .exe, or β€” for .cmd shims β€” node running the + * shim's real JS entry, which needs no cmd.exe at all. + * Falls back to the raw name when nothing better can be resolved. + */ +export function resolveCliCommand(cli) { + if (!IS_WIN) + return { command: cli, args: [] }; const resolved = resolveCli(cli); - if (IS_WIN && SHIM_RE.test(resolved)) { - return new Promise((resolve) => { - try { - const out = spawnSync(resolved, args, { - encoding: "utf8", - timeout: opts.timeout, - env: opts.env, - shell: true, - }); - resolve({ - ok: out.status === 0, - stdout: out.stdout ?? "", - stderr: out.stderr ?? "", - }); - } - catch (e) { - resolve({ ok: false, stdout: "", stderr: String(e) }); - } - }); + if (SHIM_RE.test(resolved)) { + const script = shimScriptTarget(resolved); + if (script) { + return { + command: process.execPath, + args: [script], + // packaged: process.execPath is the Electron binary; run as plain node + env: { ELECTRON_RUN_AS_NODE: "1" }, + }; + } } - return new Promise((resolve) => { - execFile(resolved, args, { timeout: opts.timeout, env: opts.env }, (err, stdout, stderr) => resolve({ ok: !err, stdout, stderr: stderr ?? "" })); + return { command: resolved, args: [] }; +} +/** execFile equivalent that also works for .cmd shims on Windows. */ +export function cliExec(cli, args, opts = {}) { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + return new Promise((resolvePromise) => { + const execOpts = { + timeout: opts.timeout, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }; + const cb = (err, stdout, stderr) => resolvePromise({ ok: !err, stdout, stderr: stderr ?? "" }); + // last-resort: a .cmd shim we couldn't unwrap runs through cmd.exe + if (IS_WIN && SHIM_RE.test(command)) { + execFile(command, [...prefix, ...args], { ...execOpts, shell: true }, cb); + } + else { + execFile(command, [...prefix, ...args], execOpts, cb); + } }); } /** `cli --version` probe; null when the CLI is missing or errors. */ export function cliVersion(cli, timeoutMs = 8000) { return cliExec(cli, ["--version"], { timeout: timeoutMs }).then((r) => r.ok && r.stdout.trim() ? r.stdout.trim() : null); } +/** + * Kill a spawned CLI and its whole process tree (child MCP servers, the + * codex app-server worker, etc.). POSIX uses the process group + * (children are spawned detached); Windows uses taskkill /T /F because + * process.kill(-pid) throws ESRCH and plain kill() leaves orphans. + */ +export function killProcessTree(pid) { + if (!pid) + return; + if (IS_WIN) { + try { + spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { + windowsHide: true, + timeout: 5000, + }); + } + catch { + /* already gone */ + } + return; + } + try { + process.kill(-pid, "SIGTERM"); + } + catch { + try { + process.kill(pid, "SIGTERM"); + } + catch { + /* already gone */ + } + } +} diff --git a/dist-server/drivers/codex.js b/dist-server/drivers/codex.js index 10c7167bcc..29759242ec 100644 --- a/dist-server/drivers/codex.js +++ b/dist-server/drivers/codex.js @@ -13,7 +13,7 @@ import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { newEventId, newId } from "../contracts.js"; import { appendNative } from "./native.js"; -import { cliSpawnShell, cliVersion, resolveCli } from "./cli.js"; +import { cliVersion, killProcessTree, resolveCliCommand } from "./cli.js"; const DRIVER_KIND = "codex"; // catalog ported from upstream packages/contracts/src/model.ts const MODELS = { @@ -63,10 +63,10 @@ export const CodexDriver = { // the CLI owns its own ChatGPT login; a leaked API key silently flips // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const child = spawn(resolveCli(config.cli), ["app-server"], { - ...cliSpawnShell(config.cli), + const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); + const child = spawn(cliCommand, [...cliPrefix, "app-server"], { cwd: turn.cwd ?? homedir(), - env, + env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, }); @@ -86,17 +86,7 @@ export const CodexDriver = { rpcPending.set(id, { resolve, reject }); send({ jsonrpc: "2.0", id, method, params }); }); - const stop = () => { - try { - process.kill(-child.pid, "SIGTERM"); - } - catch { - try { - child.kill("SIGTERM"); - } - catch { } - } - }; + const stop = () => killProcessTree(child.pid); const settle = (ok, stopReason) => { if (state.settled) return; diff --git a/dist-server/index.js b/dist-server/index.js index e15f26d809..dfb4c0c4fc 100644 --- a/dist-server/index.js +++ b/dist-server/index.js @@ -190,13 +190,26 @@ function stopScreenPoller(botId) { return entry.last; } // Local computer-use contract written by Electron main on startup -// (~/Library/Application Support/OpenMausBot/cua-connection.json). Read -// fresh each turn β€” Electron may restart or permissions may change. -function readCuaConnection() { +// (app.getPath("userData")/cua-connection.json β€” Electron main passes the +// exact path via OMB_USER_DATA; a standalone dev server falls back to +// per-platform userData locations). Read fresh each turn β€” Electron may +// restart or permissions may change. +function cuaConnectionCandidates() { + const explicit = process.env.OMB_USER_DATA; + if (explicit) + return [join(explicit, "cua-connection.json")]; + const roots = process.platform === "win32" + ? [process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")] + : process.platform === "darwin" + ? [join(homedir(), "Library", "Application Support")] + : [join(homedir(), ".config")]; // new name first; pre-rename desktop builds used the old directory - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + const dirs = ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]; + return roots.flatMap((root) => dirs.map((dir) => join(root, dir, "cua-connection.json"))); +} +function readCuaConnection() { + for (const p of cuaConnectionCandidates()) { try { - const p = join(homedir(), "Library", "Application Support", dir, "cua-connection.json"); const conn = JSON.parse(readFileSync(p, "utf8")); if (!conn || conn.mode === "unavailable" || !conn.mcpCommand) continue; diff --git a/electron/cua.mjs b/electron/cua.mjs index c3817bc27f..9200186a1e 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -15,7 +15,7 @@ // /cua-connection.json for the harness server to hand to drivers. import { app, ipcMain } from "electron"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import net from "node:net"; import path from "node:path"; @@ -53,7 +53,7 @@ const INSTALLED_DRIVER_CANDIDATES = IS_WIN function driverOnPath() { const find = IS_WIN ? "where" : "which"; try { - const out = spawnSync(find, ["cua-driver"], { encoding: "utf8", timeout: 5000 }); + const out = spawnSync(find, ["cua-driver"], { encoding: "utf8", timeout: 5000, windowsHide: true }); if (out.status === 0 && out.stdout) return out.stdout.split(/\r?\n/)[0].trim(); } catch { /* no PATH lookup available */ @@ -111,12 +111,32 @@ async function daemonRunning() { if (IS_WIN) { const binary = resolveDriverBinary(); if (!binary) return false; - try { - const out = spawnSync(binary, ["status"], { encoding: "utf8", timeout: 5000 }); - return out.status === 0 && /daemon is running/i.test(out.stdout); - } catch { - return false; - } + // async child β€” never block the main process on a dead daemon + return new Promise((resolveProbe) => { + let out = ""; + let settled = false; + const done = (ok) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolveProbe(ok); + }; + let child; + try { + child = spawn(binary, ["status"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); + } catch { + return done(false); + } + const timer = setTimeout(() => { + try { + child.kill(); + } catch {} + done(false); + }, 5000); + child.stdout.on("data", (d) => (out += d)); + child.on("error", () => done(false)); + child.on("close", (code) => done(code === 0 && /daemon is running/i.test(out))); + }); } return socketAlive(STANDALONE_SOCKET); } @@ -172,6 +192,7 @@ export function cuaPermissionsStatus() { const out = spawnSync(binary, ["permissions", "status", "--json"], { encoding: "utf8", timeout: 5000, + windowsHide: true, }); try { return { available: true, ...JSON.parse(out.stdout) }; diff --git a/electron/main.mjs b/electron/main.mjs index 5fd4ce9171..dc8fb6ea1b 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -28,6 +28,8 @@ async function startServerOn(port) { ...process.env, OMB_STATIC_DIR: path.join(process.resourcesPath, "ui"), OMB_PORT: String(port), + // exact userData so the harness can read cua-connection.json on any OS + OMB_USER_DATA: app.getPath("userData"), }, stdio: "inherit", }); diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index 2797c324e9..df828d962e 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -27,7 +27,7 @@ import type { SendTurnInput, } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; -import { cliExec, cliSpawnShell, cliVersion, resolveCli } from "./cli.ts"; +import { cliExec, cliVersion, killProcessTree, resolveCliCommand } from "./cli.ts"; import { appendNative } from "./native.ts"; const DRIVER_KIND = "claudeAgent"; @@ -91,6 +91,10 @@ function askSummary(ask: Ask): string { function permissionSocketPath(threadId: string) { const tag = threadId.replace(/[^\w-]/g, "").slice(0, 8); + // Windows has no unix sockets; Node maps a listen() path to a named pipe, + // and drive-letter paths (with ':') are invalid pipe names (EACCES). + // Use an explicit \\.\pipe\ name β€” both sides get the same string via argv. + if (process.platform === "win32") return `\\\\.\\pipe\\ogb-perm-${tag}`; return join(DATA_DIR, `perm-${tag}.sock`); } @@ -310,10 +314,10 @@ export const ClaudeDriver: ProviderDriver = { delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const child = spawn(resolveCli(config.cli), args, { - ...cliSpawnShell(config.cli), + const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); + const child = spawn(cliCommand, [...cliPrefix, ...args], { cwd: turn.cwd ?? homedir(), - env, + env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, // own process group: killing -pid reaps child MCP servers }); @@ -411,15 +415,7 @@ export const ClaudeDriver: ProviderDriver = { } }); - const stop = () => { - try { - process.kill(-child.pid!, "SIGTERM"); - } catch { - try { - child.kill("SIGTERM"); - } catch {} - } - }; + const stop = () => killProcessTree(child.pid); active.set(threadId, { stop, turnId, broker }); emit({ ...base(threadId, turnId), type: "turn.started" }); diff --git a/server/drivers/cli.ts b/server/drivers/cli.ts index 906d416f11..6185ca75e4 100644 --- a/server/drivers/cli.ts +++ b/server/drivers/cli.ts @@ -1,35 +1,91 @@ -// Windows CLI-shim helpers. +// Windows CLI helpers. // // CLIs installed via npm/yarn/pnpm on Windows ship as .cmd batch shims // (e.g. `codex.cmd` in %APPDATA%\npm). child_process cannot execute .cmd // files directly β€” spawn/execFile fail with ENOENT unless the command runs -// through cmd.exe. Native installers (the claude installer β†’ claude.exe) -// are unaffected. These helpers resolve the real path via `where` and -// degrade gracefully to the raw name. +// through cmd.exe, and going through cmd.exe re-opens argv to its quoting +// and %VAR% expansion rules. Instead we resolve the shim to the real JS +// entry and run it with process.execPath β€” no shell at all. Native +// installers (the claude installer β†’ claude.exe) resolve to their .exe. +// +// All helpers are async (or spawn async children) β€” nothing here blocks +// the harness event loop. import { execFile, spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; const IS_WIN = process.platform === "win32"; const SHIM_RE = /\.(cmd|bat)$/i; -/** Resolve a CLI name to an executable path cmd/spawn can actually run. */ +// `where` results don't change often; cache per CLI so per-turn spawns +// don't pay a fresh `where` process each time. +const whereCache = new Map(); +const WHERE_TTL = 60_000; + +/** Resolve a CLI name to a path that can actually be spawned. */ export function resolveCli(cli: string): string { if (!IS_WIN) return cli; + const hit = whereCache.get(cli); + if (hit && Date.now() - hit.at < WHERE_TTL) return hit.path; + let resolved = cli; try { - const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000 }); - if (out.status !== 0 || !out.stdout) return cli; - const candidates = out.stdout.split(/\r?\n/).map((c) => c.trim()).filter(Boolean); - const exe = candidates.find((c) => /\.exe$/i.test(c)); - if (exe) return exe; - const shim = candidates.find((c) => SHIM_RE.test(c)); - return shim ?? cli; + const out = spawnSync("where", [cli], { encoding: "utf8", timeout: 5000, windowsHide: true }); + if (out.status === 0 && out.stdout) { + const candidates = out.stdout + .split(/\r?\n/) + .map((c) => c.trim()) + .filter(Boolean); + const exe = candidates.find((c) => /\.exe$/i.test(c)); + const shim = candidates.find((c) => SHIM_RE.test(c)); + resolved = exe ?? shim ?? cli; + } } catch { - return cli; + /* keep the raw name */ } + whereCache.set(cli, { path: resolved, at: Date.now() }); + return resolved; } -/** Spawn options that make a .cmd shim runnable (through cmd.exe). */ -export function cliSpawnShell(cli: string): { shell?: true } { - return IS_WIN && SHIM_RE.test(resolveCli(cli)) ? { shell: true } : {}; +// npm/pnpm/yarn .cmd shims are thin wrappers that exec node on a JS entry +// (e.g. `"%dp0%\node_modules\@openai\codex\bin\codex.js" %*`). Extract that +// entry so we can spawn process.execPath directly and skip cmd.exe. +function shimScriptTarget(shim: string): string | null { + try { + const text = readFileSync(shim, "utf8"); + const m = text.match(/"([^"]+\.(?:[cm]?js))"/); + if (!m) return null; + const raw = m[1].replace(/%(?:~)?dp0%/gi, dirname(shim) + "\\"); + if (raw.includes("%")) return null; // unknown var token β€” give up + return isAbsolute(raw) ? raw : resolve(dirname(shim), raw); + } catch { + return null; + } +} + +/** + * How to spawn a CLI on this platform: + * - POSIX: the raw name (resolved via PATH by the shell-less spawn). + * - Windows: the resolved .exe, or β€” for .cmd shims β€” node running the + * shim's real JS entry, which needs no cmd.exe at all. + * Falls back to the raw name when nothing better can be resolved. + */ +export function resolveCliCommand( + cli: string, +): { command: string; args: string[]; env?: Record } { + if (!IS_WIN) return { command: cli, args: [] }; + const resolved = resolveCli(cli); + if (SHIM_RE.test(resolved)) { + const script = shimScriptTarget(resolved); + if (script) { + return { + command: process.execPath, + args: [script], + // packaged: process.execPath is the Electron binary; run as plain node + env: { ELECTRON_RUN_AS_NODE: "1" }, + }; + } + } + return { command: resolved, args: [] }; } /** execFile equivalent that also works for .cmd shims on Windows. */ @@ -38,33 +94,21 @@ export function cliExec( args: string[], opts: { timeout?: number; env?: NodeJS.ProcessEnv } = {}, ): Promise<{ ok: boolean; stdout: string; stderr: string }> { - const resolved = resolveCli(cli); - if (IS_WIN && SHIM_RE.test(resolved)) { - return new Promise((resolve) => { - try { - const out = spawnSync(resolved, args, { - encoding: "utf8", - timeout: opts.timeout, - env: opts.env, - shell: true, - }); - resolve({ - ok: out.status === 0, - stdout: out.stdout ?? "", - stderr: out.stderr ?? "", - }); - } catch (e) { - resolve({ ok: false, stdout: "", stderr: String(e) }); - } - }); - } - return new Promise((resolve) => { - execFile( - resolved, - args, - { timeout: opts.timeout, env: opts.env }, - (err, stdout, stderr) => resolve({ ok: !err, stdout, stderr: stderr ?? "" }), - ); + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + return new Promise((resolvePromise) => { + const execOpts = { + timeout: opts.timeout, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }; + const cb = (err: Error | null, stdout: string, stderr: string) => + resolvePromise({ ok: !err, stdout, stderr: stderr ?? "" }); + // last-resort: a .cmd shim we couldn't unwrap runs through cmd.exe + if (IS_WIN && SHIM_RE.test(command)) { + execFile(command, [...prefix, ...args], { ...execOpts, shell: true }, cb); + } else { + execFile(command, [...prefix, ...args], execOpts, cb); + } }); } @@ -74,3 +118,33 @@ export function cliVersion(cli: string, timeoutMs = 8000): Promise = { // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const child = spawn(resolveCli(config.cli), ["app-server"], { - ...cliSpawnShell(config.cli), + const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); + const child = spawn(cliCommand, [...cliPrefix, "app-server"], { cwd: turn.cwd ?? homedir(), - env, + env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, }); @@ -118,15 +118,7 @@ export const CodexDriver: ProviderDriver = { send({ jsonrpc: "2.0", id, method, params }); }); - const stop = () => { - try { - process.kill(-child.pid!, "SIGTERM"); - } catch { - try { - child.kill("SIGTERM"); - } catch {} - } - }; + const stop = () => killProcessTree(child.pid); const settle = (ok: boolean, stopReason: string | null) => { if (state.settled) return; diff --git a/server/index.ts b/server/index.ts index 3bd92f5d15..f08eb3bae1 100644 --- a/server/index.ts +++ b/server/index.ts @@ -200,13 +200,27 @@ function stopScreenPoller(botId: string): Frame | null { } // Local computer-use contract written by Electron main on startup -// (~/Library/Application Support/OpenMausBot/cua-connection.json). Read -// fresh each turn β€” Electron may restart or permissions may change. -function readCuaConnection(): { command: string; args: string[]; env: Record } | null { +// (app.getPath("userData")/cua-connection.json β€” Electron main passes the +// exact path via OMB_USER_DATA; a standalone dev server falls back to +// per-platform userData locations). Read fresh each turn β€” Electron may +// restart or permissions may change. +function cuaConnectionCandidates(): string[] { + const explicit = process.env.OMB_USER_DATA; + if (explicit) return [join(explicit, "cua-connection.json")]; + const roots = + process.platform === "win32" + ? [process.env.APPDATA ?? join(homedir(), "AppData", "Roaming")] + : process.platform === "darwin" + ? [join(homedir(), "Library", "Application Support")] + : [join(homedir(), ".config")]; // new name first; pre-rename desktop builds used the old directory - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + const dirs = ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]; + return roots.flatMap((root) => dirs.map((dir) => join(root, dir, "cua-connection.json"))); +} + +function readCuaConnection(): { command: string; args: string[]; env: Record } | null { + for (const p of cuaConnectionCandidates()) { try { - const p = join(homedir(), "Library", "Application Support", dir, "cua-connection.json"); const conn = JSON.parse(readFileSync(p, "utf8")); if (!conn || conn.mode === "unavailable" || !conn.mcpCommand) continue; return { command: conn.mcpCommand, args: conn.mcpArgs ?? ["mcp"], env: conn.mcpEnv ?? {} }; diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 3273a00e17..1123325225 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -1,6 +1,6 @@ // The bot's computer, in the right-side slot. Where it runs decides the // whole flow: cloud β†’ provision the box on open (idempotent) and preview -// via SSE frames or a ~4s screenshot poll; local ("This Mac") β†’ frames +// via SSE frames or a ~4s screenshot poll; local ("This computer") β†’ frames // come from the Electron main process (desktopCapturer over the preload // bridge β€” box endpoints are never touched); off β†’ parked. Auto (unset) // prefers the cloud box when one exists, else local inside the app. @@ -199,7 +199,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {/* Screen preview */}
{bot.name}'s screen - {phase === "local" && this Mac} + {phase === "local" && this computer}
{frameSrc ? ( @@ -217,7 +217,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {phase === "ready" ? "Waiting for the first frame…" : phase === "local" - ? "Capturing this Mac's screen…" + ? "Capturing this computer's screen…" : emptyState[phase]}
@@ -272,14 +272,14 @@ export function ComputerPanel({ bot }: { bot: Bot }) {
Runs on
- {bot.computer ? "" : "Auto: the cloud box when one exists, else this Mac. "}Pick where this bot's + {bot.computer ? "" : "Auto: the cloud box when one exists, else this computer. "}Pick where this bot's computer lives.
{( [ ["cloud", "Cloud box"], - ["local", "This Mac"], + ["local", "This computer"], ["off", "Off"], ] as const ).map(([mode, label], i) => ( diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index e64fe5aaf9..c2c9bc7905 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -131,7 +131,7 @@ export function Onboarding({ onDone }: { onDone: () => void }) {

Your engines

- Bots run on the AI tools already on this Mac β€” here’s what we found. + Bots run on the AI tools already on this computer β€” here’s what we found.

{!instances ? ( From 189cd05f1f4ae5528fb341eb2ed152f2a9e3f630 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn?= Date: Tue, 11 Aug 2026 21:59:43 -0300 Subject: [PATCH 3/4] Hide console window when spawning agent CLIs on Windows The harness spawns the agent CLIs (claude, codex) from the Electron GUI host; without windowsHide each turn flashed a console window on Windows. Both driver spawns now pass windowsHide: true (dev probes already did). --- dist-server/drivers/claude.js | 2 ++ dist-server/drivers/codex.js | 2 ++ server/drivers/claude.ts | 2 ++ server/drivers/codex.ts | 2 ++ 4 files changed, 8 insertions(+) diff --git a/dist-server/drivers/claude.js b/dist-server/drivers/claude.js index f59a878817..ca03159cc6 100644 --- a/dist-server/drivers/claude.js +++ b/dist-server/drivers/claude.js @@ -283,6 +283,8 @@ export const ClaudeDriver = { env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, // own process group: killing -pid reaps child MCP servers + // GUI host: don't flash a console window for the agent CLI + windowsHide: true, }); let settled = false; const settle = (ok, stopReason, cost = null) => { diff --git a/dist-server/drivers/codex.js b/dist-server/drivers/codex.js index 29759242ec..2edcf89fb0 100644 --- a/dist-server/drivers/codex.js +++ b/dist-server/drivers/codex.js @@ -69,6 +69,8 @@ export const CodexDriver = { env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, + // GUI host: don't flash a console window for the agent CLI + windowsHide: true, }); const state = { settled: false, lastText: "" }; const asks = new Map(); diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index df828d962e..dda942d0b2 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -320,6 +320,8 @@ export const ClaudeDriver: ProviderDriver = { env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, // own process group: killing -pid reaps child MCP servers + // GUI host: don't flash a console window for the agent CLI + windowsHide: true, }); let settled = false; diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index f7f7d09ed9..125879ba67 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -98,6 +98,8 @@ export const CodexDriver: ProviderDriver = { env: { ...env, ...cliEnv }, stdio: ["pipe", "pipe", "pipe"], detached: true, + // GUI host: don't flash a console window for the agent CLI + windowsHide: true, }); const state = { settled: false, lastText: "" }; From 5b19a84b4aa003b1a2c5994749d390cbcb251f3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Agust=C3=ADn?= Date: Tue, 11 Aug 2026 22:43:00 -0300 Subject: [PATCH 4/4] Hide the whole console subtree when spawning agent CLIs on Windows windowsHide only hides the direct child; console-app descendants (cmd.exe for device-id probes, MCP servers like cua-driver.exe) still flash their own windows. Spawn the CLI through PowerShell 7 (pwsh) with a hidden console instead, so the entire subtree inherits it and nothing flashes. Args travel through a temp JSON file (no cmd/PowerShell quoting hazards; PS 5.1 mangles embedded quotes in --mcp-config, pwsh passes argv correctly). Falls back to a plain windowsHide spawn when pwsh is missing. detached is stripped on Windows: it maps to DETACHED_PROCESS there, which makes pwsh exit 0 immediately without running the script (surfaces as "cli exited 0 before result"). POSIX keeps it (killProcessTree uses the process group); Windows reaps the tree with taskkill /T /F. Verified end-to-end on Windows: agent turn with local computer use (get_screen_size -> 1920x1080) through the packaged app, no console windows. --- dist-server/drivers/claude.js | 10 +--- dist-server/drivers/cli.js | 92 ++++++++++++++++++++++++++++++- dist-server/drivers/codex.js | 10 +--- server/drivers/claude.ts | 10 +--- server/drivers/cli.ts | 101 +++++++++++++++++++++++++++++++++- server/drivers/codex.ts | 10 +--- 6 files changed, 199 insertions(+), 34 deletions(-) diff --git a/dist-server/drivers/claude.js b/dist-server/drivers/claude.js index ca03159cc6..95002a2278 100644 --- a/dist-server/drivers/claude.js +++ b/dist-server/drivers/claude.js @@ -8,7 +8,6 @@ // - Composio Connect (connected apps β†’ tools) over streamable HTTP // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts // β€” screenshot/exec/open_url, the CUA-on-the-box bridge -import { spawn } from "node:child_process"; import { existsSync, unlinkSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { homedir } from "node:os"; @@ -16,7 +15,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { DATA_DIR } from "../config.js"; import { newEventId, newId } from "../contracts.js"; -import { cliExec, cliVersion, killProcessTree, resolveCliCommand } from "./cli.js"; +import { cliExec, cliVersion, killProcessTree, spawnCliHidden } from "./cli.js"; import { appendNative } from "./native.js"; const DRIVER_KIND = "claudeAgent"; // model catalog ported from upstream packages/contracts/src/model.ts @@ -277,14 +276,11 @@ export const ClaudeDriver = { delete env.ANTHROPIC_API_KEY; delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); - const child = spawn(cliCommand, [...cliPrefix, ...args], { + const child = spawnCliHidden(config.cli, args, { cwd: turn.cwd ?? homedir(), - env: { ...env, ...cliEnv }, + env, stdio: ["pipe", "pipe", "pipe"], detached: true, // own process group: killing -pid reaps child MCP servers - // GUI host: don't flash a console window for the agent CLI - windowsHide: true, }); let settled = false; const settle = (ok, stopReason, cost = null) => { diff --git a/dist-server/drivers/cli.js b/dist-server/drivers/cli.js index 6c449c2d6c..0f11b96900 100644 --- a/dist-server/drivers/cli.js +++ b/dist-server/drivers/cli.js @@ -10,9 +10,10 @@ // // All helpers are async (or spawn async children) β€” nothing here blocks // the harness event loop. -import { execFile, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, isAbsolute, resolve } from "node:path"; +import { execFile, spawn, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; const IS_WIN = process.platform === "win32"; const SHIM_RE = /\.(cmd|bat)$/i; // `where` results don't change often; cache per CLI so per-turn spawns @@ -143,3 +144,88 @@ export function killProcessTree(pid) { } } } +// PowerShell wrapper that runs a CLI with a hidden console. windowsHide on +// the direct spawn would give the CLI NO console β€” then every console-app +// it spawns (cmd.exe for device-id probes, MCP servers like cua-driver.exe) +// would create its own VISIBLE console window. A hidden console instead is +// inherited by the whole subtree, so nothing ever flashes. Args travel in a +// JSON file, so there is no cmd/PowerShell quoting hazard. +const PS_HIDDEN_WRAPPER = `param([string]$Cli, [string]$ArgsFile) +$ErrorActionPreference = 'Stop' +$ArgList = @(Get-Content -Raw -LiteralPath $ArgsFile | ConvertFrom-Json) +& $Cli @ArgList +exit $LASTEXITCODE +`; +// PowerShell 5.1 (built into Windows) mangles native args that contain +// embedded quotes β€” fatal for --mcp-config JSON. PowerShell 7 (pwsh) +// passes argv correctly, so prefer it and fall back to a plain +// windowsHide spawn (direct child hidden; grandchildren may flash). +function resolvePwsh() { + const candidates = [ + join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + ...(process.env.ProgramW6432 ? [join(process.env.ProgramW6432, "PowerShell", "7", "pwsh.exe")] : []), + ]; + for (const c of candidates) { + if (existsSync(c)) + return c; + } + return null; +} +/** + * Spawn a CLI so that no console window ever appears on Windows β€” including + * for anything the CLI itself spawns. POSIX: plain spawn (no-op). + * Callers pass stdio pipes (["pipe","pipe","pipe"]) and get a child with + * live stdout/stderr streams. + */ +export function spawnCliHidden(cli, args, opts) { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + if (!IS_WIN) { + return spawn(command, [...prefix, ...args], opts); + } + // `detached` is a POSIX-only need here (killProcessTree uses the process + // group). On Windows it maps to DETACHED_PROCESS β€” the child gets NO + // console, and pwsh then exits 0 immediately without running the script + // or writing a byte, which surfaces as "cli exited 0 before result". + // Windows reaps the tree with taskkill /T /F, so drop the flag. + const { detached: _detached, ...winOpts } = opts; + const pwsh = resolvePwsh(); + if (!pwsh) { + // no pwsh: plain spawn with the direct child hidden (grandchildren may + // flash their own consoles β€” acceptable degradation) + return spawn(command, [...prefix, ...args], { + ...winOpts, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }); + } + // NOTE: no windowsHide here β€” PowerShell must keep a (hidden) console so + // the CLI and its console descendants attach to it instead of flashing. + const dir = mkdtempSync(join(tmpdir(), "omb-spawn-")); + const argsFile = join(dir, "args.json"); + const wrapper = join(dir, "wrap.ps1"); + writeFileSync(argsFile, JSON.stringify([...prefix, ...args])); + writeFileSync(wrapper, PS_HIDDEN_WRAPPER); + const child = spawn(pwsh, [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + wrapper, + "-Cli", + command, + "-ArgsFile", + argsFile, + ], { ...winOpts, env: { ...opts.env, ...runEnv } }); + child.once("close", () => { + try { + rmSync(dir, { recursive: true, force: true }); + } + catch { + /* best-effort cleanup */ + } + }); + return child; +} diff --git a/dist-server/drivers/codex.js b/dist-server/drivers/codex.js index 2edcf89fb0..3f703827d5 100644 --- a/dist-server/drivers/codex.js +++ b/dist-server/drivers/codex.js @@ -9,11 +9,10 @@ // // resumeCursor is the codex thread id; a later turn tries thread/resume // and falls back to a fresh thread/start. -import { spawn } from "node:child_process"; import { homedir } from "node:os"; import { newEventId, newId } from "../contracts.js"; import { appendNative } from "./native.js"; -import { cliVersion, killProcessTree, resolveCliCommand } from "./cli.js"; +import { cliVersion, killProcessTree, spawnCliHidden } from "./cli.js"; const DRIVER_KIND = "codex"; // catalog ported from upstream packages/contracts/src/model.ts const MODELS = { @@ -63,14 +62,11 @@ export const CodexDriver = { // the CLI owns its own ChatGPT login; a leaked API key silently flips // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); - const child = spawn(cliCommand, [...cliPrefix, "app-server"], { + const child = spawnCliHidden(config.cli, ["app-server"], { cwd: turn.cwd ?? homedir(), - env: { ...env, ...cliEnv }, + env, stdio: ["pipe", "pipe", "pipe"], detached: true, - // GUI host: don't flash a console window for the agent CLI - windowsHide: true, }); const state = { settled: false, lastText: "" }; const asks = new Map(); diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index dda942d0b2..4b4017a122 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -8,7 +8,6 @@ // - Composio Connect (connected apps β†’ tools) over streamable HTTP // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts // β€” screenshot/exec/open_url, the CUA-on-the-box bridge -import { spawn } from "node:child_process"; import { existsSync, unlinkSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { homedir } from "node:os"; @@ -27,7 +26,7 @@ import type { SendTurnInput, } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; -import { cliExec, cliVersion, killProcessTree, resolveCliCommand } from "./cli.ts"; +import { cliExec, cliVersion, killProcessTree, spawnCliHidden } from "./cli.ts"; import { appendNative } from "./native.ts"; const DRIVER_KIND = "claudeAgent"; @@ -314,14 +313,11 @@ export const ClaudeDriver: ProviderDriver = { delete env.CLAUDECODE; delete env.CLAUDE_CODE_ENTRYPOINT; - const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); - const child = spawn(cliCommand, [...cliPrefix, ...args], { + const child = spawnCliHidden(config.cli, args, { cwd: turn.cwd ?? homedir(), - env: { ...env, ...cliEnv }, + env, stdio: ["pipe", "pipe", "pipe"], detached: true, // own process group: killing -pid reaps child MCP servers - // GUI host: don't flash a console window for the agent CLI - windowsHide: true, }); let settled = false; diff --git a/server/drivers/cli.ts b/server/drivers/cli.ts index 6185ca75e4..9f585ce652 100644 --- a/server/drivers/cli.ts +++ b/server/drivers/cli.ts @@ -10,9 +10,10 @@ // // All helpers are async (or spawn async children) β€” nothing here blocks // the harness event loop. -import { execFile, spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { dirname, isAbsolute, resolve } from "node:path"; +import { execFile, spawn, spawnSync, type ChildProcessWithoutNullStreams, type SpawnOptions } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, isAbsolute, join, resolve } from "node:path"; const IS_WIN = process.platform === "win32"; const SHIM_RE = /\.(cmd|bat)$/i; @@ -148,3 +149,97 @@ export function killProcessTree(pid: number | undefined): void { } } } + +// PowerShell wrapper that runs a CLI with a hidden console. windowsHide on +// the direct spawn would give the CLI NO console β€” then every console-app +// it spawns (cmd.exe for device-id probes, MCP servers like cua-driver.exe) +// would create its own VISIBLE console window. A hidden console instead is +// inherited by the whole subtree, so nothing ever flashes. Args travel in a +// JSON file, so there is no cmd/PowerShell quoting hazard. +const PS_HIDDEN_WRAPPER = `param([string]$Cli, [string]$ArgsFile) +$ErrorActionPreference = 'Stop' +$ArgList = @(Get-Content -Raw -LiteralPath $ArgsFile | ConvertFrom-Json) +& $Cli @ArgList +exit $LASTEXITCODE +`; + +// PowerShell 5.1 (built into Windows) mangles native args that contain +// embedded quotes β€” fatal for --mcp-config JSON. PowerShell 7 (pwsh) +// passes argv correctly, so prefer it and fall back to a plain +// windowsHide spawn (direct child hidden; grandchildren may flash). +function resolvePwsh(): string | null { + const candidates = [ + join(process.env.ProgramFiles ?? "C:\\Program Files", "PowerShell", "7", "pwsh.exe"), + ...(process.env.ProgramW6432 ? [join(process.env.ProgramW6432, "PowerShell", "7", "pwsh.exe")] : []), + ]; + for (const c of candidates) { + if (existsSync(c)) return c; + } + return null; +} + +/** + * Spawn a CLI so that no console window ever appears on Windows β€” including + * for anything the CLI itself spawns. POSIX: plain spawn (no-op). + * Callers pass stdio pipes (["pipe","pipe","pipe"]) and get a child with + * live stdout/stderr streams. + */ +export function spawnCliHidden( + cli: string, + args: string[], + opts: SpawnOptions & { env?: NodeJS.ProcessEnv }, +): ChildProcessWithoutNullStreams { + const { command, args: prefix, env: runEnv } = resolveCliCommand(cli); + if (!IS_WIN) { + return spawn(command, [...prefix, ...args], opts) as ChildProcessWithoutNullStreams; + } + // `detached` is a POSIX-only need here (killProcessTree uses the process + // group). On Windows it maps to DETACHED_PROCESS β€” the child gets NO + // console, and pwsh then exits 0 immediately without running the script + // or writing a byte, which surfaces as "cli exited 0 before result". + // Windows reaps the tree with taskkill /T /F, so drop the flag. + const { detached: _detached, ...winOpts } = opts; + const pwsh = resolvePwsh(); + if (!pwsh) { + // no pwsh: plain spawn with the direct child hidden (grandchildren may + // flash their own consoles β€” acceptable degradation) + return spawn(command, [...prefix, ...args], { + ...winOpts, + env: { ...opts.env, ...runEnv }, + windowsHide: true, + }) as ChildProcessWithoutNullStreams; + } + // NOTE: no windowsHide here β€” PowerShell must keep a (hidden) console so + // the CLI and its console descendants attach to it instead of flashing. + const dir = mkdtempSync(join(tmpdir(), "omb-spawn-")); + const argsFile = join(dir, "args.json"); + const wrapper = join(dir, "wrap.ps1"); + writeFileSync(argsFile, JSON.stringify([...prefix, ...args])); + writeFileSync(wrapper, PS_HIDDEN_WRAPPER); + const child = spawn( + pwsh, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + wrapper, + "-Cli", + command, + "-ArgsFile", + argsFile, + ], + { ...winOpts, env: { ...opts.env, ...runEnv } }, + ) as ChildProcessWithoutNullStreams; + child.once("close", () => { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + /* best-effort cleanup */ + } + }); + return child; +} diff --git a/server/drivers/codex.ts b/server/drivers/codex.ts index 125879ba67..6ca260dddc 100644 --- a/server/drivers/codex.ts +++ b/server/drivers/codex.ts @@ -9,7 +9,6 @@ // // resumeCursor is the codex thread id; a later turn tries thread/resume // and falls back to a fresh thread/start. -import { spawn } from "node:child_process"; import { homedir } from "node:os"; import type { @@ -23,7 +22,7 @@ import type { } from "../contracts.ts"; import { newEventId, newId } from "../contracts.ts"; import { appendNative } from "./native.ts"; -import { cliVersion, killProcessTree, resolveCliCommand } from "./cli.ts"; +import { cliVersion, killProcessTree, spawnCliHidden } from "./cli.ts"; const DRIVER_KIND = "codex"; @@ -92,14 +91,11 @@ export const CodexDriver: ProviderDriver = { // billing to pay-as-you-go (agentcal) delete env.OPENAI_API_KEY; - const { command: cliCommand, args: cliPrefix, env: cliEnv } = resolveCliCommand(config.cli); - const child = spawn(cliCommand, [...cliPrefix, "app-server"], { + const child = spawnCliHidden(config.cli, ["app-server"], { cwd: turn.cwd ?? homedir(), - env: { ...env, ...cliEnv }, + env, stdio: ["pipe", "pipe", "pipe"], detached: true, - // GUI host: don't flash a console window for the agent CLI - windowsHide: true, }); const state = { settled: false, lastText: "" };