Merge verified contributor and Computer Use source for 0.9.13

Preserve the contributor/security branch and all five Computer Use commits
through 050f916af as separate parents. Resolve build.rs by retaining both
complete initialization paths: loop-built localization data for small-stack
safety and the bundled macOS Computer Use helper. The other five overlapping
source files retain the MCP authority fixes and live tool discovery together.
Format the final raw-key PTY test line missed by its preceding commit.

Verified staged source tree: 314bd33f47

Local macOS evidence on this combined source:
- cargo nextest run --workspace --all-features --locked --profile ci
  --test-threads 8: 14,621 passed, 0 failed, 15 skipped; 1 slow, 2 leaky
  process-output classifications, with no retry. Includes the six-case
  unbracketed offline-queue PTY regression for #5999.
- Focused ACP/MCP/queue/CU discovery/shutdown regressions: 20 passed, 0 failed.
- cargo clippy --workspace --all-targets --all-features --locked --
  -D warnings: passed.
- Workspace doctests: 3 passed, 0 failed, 8 ignored.
- Final cargo fmt --all -- --check and git diff --cached --check: passed.
- Vendored Computer Use tests: 130 passed, 0 failed. All 19 runtime files
  exactly match upstream eb5eac7, including verified type delivery and
  session re-lease. Development-only c9ab245 scanner was not vendored.
- Combined web package: 411 tests passed, 0 failed; typecheck, facts and docs
  passed. Same web application source previously passed webpack production
  build (785 static pages) and lint (0 errors, 2 existing image warnings).
- Core root npm test && npm run check:web is unavailable: package.json has
  neither script. Default Turbopack build cannot follow the existing external
  node_modules symlink; supported webpack build passed with font downloads.

Tests use the isolated test-home helper, a per-tree Cargo target,
CARGO_INCREMENTAL=0, and four compiler jobs. Computer Use helper signing is
ad-hoc for these local artifacts. Initial sandbox-only fixture failures and
the PTY fixture setup corrections remain in separate logs; no negative
pre-fix PTY replay is claimed.

Not claimed: exact-commit hosted CI, native Windows /pin acceptance, release
packaging/signing/notarization, deployment, or customer acceptance. PR #6002
remote head 13c0f4ac2 still has its older failing ACP check; this local source
passes the exact regression. PR #5946 remains deferred because its incoming
final-answer extraction can reuse a prior resumed turn's assistant reply.
No public push, tag, remote merge, release, deployment, or provider spend.
This commit is contained in:
CodeWhale Bot committed 2026-09-07 19:27:32 -07:00
commit 67430af67c
48 files changed
+3276 -471

No files matched your search

+1
View File
@@ -72,6 +72,7 @@ class ${class_name} < Formula
homepage "https://github.com/Hmbown/CodeWhale"
version "${VERSION}"
license "MIT"
depends_on "node"
${extra_header}
on_macos do
if Hardware::CPU.arm?
@@ -37,6 +37,7 @@ grep -Fq 'class Codewhale < Formula' "${formula}"
grep -Fq 'class DeepseekTui < Formula' "${legacy}"
grep -Fq 'deprecate! date: "2026-08-14", because: "renamed to codewhale"' "${legacy}"
grep -Fq 'desc "Agentic terminal for open-source and open-weight coding models"' "${formula}"
grep -Fq 'depends_on "node"' "${formula}"
test "$(grep -Fc 'resource "codew" do' "${formula}")" -eq 4
grep -Fq 'bin.install Dir["*"].first => "codew"' "${formula}"
grep -Fq 'system "#{bin}/codew", "--version"' "${formula}"
+84
View File
@@ -6,6 +6,7 @@ fn main() {
codewhale_build_support::declare_rerun_conditions(&manifest_dir);
generate_localization(&manifest_dir);
configure_windows_stack();
build_computer_use_helper(&manifest_dir);
codewhale_build_support::emit_build_version(&manifest_dir, env!("CARGO_PKG_VERSION"));
}
@@ -48,6 +49,89 @@ fn generate_localization(manifest_dir: &Path) {
.expect("write i18n bootstrap");
}
/// Ship native computer-use support with macOS binaries. Requiring clang on
/// the customer's machine would make the built-in plugin a source-only demo.
fn build_computer_use_helper(manifest_dir: &std::path::Path) {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
return;
}
let source = manifest_dir.join("plugins/computer-use/src/backends/darwin-accessibility.m");
println!("cargo:rerun-if-changed={}", source.display());
println!(
"cargo:rerun-if-changed={}",
source.with_file_name("darwin-recording.h").display()
);
println!(
"cargo:rerun-if-changed={}",
source.with_file_name("darwin-ocr.h").display()
);
println!("cargo:rerun-if-env-changed=CODEWHALE_CU_SIGN_IDENTITY");
let arch = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() {
Ok("aarch64") => "arm64",
Ok("x86_64") => "x86_64",
other => panic!("unsupported macOS computer-use architecture: {other:?}"),
};
let output = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR"))
.join("computer-use-accessibility");
let compiled = std::process::Command::new("xcrun")
.args([
"clang",
"-fobjc-arc",
"-Os",
"-arch",
arch,
"-mmacosx-version-min=13.0",
"-framework",
"Cocoa",
"-framework",
"ApplicationServices",
"-framework",
"ScreenCaptureKit",
"-framework",
"AVFoundation",
"-framework",
"CoreMedia",
"-framework",
"Vision",
])
.arg(&source)
.arg("-o")
.arg(&output)
.output()
.expect("macOS builds require Xcode Command Line Tools to package Computer Use");
assert!(
compiled.status.success(),
"Computer Use helper compilation failed: {}",
String::from_utf8_lossy(&compiled.stderr)
);
// Developer builds use ad-hoc signing; release builders can supply the
// same Developer ID as the app. No keychain lookup or credential copying.
let identity = std::env::var("CODEWHALE_CU_SIGN_IDENTITY").unwrap_or_else(|_| "-".into());
let signed = std::process::Command::new("codesign")
.args([
"--force",
if identity == "-" {
"--timestamp=none"
} else {
"--timestamp"
},
"--options",
"runtime",
"--identifier",
"net.codewhale.computer-use.helper",
"--sign",
])
.arg(&identity)
.arg(&output)
.output()
.expect("macOS builds require codesign to package Computer Use");
assert!(
signed.status.success(),
"Computer Use helper signing failed: {}",
String::from_utf8_lossy(&signed.stderr)
);
}
fn configure_windows_stack() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
+1 -1
View File
@@ -1 +1 @@
906b4330931f990880d24cad4b0f748cc25e32b3
eb5eac7d06e3ef5c1f0d9f246cbe87f3f706c47f
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Codewhale contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+61 -92
View File
@@ -1,105 +1,74 @@
# computer-use
# Computer Use
Codewhale Computer Use — the `computer-use` plugin. One tool surface, four
platforms, and **switching between registered computers as a default**: every
tool accepts `computer`, and using a computer id sticks until you switch.
This is the Computer Use plugin included in Codewhale. The Engine embeds the
runtime bundle, discovers it through the existing plugin registry, and runs
only the copy the user has reviewed and enabled. Codewhale Apps uses that same
Engine inventory and approval flow.
The bundle is an Agent Plugins v1 package (`plugin.json` + sibling `mcp.json`,
`commands/`, `skills/`): the Codewhale Engine discovers, reviews, installs, and
runs it. Nothing here writes to your Codewhale configuration.
The bundle provides 39 MCP tools for application and window observation,
accessibility actions, screenshots and zoom, keyboard and pointer input,
clipboard access, recording, and switching between registered computers.
Implementation exists for macOS, Windows, Linux and HarmonyOS target devices;
platform support still depends on the tools, OS grants and actual device
verification described by the upstream project. A source build is not a
published or certified release.
Current launch qualification covers the local macOS candidate. Windows,
Wayland, HarmonyOS and SSH require separate device and workflow evidence.
| | |
|---|---|
| Platforms | macOS, Windows, Linux (X11 + Wayland), HarmonyOS (hdc devices) |
| Hosts | macOS, Windows, Linux (`when.os`); HarmonyOS is a target device, not a host |
| Transports | local process, ssh + bundled remote agent, hdc |
| Runtime deps | none (Node ≥ 20; platform tools probed at call time) |
| Tools | 38: observe, pointer, keyboard/text, semantic, clipboard, recording, computer registry |
## Included runtime
## Frontier ability set
macOS Codewhale builds carry the compiled native helper. Using the included
plugin needs neither a separate Computer Use app nor a compiler. It uses the
permission identity of its hosting Codewhale app or terminal. Accessibility
and Screen Recording grants remain controlled by the user in System Settings.
Use `request_access` to inspect readiness; a loaded plugin alone does not prove
its OS permissions work.
- **Observe & resolve** — `list_apps`, `list_windows`, `list_displays`,
`switch_display`, `get_app_state` (accessibility/UIA/uitest tree with
element indices + `state_id`), `screenshot` (display/region, raster-bound
coordinates), `zoom` (close-up crop of the last raster), `cursor_position`,
`open_application` (exact-name rule), `request_access` (fail-closed
permission/capability probe).
- **Pointer** — left/double/triple/right/middle click, move, drag,
down/up, scroll (4 directions).
- **Keyboard & text** — `type` (unicode), `key` (chords + repeat),
`hold_key`, `set_value` (semantic, background-safe), `select_text`,
`perform_action` (element's own actions: AXPress / UIA Invoke / AT-SPI / uitest).
- **Recording** — `recording_start/stop/status/list` (see below).
- **Computers** — `computer_list`, `computer_switch`, `computer_register`
(ssh agent auto-push), `computer_remove`.
- **Safety** — `stop_computer_control` kill switch; permission probes that
name the missing grant; receipts on every call naming the computer it
happened on.
The MCP server requires Node.js 20 or newer. Codewhale Apps packages its own
Node runtime; the CLI uses Node on PATH. Homebrew declares the dependency;
Cargo and direct binary users can install Node from <https://nodejs.org/>.
Linux also needs the appropriate X11 or Wayland utilities and AT-SPI bindings.
Windows uses PowerShell and UI Automation. Linux and Windows recording is
currently unavailable until recorder ownership and shutdown cleanup are built.
HarmonyOS targets require a connected device and hdc.
## Requirements
Persistent holds and drags on Linux and Windows currently require the separate
session-aware Computer Use helper. Their direct bundled path refuses these
operations before sending input. macOS carries its native input owner in the
included bundle. Real Windows, Wayland and mixed-display validation is still
required before claiming equivalent platform readiness.
The current one-shot SSH agent also loses application binding between calls;
stateful remote input needs a persistent session transport before it is ready.
Tools and permissions are probed at call time; `request_access` reports what is
missing and every capability **fails closed naming the missing tool or
permission** — it never guesses and never half-acts.
## Control and session ownership
- **macOS** — Accessibility + Screen Recording permission for the terminal
app that hosts the Engine (System Settings → Privacy & Security).
python3+pyobjc or cliclick improves cursor reads. ffmpeg optional (mp4 remux).
- **Windows** — PowerShell (built in); ffmpeg for recording.
- **Linux** — X11: xdotool, wmctrl, scrot or imagemagick, xclip; Wayland:
grim, wtype, ydotool+ydotoold, wl-clipboard, wf-recorder; python3-pyatspi
for the accessibility tree; ffmpeg for recording on X11.
- **HarmonyOS** — `hdc` on PATH with the device connected
(`hdc list targets`); ffmpeg on the host for snapshot-series recordings.
Select an application before sending input. Prefer accessibility actions and
background selection; requests that need foreground input report that fact.
Screenshots and zoom return actual image content to compatible vision models.
Preview and recording are explicit opt-ins.
Application observations return a concise default summary; request full detail
when needed. Text-only models can use element roles, values and advertised
actions. On macOS, optional local OCR enriches the selected window observation
with text and raster bounds; it requires Screen Recording permission and does
not invent accessibility elements or actions.
The Engine permits one inline image up to 5 MiB per tool result; use a scoped
capture or zoom when a larger image receives an omission receipt.
## How the four platforms map
| Ability | macOS | Windows | Linux | HarmonyOS |
|---|---|---|---|---|
| Accessibility tree | AX via System Events (JXA) | UIAutomation | AT-SPI (pyatspi) | `uitest dumpLayout` |
| Raw input | CGEvent (JXA bridge) | user32 SendInput/mouse_event (PowerShell) | xdotool (X11) / ydotool+wtype (Wayland) | `uitest uiInput` |
| Screenshots | `screencapture` | .NET CopyFromScreen | scrot/import (X11), grim (Wayland) | `snapshot_display` |
| Recording | `screencapture -v` → .mov, ffmpeg remux to .mp4 | ffmpeg gdigrab | ffmpeg x11grab / wf-recorder | snapshot-series + ffmpeg mux |
| Clipboard | pbcopy/pbpaste | Get/Set-Clipboard | xclip/xsel, wl-clipboard | fail-closed (not exposed by hdc) |
## Remote computers (ssh)
```json
computer_register { "computer": "winbox", "transport": "ssh", "host": "winbox.lan", "user": "me" }
```
Registration pushes the self-contained agent (`agent.mjs` + `src/`) to
`~/.codewhale-cu/agent/` on the remote over scp, probes the remote platform
through it, and pins the result. Remote calls run
`node agent.mjs <base64 json>` — one JSON receipt line back. Only an
allow-listed tool set executes remotely; arguments travel as data, never as
shell. Requires publickey ssh (BatchMode) and Node ≥ 20 on the remote.
## HarmonyOS computers
```json
computer_register { "computer": "pad", "transport": "hdc" }
```
Drives the device over `hdc shell uitest ...` and `snapshot_display`. Element
targets come from `dumpLayout`; input is touch-synthesis (click / swipe /
inputText / keyEvent). Recording is honestly labeled `snapshot-series`
(frame captures muxed on stop) because HarmonyOS exposes no CLI screen
recorder.
Each task owns its MCP connection and computer selection, observations and
held input. Subagents within that task share the task's Computer Use session.
Stopping control or closing the task releases that session's input. Stale
observations, unexpected foreground changes and unavailable capabilities fail
closed with a receipt; successful dispatch still needs application-state
verification.
## Development
The bundle lives at `crates/tui/plugins/computer-use` in the Codewhale repository and has
no dependencies to install; run its suites from that directory.
The exact upstream source revision is recorded beside this directory in
`computer-use.upstream-sha`. This tree contains the runtime and its tests;
standalone app installers and release tooling belong to the upstream project.
```bash
npm test # unit + protocol tests (no GUI input performed)
npm run smoke # live end-to-end against this machine (isolated state dirs,
# no clicks/typing into your session, no clipboard access)
```
Smoke receipts land in `receipts/` with per-check pass/fail and artifact
paths. Proven levels are separated: local live (this Mac: darwin) > mocked
transport (ssh protocol, harmony backend logic) > code-complete (win32/linux
paths, implemented to their documented tool interfaces but only verifiable on
those platforms).
Run `npm test` here for unit and protocol coverage. Those tests do not type or
click in the user's applications. `npm run smoke` is a separate legacy live
check: it captures and records the selected display, so run it only when that
capture is intended. The upstream parity suite contains scoped application
fixtures for interactive verification.
@@ -19,4 +19,8 @@ try {
reply({ ok: false, error: { code: "bad_payload", message: "payload is not base64 JSON" } });
}
if (["left_mouse_down", "recordingStart"].includes(req?.tool)) {
reply({ ok: false, error: { code: "persistent_session_required", message: "This operation needs a persistent computer session; the SSH agent exits after each request. Use a complete drag gesture or the local helper instead." } });
}
reply(await handle(req, { computerId: "remote" }));
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env node
// Codewhale Computer Use — the desktop app process.
//
// A long-lived daemon that runs the platform backend on this machine and
// answers one-line JSON requests over a per-user local socket (see
// src/app-socket.mjs). The app bundles built by scripts/build-app.mjs launch
// exactly this file, so the OS attributes every osascript / screencapture /
// UI-automation call to the app: grant Accessibility and Screen Recording to
// "Codewhale Computer Use" once and every host that speaks to the plugin
// inherits it.
//
// Env (set by the launchers inside the bundles):
// CODEWHALE_CU_APP_BUNDLE absolute path of the installed bundle
// CODEWHALE_CU_APP_LAUNCH JSON argv that re-launches the bundle detached
// CODEWHALE_CU_STATE_DIR state dir (defaults to ~/.codewhale-cu)
import fs from "node:fs";
import net from "node:net";
import crypto from "node:crypto";
import { handle, closeSession, closeAllSessions, releaseSessionInput, reopenSession, ALLOWED } from "../src/app-handler.mjs";
import { APP_ID, APP_NAME, APP_VERSION, socketPath, runInfoPath, writeRegistration, defaultLaunch, hello } from "../src/app-socket.mjs";
import { stateDir } from "../src/registry.mjs";
const startedAt = new Date().toISOString();
const bundle = process.env.CODEWHALE_CU_APP_BUNDLE || null;
const log = (msg) => process.stderr.write(`${new Date().toISOString()} ${APP_NAME}: ${msg}\n`);
function appInfo() {
return { id: APP_ID, name: APP_NAME, version: APP_VERSION, sessionProtocol: 2, pid: process.pid, platform: process.platform, node: process.version, bundle, startedAt, socket: socketPath() };
}
if (await hello({ timeoutMs: 1_500 })) {
log(`already running on ${socketPath()}; exiting`);
process.exit(0);
}
const sock = socketPath();
fs.mkdirSync(stateDir(), { recursive: true });
if (process.platform !== "win32") {
try { fs.unlinkSync(sock); } catch {} // stale file from an unclean exit; nobody answered hello above
}
const leases = new Map();
let shuttingDown = false;
async function serve(conn) {
let buf = "";
let chain = Promise.resolve();
const controller = new AbortController();
let ownedSession = null;
conn.on("close", () => {
controller.abort();
if (ownedSession && leases.get(ownedSession)?.socket === conn) {
leases.delete(ownedSession);
closeSession(ownedSession).catch((err) => log(`disconnected session input cleanup failed: ${err.message}`));
}
});
conn.setEncoding("utf8");
conn.on("error", () => {});
conn.on("data", (chunk) => {
buf += chunk;
let nl;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
chain = chain.then(async () => {
if (controller.signal.aborted) return;
let req;
try { req = JSON.parse(line); } catch { return conn.write(JSON.stringify({ ok: false, error: { code: "bad_payload", message: "request is not JSON" } }) + "\n"); }
let reply;
if (shuttingDown) reply = { ok: false, error: { code: "app_shutting_down", message: "Computer Use helper is shutting down" } };
else if (req?.tool === "hello") reply = { ok: true, app: appInfo() };
else if (req?.tool === "platform") reply = await handle(req);
else if (!ALLOWED.has(req?.tool) && !["open_session", "close_session", "release_session_input"].includes(req?.tool)) reply = await handle(req);
else if (typeof req?.sessionId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(req.sessionId)) {
reply = { ok: false, error: { code: "session_required", message: "Update the MCP server: every computer request must carry its session identity." } };
} else if (req.tool === "open_session") {
if (ownedSession || leases.has(req.sessionId)) reply = { ok: false, error: { code: "session_owned", message: "Computer session already has an owner" } };
else if (leases.size >= 256) reply = { ok: false, error: { code: "session_limit", message: "Too many active computer sessions" } };
else {
ownedSession = req.sessionId;
const leaseToken = crypto.randomUUID();
leases.set(ownedSession, { socket: conn, token: leaseToken });
reopenSession(ownedSession);
reply = { ok: true, leaseToken };
}
} else if (!leases.has(req.sessionId) || leases.get(req.sessionId).token !== req.leaseToken) {
reply = { ok: false, error: { code: "session_owner_required", message: "Computer request needs its live session owner lease; update or restart the MCP server" } };
} else if (["close_session", "release_session_input"].includes(req.tool)) {
try {
if (req.tool === "close_session") await closeSession(req.sessionId);
else await releaseSessionInput(req.sessionId);
reply = { ok: true, closed: req.tool === "close_session", inputReleased: true };
} catch (err) {
reply = { ok: false, error: { code: "input_release_failed", message: String(err?.message ?? err) } };
}
} else reply = await handle(req, { computerId: "local", sessionId: req.sessionId, signal: controller.signal, persistentInputOwner: true });
if (!conn.destroyed) conn.write(JSON.stringify(reply) + "\n");
});
}
});
}
const server = net.createServer(serve);
server.on("error", (err) => { log(`socket error: ${err.message}`); process.exit(1); });
server.listen(sock, () => {
if (process.platform !== "win32") { try { fs.chmodSync(sock, 0o600); } catch {} }
fs.writeFileSync(runInfoPath(), JSON.stringify(appInfo(), null, 2) + "\n");
if (bundle) {
// Launching the bundle once is what registers it: the MCP server reads this
// record to bring the app up on demand.
try {
const launch = process.env.CODEWHALE_CU_APP_LAUNCH ? JSON.parse(process.env.CODEWHALE_CU_APP_LAUNCH) : defaultLaunch(bundle);
writeRegistration({ id: APP_ID, path: bundle, launch });
} catch (err) { log(`could not record launch command: ${err.message}`); }
}
log(`v${APP_VERSION} listening on ${sock}${bundle ? ` (bundle ${bundle})` : " (bare, no bundle identity)"}`);
if (bundle && process.env.CODEWHALE_CU_APP_WARM !== "off") warmPermissions();
});
/**
* Touch every permission-gated capability once so the OS asks for the grants
* under the app's own name and icon (macOS: Automation → System Events,
* Accessibility, Screen Recording). Failures are logged, never fatal: the
* probe fails closed and names the missing grant, which is the point.
*/
async function warmPermissions() {
for (const tool of ["probe", "list_apps"]) {
const r = await handle({ tool }, { computerId: "local" });
log(`warm-up ${tool}: ${r.ok ? JSON.stringify(r.data?.permissions ?? "ok") : `${r.error?.code}: ${r.error?.message}`}`);
}
}
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
log(`${signal}; shutting down`);
server.close();
const timer = setTimeout(() => process.exit(1), 3_000);
const results = await closeAllSessions();
clearTimeout(timer);
for (const result of results) {
if (result.status === "rejected") log(`input cleanup failed: ${result.reason?.message ?? result.reason}`);
}
try { fs.unlinkSync(runInfoPath()); } catch {}
if (process.platform !== "win32") { try { fs.unlinkSync(sock); } catch {} }
process.exit(0);
}
for (const s of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(s, () => shutdown(s));
+114 -20
View File
@@ -5,19 +5,24 @@
// computer id switches the sticky active computer.
import fs from "node:fs";
import * as registry from "../src/registry.mjs";
import { backendFor, installRemoteAgent, executorFor } from "../src/transport.mjs";
import { backendFor, installRemoteAgent, executorFor, closeAppSession } from "../src/transport.mjs";
import { TOOLS, TOOL_NAMES, READ_ONLY_TOOLS, REMOTE_TOOLS, BACKEND_METHOD } from "../src/tools.mjs";
import { tryJson } from "../src/exec.mjs";
import { tryJson, withSignal, throwIfAborted, wait } from "../src/exec.mjs";
const VERSION = "0.2.0";
const VERSION = "0.2.1";
const SERVER_NAME = "codewhale-cu";
// ---------- per-session runtime state ----------
let controlStopped = false;
// Registered computers are shared; the selected destination belongs to this
// MCP host. Another task must never redirect an implicit input action.
let activeComputerId = "local";
let stateCounter = 0;
let inFlight = 0; // actions currently dispatching to a backend/executor
/** request ids cancelled via notifications/cancelled */
const cancelled = new Set();
const requests = new Map();
let dispatch = Promise.resolve();
/** state_id -> { computerId, app_ref, windowIndex, elements } */
const appStates = new Map();
/** computerId -> last raster metadata {file, scale, origin} */
@@ -169,6 +174,23 @@ function rememberState(computer, app_ref, result) {
return id;
}
function observeState(computer, app_ref, result, detail) {
// Cache the complete backend records before making the model-facing view.
// Public indices still address those records, including their private AX
// paths; a compact response must never weaken live target revalidation.
const state_id = rememberState(computer, app_ref, result);
const full = detail === "full";
const elements = full ? result.elements : (result.elements ?? [])
.filter((el) => el.windowIndex !== -1 || !Array.isArray(el.path) || el.path.length <= 1)
.map(({ path, windowIndex, ...el }) => el);
return {
...result, state_id, elements, detail: full ? "full" : "summary",
note: "Target observed elements with {type:'element', state_id, index}; observe again after UI changes. " +
(full ? "" : "Summary keeps app content and top-level menus; use detail:'full' for nested menus and tree structure. ") +
"Missing labels or values are unknown; do not guess their contents.",
};
}
// ---------- tool dispatch ----------
async function callTool(params) {
const name = params.name;
@@ -179,7 +201,15 @@ async function callTool(params) {
if (name === "stop_computer_control") {
controlStopped = true;
return { content: [{ type: "text", text: JSON.stringify(receipt(null, { ok: true, stopped: true, inFlight, note: "Computer control refused for the rest of this session. Restart the session or the codewhale-cu server to continue. Actions already dispatching may still land." })) }] };
for (const request of requests.values()) {
if (request.name && request.name !== "stop_computer_control") request.controller.abort();
}
try {
await releaseControl({ releaseOnly: true });
return { content: [{ type: "text", text: JSON.stringify(receipt(null, { ok: true, stopped: true, inFlight, inputReleased: true, note: "Queued input was refused and ongoing requests were cancelled. Input already delivered cannot be undone. Restart this MCP session to resume." })) }] };
} catch (err) {
return { content: [{ type: "text", text: JSON.stringify(fail(null, "input_release_failed", String(err?.message ?? err), { stopped: true, inFlight })) }], isError: true };
}
}
if (controlStopped && !READ_ONLY_TOOLS.has(name)) {
return { content: [{ type: "text", text: JSON.stringify(fail(null, "control_stopped", "stop_computer_control is active; no further actions are permitted this session")) }], isError: true };
@@ -187,7 +217,7 @@ async function callTool(params) {
if (name === "wait") {
const s = Math.max(0, Math.min(30, Number(args.seconds) || 1));
await new Promise((r) => setTimeout(r, s * 1000));
await wait(s * 1000);
return { content: [{ type: "text", text: JSON.stringify(receipt(null, { ok: true, waitedSec: s })) }] };
}
@@ -195,7 +225,7 @@ async function callTool(params) {
const reg = registry.list();
return { content: [{ type: "text", text: JSON.stringify(receipt(null, {
ok: true,
active: reg.active,
active: activeComputerId,
computers: Object.values(reg.computers).map((c) => ({ id: c.id, transport: c.transport, platform: c.platform ?? c.platformHint ?? null, label: c.label ?? null, host: c.host ?? null })),
note: "Pass `computer` on any tool to switch (sticky), or computer_switch to switch explicitly.",
})) }] };
@@ -228,13 +258,16 @@ async function callTool(params) {
if (name === "computer_remove") {
const res = registry.remove(args.computer);
if (activeComputerId === args.computer) activeComputerId = "local";
res.active = activeComputerId;
backendCache.delete(args.computer);
lastRasters.delete(args.computer);
return { content: [{ type: "text", text: JSON.stringify(receipt(null, { ok: true, ...res })) }] };
}
if (name === "computer_switch") {
const c = registry.switchTo(args.computer);
const c = registry.get(args.computer);
activeComputerId = c.id;
return { content: [{ type: "text", text: JSON.stringify(receipt(c, { ok: true, active: c.id })) }] };
}
@@ -242,11 +275,12 @@ async function callTool(params) {
let computer;
let switched = false;
try {
if (args.computer && args.computer !== registry.list().active) {
computer = registry.switchTo(args.computer);
if (args.computer && args.computer !== activeComputerId) {
computer = registry.get(args.computer);
activeComputerId = computer.id;
switched = true;
} else {
computer = registry.active();
computer = registry.get(activeComputerId);
}
} catch (err) {
return { content: [{ type: "text", text: JSON.stringify(fail(null, err.code ?? "registry_error", err.message)) }], isError: true };
@@ -275,6 +309,7 @@ async function callTool(params) {
return rep.data;
};
const wireArgs = await prepareArgs(computer, name, args, resolve, sink);
throwIfAborted();
// Re-check the kill switch: a stop that arrived while the executor was
// being resolved still blocks this dispatch.
if (controlStopped && !READ_ONLY_TOOLS.has(name)) throw new ServerError("control_stopped", "stop_computer_control is active; no further actions are permitted this session");
@@ -298,8 +333,7 @@ async function callTool(params) {
}
if (backendMethod === "zoom") bindZoomRaster(computer, zoomParent, args.region, ex.filesLocal ? data?.file ?? data?.path : null);
if (name === "get_app_state") {
data.state_id = rememberState(computer, wireArgs.app_ref, data);
data.note = "Element targets are {type:'element', state_id, index}. State goes stale when the UI changes; observe again.";
data = observeState(computer, wireArgs.app_ref, data, args.detail);
}
if (backendMethod === "probe") Object.assign(data, { via: ex.kind, app: ex.app ?? null });
} else {
@@ -309,6 +343,7 @@ async function callTool(params) {
}
const resolve = typeof backend.resolve_element === "function" ? (req) => backend.resolve_element(req) : null;
const prepared = await prepareArgs(computer, name, args, resolve, sink);
throwIfAborted();
if (controlStopped && !READ_ONLY_TOOLS.has(name)) throw new ServerError("control_stopped", "stop_computer_control is active; no further actions are permitted this session");
inFlight++;
try {
@@ -320,9 +355,7 @@ async function callTool(params) {
if (name === "screenshot") bindRaster(computer, data);
if (backendMethod === "zoom") bindZoomRaster(computer, zoomParent, args.region, data?.file ?? data?.path);
if (name === "get_app_state") {
const stateId = rememberState(computer, prepared.app_ref, data);
data.state_id = stateId;
data.note = "Element targets are {type:'element', state_id, index}. State goes stale when the UI changes; observe again.";
data = observeState(computer, prepared.app_ref, data, args.detail);
}
if (backendMethod === "probe" && computer.transport === "local") {
// Direct mode: permissions belong to whatever hosts this server. Say so.
@@ -330,6 +363,15 @@ async function callTool(params) {
}
}
if (name === "get_app_state" && args.include_ocr) {
data.ocr ??= { status: "unavailable", reason: "Text recognition is not available on this backend", blocks: [] };
if (data.ocr.raster) {
const localFile = typeof ex?.remote !== "function" || ex.filesLocal;
bindRaster(computer, localFile ? data.ocr.raster : { ...data.ocr.raster, file: null, path: null });
}
data.ocr.note = "Recognized text may be imperfect. These coordinate targets belong to this captured image, not to accessibility elements; observe again after the UI changes.";
}
const content = [{ type: "text", text: JSON.stringify(receipt(computer, { ok: true, tool: name, switched, ...(sink.reacquired ? { target_reacquired: true } : {}), ...data })) }];
if ((name === "screenshot" || name === "zoom") && computer.transport === "local" && (data.file || data.path)) {
const bytes = fs.readFileSync(data.file || data.path);
@@ -363,7 +405,10 @@ async function prepareArgs(computer, name, args, resolve, sink) {
}
if (name === "get_app_state") {
out.app_ref = out.app_ref ?? null;
if (out.window_id != null) out.window_id = Number(out.window_id);
if (out.detail != null && !["summary", "compact", "full"].includes(out.detail)) throw new ServerError("bad_args", "detail must be summary or full (compact is an alias for summary)");
out.detail = out.detail === "full" ? "full" : "summary";
if (out.include_ocr != null && typeof out.include_ocr !== "boolean") throw new ServerError("bad_args", "include_ocr must be true or false");
if (out.window_id != null && (!Number.isSafeInteger(out.window_id) || out.window_id < 0)) throw new ServerError("bad_args", "window_id must be a non-negative window index from list_windows");
}
return out;
}
@@ -388,10 +433,25 @@ const HANDLERS = {
return { tools: TOOLS };
},
async "tools/call"(params) {
return await callTool(params ?? {});
if (params?.name === "stop_computer_control") return callTool(params);
const previous = dispatch;
let release;
dispatch = new Promise((resolve) => { release = resolve; });
try {
await previous;
throwIfAborted();
return await callTool(params ?? {});
} catch (err) {
if (err?.code !== "cancelled") throw err;
return { content: [{ type: "text", text: JSON.stringify(fail(null, controlStopped ? "control_stopped" : "cancelled", err.message)) }], isError: true };
} finally { release(); }
},
"notifications/cancelled"(params) {
if (params?.requestId != null) cancelled.add(params.requestId);
const request = requests.get(params?.requestId);
if (request) {
cancelled.add(params.requestId);
request.controller.abort();
}
return {};
},
ping() {
@@ -411,9 +471,39 @@ process.stdin.on("data", (chunk) => {
handleLine(line);
}
});
process.stdin.on("end", () => process.exit(0));
async function releaseControl({ releaseOnly = false } = {}) {
let timer;
try {
await Promise.race([
(async () => {
await dispatch;
await withSignal(null, () => Promise.all([
closeAppSession({ releaseOnly }),
...[...backendCache.values()].map(async (backend) => {
await backend.releaseInput?.();
if (!releaseOnly) await backend.closeSession?.();
}),
]));
})(),
new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("Computer input cleanup did not finish within 3 seconds")), 3_000); }),
]);
} finally { clearTimeout(timer); }
}
let shuttingDown = false;
async function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
for (const request of requests.values()) request.controller.abort();
try { await releaseControl(); }
catch (err) { process.stderr.write(`Computer input cleanup failed: ${err?.message ?? err}\n`); }
process.exit(0);
}
process.stdin.on("end", shutdown);
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, shutdown);
async function handleLine(line) {
if (shuttingDown) return;
const msg = tryJson(line, null);
if (!msg || typeof msg !== "object") return;
const { id, method, params } = msg;
@@ -425,8 +515,10 @@ async function handleLine(line) {
}
// Cancelled before dispatch: per MCP, respond nothing.
if (id != null && cancelled.has(id)) { cancelled.delete(id); return; }
const controller = new AbortController();
if (id != null) requests.set(id, { controller, name: method === "tools/call" ? params?.name : null });
try {
const result = await handler(params);
const result = await withSignal(controller.signal, () => handler(params));
// Cancelled mid-flight: drop the completed response.
if (id != null) {
if (cancelled.has(id)) { cancelled.delete(id); return; }
@@ -434,6 +526,8 @@ async function handleLine(line) {
}
} catch (err) {
if (id != null && !cancelled.delete(id)) respondError(id, -32603, err?.message ?? String(err));
} finally {
if (id != null) requests.delete(id);
}
}
+3 -4
View File
@@ -1,15 +1,14 @@
{
"name": "codewhale-cu",
"version": "0.1.0",
"description": "Codewhale Computer Use plugin: frontier computer-use abilities (accessibility-first control, screenshots, zoom, recording) on macOS, Windows, Linux, and HarmonyOS, with multi-computer switching as a default. Zero runtime dependencies.",
"version": "0.2.1",
"description": "Codewhale's included Computer Use plugin: accessibility, screenshots, keyboard and pointer control, and recording through the Engine's reviewed plugin authority.",
"license": "MIT",
"type": "module",
"private": true,
"main": "./mcp/server.mjs",
"scripts": {
"test": "node --test tests/*.test.mjs",
"smoke": "node scripts/smoke.mjs",
"smoke:protocol": "node scripts/smoke-protocol.mjs"
"smoke": "node scripts/smoke.mjs"
},
"engines": {
"node": ">=20"
+2 -2
View File
@@ -1,8 +1,8 @@
{
"$schema": "https://agent-plugins.org/schemas/plugin.json",
"name": "computer-use",
"version": "0.2.0",
"description": "See the screen and operate it: accessibility-first control, screenshots, zoom, and screen recording on macOS, Windows, and Linux, plus registered remote computers over ssh and HarmonyOS devices over hdc.",
"version": "0.2.1",
"description": "See the screen and operate it: accessibility-first control, screenshots and zoom on macOS, Windows and Linux, plus macOS recording and HarmonyOS devices over hdc. SSH is experimental; Windows/Linux recording is unavailable pending owned cleanup.",
"author": { "name": "Codewhale" },
"license": "MIT",
"keywords": [
@@ -27,25 +27,44 @@ Observe once, act once, then verify.
permissions and missing tools per platform, and never pops dialogs. Its
`via` field says who holds the permissions: `"app"` means the Codewhale
Computer Use desktop app is doing the work (grants belong to it);
`"direct"` means this server process is, and `appHint` says how to
install the app so grants stop depending on the host terminal.
`"direct"` means the hosting app or terminal is. Follow the actual
`appHint`: bundled Codewhale builds already carry their native helper.
2. `list_apps` shows running apps only. If the user names an app that is
absent, call `open_application` once with the original user-provided name,
copied character-for-character — including case, spaces, punctuation, and
suffixes such as `app` or `.exe`. Do not translate, localize, normalize,
shorten, or retry with guesses.
3. `get_app_state` returns a bounded accessibility tree (macOS AX / Windows
UIA / Linux AT-SPI / HarmonyOS uitest) with element indices and a
`state_id`. Start here, without a screenshot.
3. `get_app_state` defaults to a text-first summary (macOS AX / Windows
UIA / Linux AT-SPI / HarmonyOS uitest) with controls, values, actions,
layout, element indices and a `state_id`. Start here without a screenshot,
whether or not the model supports vision. Use `detail:"full"` for nested
menus and tree structure; `compact` remains a summary alias. Missing labels
or values mean unknown content, not something to guess.
4. If the tree contains the target, act on the element: `perform_action`
(AXPress/Invoke/click…), `set_value` for editable fields, element click.
The element path is background-safe on macOS and UIA platforms.
5. Only when accessibility cannot express the target: `screenshot` (optionally
5. When accessibility cannot read visible text, macOS supports
`get_app_state({app_ref, include_ocr:true})`. This explicitly captures the
selected app window and recognizes text locally, without a vision model or
remote service. Check `ocr.status`; recognized blocks include confidence,
pixel bounds and ready-to-use coordinate targets. OCR text is not a control
role or an advertised action. Verify uncertain text and observe again after
changes. Other platforms return an explicit unavailable status while keeping
their accessibility state usable. A text-only model must not infer unlabeled
icons, charts or other graphical meaning from OCR or a screenshot file path.
With vision, when accessibility cannot express the target: `screenshot` (optionally
`zoom` for small targets) and act with a coordinate target. Coordinates are
pixels **in the latest returned raster** for that computer; the server maps
them to screen points. After a new screenshot, old pixels are stale.
If the host reports an omitted or oversized image, capture a smaller app
window/region or zoom, then use that returned raster. Do not guess from a
file path or reuse coordinates from an image the model never received.
6. Verify with a fresh observation or a task oracle before claiming success.
`action_sent: true` means it may already have happened — never replay.
On macOS `type` also reports `verified`: `false` (with
`verification_required: "screenshot"`) means the focused control's value
did not reflect the text, so confirm with a screenshot before relying on
the input.
## Choosing targets
@@ -81,6 +100,13 @@ Observe once, act once, then verify.
and `foreground_taken`. Read it, and tell the user when a step took their
foreground. Pass `strategy: "a11y"` when the task must not disturb them —
it fails closed rather than falling back.
- For a dialog or toolkit that needs foreground keyboard delivery, select
`open_application(activate:true)` explicitly. Receipts say
`keyboard_delivery: "foreground-guarded"`; typing fails if another app
takes focus. Never keep reactivating after the user takes control. Return
to `activate:false` when the foreground-only step ends.
- Menus appear in `get_app_state`. Use the advertised action (often
`AXPress` to open a menu, then `AXPick` on its item), then observe again.
- A pointer gesture is refused when another application's window covers the
point; it names the owner. Raise the window you meant with
`open_application(activate:true)`, observe again, and retry — do not move
@@ -106,8 +132,9 @@ Observe once, act once, then verify.
`recording_start` → work → `recording_stop` returns the finalized file path.
macOS uses ScreenCaptureKit inside the signed helper — no system recorder UI
and no desktop dimming overlay (a receipt warning about Screen Recording
permission means the user must grant it once). Linux uses x11grab/wf-recorder,
Windows ffmpeg gdigrab, HarmonyOS snapshot-series (no native CLI recorder —
permission means the user must grant it once). Linux and Windows recording is
unavailable pending session-owned cleanup; use screenshots. HarmonyOS uses
snapshot-series (no native CLI recorder —
the receipt says so). `recording_status` / `recording_list` report bytes and
paths. Screenshots land in the same directory.
@@ -1,14 +1,14 @@
---
name: recording
description: Capture screen recordings and screenshots on any registered computer (macOS, Windows, Linux, HarmonyOS) and manage the recording library.
description: Capture screenshots on registered computers, record on macOS or HarmonyOS, and manage saved captures. Probe capabilities before recording.
---
# Recording and screenshots
1. Pick the computer (`computer_list`, or pass `computer` — it switches).
2. `recording_start` with optional `display` (macOS/Linux), `region` (Linux/
Windows), `fps` (Linux/Windows), `durationSec` (macOS auto-stop), and
`intervalMs` (HarmonyOS snapshot cadence).
2. Check `request_access` when readiness is unknown. On a supported target,
use `recording_start` with optional `display`, `region`, `fps` and
`durationSec` (macOS auto-stop), or `intervalMs` (HarmonyOS snapshot cadence).
3. Do the work (or let the user do it).
4. `recording_stop` with the returned `id` → finalized file path + bytes.
5. `recording_list` shows everything saved; `recording_status` checks one.
@@ -21,9 +21,8 @@ Platform truths:
missing for whoever ran it: the Codewhale Computer Use app when
`request_access` says `via: "app"`, else the terminal that hosts the
server — tell the user which, do not retry.
- **Windows**: ffmpeg gdigrab of the desktop or a region.
- **Linux X11**: ffmpeg x11grab (honors $DISPLAY, region, fps). Wayland:
wf-recorder (per-output via CU_WAYLAND_OUTPUT).
- **Windows and Linux**: recording is unavailable pending session-owned
recorder cleanup. Use screenshots; installing ffmpeg does not enable it.
- **HarmonyOS**: no native CLI recorder; the backend captures
`snapshot_display` frames at `intervalMs` and muxes with ffmpeg on stop.
The receipt labels the mode `snapshot-series` — never call it real-time.
@@ -4,6 +4,7 @@
// transport nor the app socket can ever become a generic shell.
import url from "node:url";
import { exec } from "./remote-runtime.mjs";
import { withSignal, throwIfAborted } from "./exec.mjs";
export const ALLOWED = new Set([
"preview", "platform", "probe", "list_displays", "switch_display", "list_apps", "list_windows",
@@ -16,36 +17,143 @@ export const ALLOWED = new Set([
]);
const backends = new Map();
const heldPointers = new Map();
const INPUT_MUTATIONS = new Set([
"open_application", "left_click", "double_click", "triple_click", "right_click", "middle_click", "mouse_move",
"left_click_drag", "left_mouse_down", "left_mouse_up", "scroll", "type", "key", "hold_key", "set_value", "select_text", "perform_action",
]);
let queue = Promise.resolve();
async function backend(computerId) {
if (!backends.has(computerId)) {
async function backend(computerId, sessionId, persistentInputOwner) {
const key = `${computerId}:${sessionId}`;
if (!backends.has(key)) {
// Same test hook as src/transport.mjs, so the out-of-process route can be
// driven end to end against a recording backend (never set in production).
const test = process.env.CODEWHALE_CU_TEST_BACKEND;
const mod = await import(test ? url.pathToFileURL(test).href : `./backends/${process.platform}.mjs`);
backends.set(computerId, mod.create({ exec, computer: { id: computerId, transport: "local", platform: process.platform } }));
backends.set(key, mod.create({ exec: { ...exec, persistentInputOwner }, computer: { id: computerId, transport: "local", platform: process.platform } }));
}
return backends.get(computerId);
return backends.get(key);
}
const sessions = new Map();
function enqueue(fn) {
const next = queue.then(fn);
queue = next.catch(() => {});
return next;
}
/** Cancel this host's work, then release only input held by its backend. */
export function releaseSessionInput(sessionId, computerId = "local", { close = false } = {}) {
const key = `${computerId}:${sessionId}`;
let session = sessions.get(key);
if (!session) {
if (!close) return Promise.resolve();
session = { requests: new Set(), closed: true, touched: Date.now() };
sessions.set(key, session);
}
if (close) session.closed = true;
for (const controller of session.requests) controller.abort();
return enqueue(async () => {
try {
await withSignal(null, () => backends.get(key)?.releaseInput?.());
if (heldPointers.get(computerId) === key) heldPointers.delete(computerId);
if (close) await withSignal(null, () => backends.get(key)?.closeSession?.());
if (close) backends.delete(key);
} finally { session.touched = Date.now(); }
});
}
export function closeSession(sessionId, computerId = "local") {
return releaseSessionInput(sessionId, computerId, { close: true });
}
/**
* A freshly granted session owner supersedes a closed-session tombstone:
* without this, a daemon that re-leases a session id (the old owner socket
* died with the previous daemon) would keep aborting every request on it.
* In-flight requests from the dead owner stay aborted; the tombstone is the
* only thing removed.
*/
export function reopenSession(sessionId, computerId = "local") {
sessions.delete(`${computerId}:${sessionId}`);
}
export function closeAllSessions() {
return Promise.allSettled([...sessions.keys()].map((key) => {
const colon = key.indexOf(":");
return closeSession(key.slice(colon + 1), key.slice(0, colon));
}));
}
/**
* Execute one {tool, args} request on this machine's backend. Never throws:
* every outcome is a receipt object with `ok`.
*/
export async function handle(req, { computerId = "local" } = {}) {
export async function handle(req, { computerId = "local", sessionId = "direct", signal, persistentInputOwner = false } = {}) {
const tool = req?.tool;
if (!ALLOWED.has(tool)) {
return { ok: false, error: { code: "tool_not_allowed", message: `tool "${tool}" is not in the remote allow-list` } };
}
if (tool === "platform") return { ok: true, platform: process.platform };
try {
const fn = (await backend(computerId))[tool];
if (typeof fn !== "function") {
return { ok: false, error: { code: "unsupported_on_platform", message: `"${tool}" is not implemented on ${process.platform}` } };
const key = `${computerId}:${sessionId}`;
let session = sessions.get(key);
if (!session) {
// Retain closed-session tombstones briefly, and bound abandoned sessions
// when a host is killed without a graceful MCP disconnect.
for (const [id, old] of sessions) {
if (old.requests.size || Date.now() - old.touched <= (old.closed ? 300_000 : 3_600_000)) continue;
if (backends.has(id)) {
const colon = id.indexOf(":");
try { await closeSession(id.slice(colon + 1), id.slice(0, colon)); }
catch (err) { return { ok: false, error: { code: "input_release_failed", message: String(err?.message ?? err) } }; }
} else sessions.delete(id);
}
const data = await fn(req.args ?? {});
return { ok: true, platform: process.platform, tool, data };
if ([...sessions.values()].filter((entry) => !entry.closed).length >= 256) return { ok: false, error: { code: "session_limit", message: "Too many active computer sessions; close unused hosts or restart the helper." } };
session = { requests: new Set(), closed: false, touched: Date.now() };
sessions.set(key, session);
}
const controller = new AbortController();
const abort = () => controller.abort();
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted || session.closed) controller.abort();
session.requests.add(controller);
// One desktop can execute only one input gesture at a time. The queue spans
// sockets and sessions; a disconnected/cancelled request is checked again
// when its slot arrives, before it can post any input.
try {
return await enqueue(() => withSignal(controller.signal, async () => {
throwIfAborted();
const instance = await backend(computerId, sessionId, persistentInputOwner);
throwIfAborted();
if (INPUT_MUTATIONS.has(tool) && heldPointers.has(computerId) && heldPointers.get(computerId) !== key) {
return { ok: false, error: { code: "input_busy", message: "Another computer session owns a held pointer; release it or close that session before sending input." } };
}
const fn = instance[tool];
if (typeof fn !== "function") {
return { ok: false, error: { code: "unsupported_on_platform", message: `"${tool}" is not implemented on ${process.platform}` } };
}
// Preserve ownership across calls, not just during the serialized
// request. Another host's click/up must not release this host's press.
if (tool === "left_mouse_down") heldPointers.set(computerId, key);
let data;
try { data = await fn(req.args ?? {}); throwIfAborted(); }
catch (error) {
if (["left_mouse_down", "left_mouse_up", "mouse_move"].includes(tool) && heldPointers.get(computerId) === key) {
await withSignal(null, () => instance.releaseInput?.());
if (heldPointers.get(computerId) === key) heldPointers.delete(computerId);
}
throw error;
}
if (tool === "left_mouse_up" && heldPointers.get(computerId) === key) heldPointers.delete(computerId);
return { ok: true, platform: process.platform, tool, data };
}));
} catch (err) {
return { ok: false, platform: process.platform, tool, error: { code: err?.code ?? "tool_error", message: String(err?.message ?? err) } };
} finally {
signal?.removeEventListener("abort", abort);
session.requests.delete(controller);
session.touched = Date.now();
}
}
@@ -14,7 +14,7 @@ import url from "node:url";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { stateDir } from "./registry.mjs";
import { ExecError } from "./exec.mjs";
import { ExecError, currentSignal, throwIfAborted, wait } from "./exec.mjs";
export const PLUGIN_ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
export const APP_ID = "net.codewhale.computer-use";
@@ -54,26 +54,75 @@ export function writeRegistration(reg) {
}
/** Send one request to the app and await its single-line reply. */
export function appRequest(request, { timeoutMs = 30_000 } = {}) {
function requestConnection(request, { timeoutMs = 30_000, signal = currentSignal(), keepOpen = false } = {}) {
throwIfAborted(signal);
return new Promise((resolve, reject) => {
const sock = net.connect(socketPath());
let buf = "";
let settled = false;
const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); sock.destroy(); fn(v); };
const timer = setTimeout(() => done(reject, new ExecError(`${APP_NAME}: request timed out after ${timeoutMs}ms`, { code: "app_timeout" })), timeoutMs);
const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); if (!keepOpen || fn === reject) sock.destroy(); fn(v); };
const abort = () => done(reject, Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
const timer = setTimeout(() => done(reject, Object.assign(new ExecError(`${APP_NAME}: request timed out after ${timeoutMs}ms`), { code: "app_timeout" })), timeoutMs);
signal?.addEventListener("abort", abort, { once: true });
sock.on("error", (err) => done(reject, Object.assign(new ExecError(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`), { code: "app_unavailable" })));
sock.on("connect", () => sock.write(JSON.stringify(request) + "\n"));
sock.on("data", (d) => {
buf += d.toString("utf8");
const nl = buf.indexOf("\n");
if (nl === -1) return;
try { done(resolve, JSON.parse(buf.slice(0, nl))); }
try { const reply = JSON.parse(buf.slice(0, nl)); done(resolve, keepOpen ? { reply, socket: sock } : reply); }
catch { done(reject, Object.assign(new ExecError(`${APP_NAME}: malformed reply`), { code: "app_bad_reply" })); }
});
sock.on("close", () => done(reject, Object.assign(new ExecError(`${APP_NAME}: connection closed before a reply`), { code: "app_unavailable" })));
});
}
export function appRequest(request, options) { return requestConnection(request, options); }
// A live socket is the session owner, independent of short-lived cancellable
// request sockets. The OS closes it even if the MCP process is killed; no PID
// lookup or reuse-prone process identity is needed to release held input.
// When the socket dies without close_session (an app update replaces the
// daemon and every socket it owned), the dead lease is dropped so the next
// request re-opens one instead of failing forever.
const sessionLeases = new Map();
export function openAppSession(sessionId) {
if (!sessionLeases.has(sessionId)) {
const pending = requestConnection({ tool: "open_session", sessionId }, { timeoutMs: 3_000, signal: null, keepOpen: true }).then(({ reply, socket }) => {
if (!reply?.ok || typeof reply.leaseToken !== "string") {
socket.destroy();
throw Object.assign(new ExecError(reply?.error?.message ?? "Computer session lease was refused"), { code: reply?.error?.code ?? "app_session_closed" });
}
const lease = { token: reply.leaseToken, socket, closed: socket.destroyed, deliberate: false };
socket.once("close", () => {
lease.closed = true;
if (sessionLeases.get(sessionId) === pending && !lease.deliberate) sessionLeases.delete(sessionId);
});
// Library clients need not keep Node alive solely for an idle lease.
socket.unref();
return lease;
});
// A refused or unreachable open is retried on the next request, not cached.
pending.catch(() => { if (sessionLeases.get(sessionId) === pending) sessionLeases.delete(sessionId); });
sessionLeases.set(sessionId, pending);
}
return sessionLeases.get(sessionId);
}
export async function appSessionRequest(request, options = {}) {
throwIfAborted(options.signal === undefined ? currentSignal() : options.signal);
let lease = await openAppSession(request.sessionId);
if (lease.closed) {
if (lease.deliberate) throw Object.assign(new ExecError("Computer session was closed; start a new session to continue"), { code: "app_session_closed" });
// The fresh lease is on a daemon that holds no input for this session,
// so nothing the old lease held can replay across the reconnect.
lease = await openAppSession(request.sessionId);
if (lease.closed) throw Object.assign(new ExecError("Computer session lease could not be re-established with the helper; retry the request"), { code: "app_session_closed" });
}
try { return await appRequest({ ...request, leaseToken: lease.token }, options); }
finally { if (request.tool === "close_session") { lease.deliberate = true; lease.socket.destroy(); } }
}
/** App identity if it is running, else null. Cheap: one connect. */
export async function hello({ timeoutMs = 2_000 } = {}) {
try {
@@ -107,11 +156,19 @@ let lastLaunchAt = 0;
*/
export async function ensureApp({ launch = true } = {}) {
if (process.env.CODEWHALE_CU_APP === "off") return { via: "direct", reason: "CODEWHALE_CU_APP=off" };
if (process.platform === "darwin" && fs.existsSync(path.join(PLUGIN_ROOT, "bin", "darwin", "accessibility"))) {
return { via: "direct", reason: "Using the Computer Use helper included with Codewhale. Grant Accessibility and Screen Recording to the host app in macOS System Settings when requested." };
}
let app = await hello();
throwIfAborted();
if (app) return { via: "app", app };
const reg = readRegistration();
if (!reg) {
return { via: "direct", reason: `${APP_NAME} is not installed on this computer; run "npm run build:app && npm run install:app" in the plugin so OS permissions belong to the app instead of the host terminal` };
const standalone = fs.existsSync(path.join(PLUGIN_ROOT, "scripts", "build-app.mjs"));
return { via: "direct", reason: standalone
? `${APP_NAME} is not installed. Input and screen permissions belong to the current host. To use a standalone permission-owning helper, run "npm run build:app && npm run install:app" in the plugin checkout.`
: "Using the Computer Use helper included with Codewhale. Input and screen permissions belong to the current host app; grant them in your operating system's privacy settings when requested." };
}
if (!launch || Date.now() - lastLaunchAt < 15_000) {
return { via: "direct", reason: `${APP_NAME} is installed at ${reg.path} but not running (last launch attempt did not come up)` };
@@ -120,8 +177,9 @@ export async function ensureApp({ launch = true } = {}) {
launchApp(reg);
const deadline = Date.now() + 8_000;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 250));
await wait(250);
app = await hello({ timeoutMs: 1_000 });
throwIfAborted();
if (app) return { via: "app", app, launched: true };
}
return { via: "direct", reason: `${APP_NAME} at ${reg.path} did not answer within 8s of launch; open it manually and check ${runInfoPath()}` };
@@ -1,9 +1,112 @@
#import <Cocoa/Cocoa.h>
#import <ApplicationServices/ApplicationServices.h>
#include <unistd.h>
#include <signal.h>
#include <poll.h>
#include <fcntl.h>
#include <sys/file.h>
#include <sys/stat.h>
#import "darwin-recording.h"
#import "darwin-ocr.h"
static volatile sig_atomic_t cuCancelled = 0;
static BOOL cuOwnerPipe = NO;
static NSDictionary *cuLeaseKey = nil;
static pid_t cuLeasePid = 0;
static NSRunningApplication *cuLeaseApp = nil;
static BOOL cuLeaseButtons[3] = {NO,NO,NO};
static CGPoint cuLeasePoint;
#ifdef CU_TEST
static NSString *cuTestLockDir = nil;
static NSString *cuTestReleaseFile = nil;
#endif
static void cuCancel(int signum) { cuCancelled = 1; }
static void cuCheckCancelled(void) {
if(cuOwnerPipe) { struct pollfd fd={STDIN_FILENO,POLLHUP,0}; if(poll(&fd,1,0)>0 && (fd.revents&POLLHUP)) cuCancelled=1; }
if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
}
static void cuLockInput(void) {
// One physical desktop, including separately launched direct MCP hosts.
// The kernel releases this lock if the native owner itself crashes.
NSString *dir=[NSHomeDirectory() stringByAppendingPathComponent:@".codewhale-cu"];
#ifdef CU_TEST
if(cuTestLockDir) dir=cuTestLockDir;
#endif
[NSFileManager.defaultManager createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:@{NSFilePosixPermissions:@0700} error:nil];
int fd=open([[dir stringByAppendingPathComponent:@"input.lock"] fileSystemRepresentation],O_CREAT|O_RDWR|O_NOFOLLOW|O_CLOEXEC,0600);
if(fd<0) @throw [NSException exceptionWithName:@"input_lock" reason:[NSString stringWithFormat:@"cannot open Computer Use input ownership lock: %s",strerror(errno)] userInfo:nil];
struct stat st;
if(fd<0 || fstat(fd,&st)!=0 || !S_ISREG(st.st_mode) || st.st_uid!=getuid() || flock(fd,LOCK_EX|LOCK_NB)!=0) {
if(fd>=0) close(fd);
@throw [NSException exceptionWithName:@"input_busy" reason:@"another Computer Use session owns held input; release its key or pointer before sending input" userInfo:nil];
}
}
static void cuRequireForeground(NSRunningApplication *expected) {
NSRunningApplication *actual=NSWorkspace.sharedWorkspace.frontmostApplication;
if(actual.processIdentifier!=expected.processIdentifier)
@throw [NSException exceptionWithName:@"focus" reason:[NSString stringWithFormat:@"foreground changed to %@ (pid %d); expected %@ (pid %d). No key-down or text was sent to the new foreground application.",actual.localizedName?:@"unknown application",actual.processIdentifier,expected.localizedName?:@"bound application",expected.processIdentifier] userInfo:nil];
}
static id cuPostKey(NSDictionary *args, pid_t destination) {
CGEventRef event=CGEventCreateKeyboardEvent(NULL,[args[@"code"] unsignedShortValue],[args[@"down"] boolValue]);
CGEventSetFlags(event,[args[@"flags"] unsignedLongLongValue]);
if([args[@"foreground_input"] boolValue]) CGEventPost(kCGHIDEventTap,event);
else CGEventPostToPid(destination,event);
CFRelease(event);
return @{@"action_sent":@YES};
}
static void cuPrint(id result) {
NSData *data=[NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingFragmentsAllowed error:nil];
puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); fflush(stdout);
}
static void cuReleaseLease(void) {
#ifdef CU_TEST
if(cuTestReleaseFile) { [@"released" writeToFile:cuTestReleaseFile atomically:YES encoding:NSUTF8StringEncoding error:nil]; cuTestReleaseFile=nil; }
#endif
if(cuLeaseKey) {
NSMutableDictionary *up=[cuLeaseKey mutableCopy]; up[@"down"]=@NO;
if([up[@"foreground_input"] boolValue] || !cuLeaseApp.terminated) cuPostKey(up,cuLeasePid);
cuLeaseKey=nil; cuLeaseApp=nil;
}
for(int button=0;button<3;button++) if(cuLeaseButtons[button]) {
CGEventType up=button==0?kCGEventLeftMouseUp:button==1?kCGEventRightMouseUp:kCGEventOtherMouseUp;
CGEventRef event=CGEventCreateMouseEvent(NULL,up,cuLeasePoint,button);
CGEventPost(kCGHIDEventTap,event); CFRelease(event); cuLeaseButtons[button]=NO;
}
}
static void cuWaitForLease(void) {
NSMutableData *buffer=[NSMutableData data];
@try {
while(!cuCancelled) {
struct pollfd fd={STDIN_FILENO,POLLIN|POLLHUP,0};
int ready=poll(&fd,1,100);
if(ready<=0) continue;
char byte; ssize_t n=read(STDIN_FILENO,&byte,1);
if(n<=0) break;
if(byte!='\n') { if(buffer.length>=4096) break; [buffer appendBytes:&byte length:1]; continue; }
NSDictionary *message=[NSJSONSerialization JSONObjectWithData:buffer options:0 error:nil];
[buffer setLength:0];
if(![message isKindOfClass:NSDictionary.class]) break;
NSDictionary *point=message[@"point"];
if([point[@"x"] isKindOfClass:NSNumber.class] && [point[@"y"] isKindOfClass:NSNumber.class]) {
cuLeasePoint=CGPointMake([point[@"x"] doubleValue],[point[@"y"] doubleValue]);
}
if([message[@"release"] boolValue]) break;
cuCheckCancelled();
if(!cuLeaseButtons[0] || !point) break;
CGEventSourceRef source=CGEventSourceCreate(kCGEventSourceStateHIDSystemState);
CGEventRef event=CGEventCreateMouseEvent(source,kCGEventLeftMouseDragged,cuLeasePoint,kCGMouseButtonLeft);
CGEventSetIntegerValueField(event,kCGMouseEventClickState,1);
CGEventPost(kCGHIDEventTap,event); CFRelease(event); CFRelease(source);
cuPrint(@{@"action_sent":@YES,@"restored":@NO});
}
} @finally { cuReleaseLease(); }
}
static id attr(AXUIElementRef el, NSString *name) {
#ifdef CU_TEST
// The observation fixture exercises the real walker without reading a GUI.
if([(__bridge id)el isKindOfClass:NSDictionary.class]) return ((__bridge NSDictionary *)el)[name];
#endif
CFTypeRef out = NULL;
AXError e = AXUIElementCopyAttributeValue(el, (__bridge CFStringRef)name, &out);
return e == kAXErrorSuccess ? CFBridgingRelease(out) : nil;
@@ -28,25 +131,73 @@ static NSDictionary *info(AXUIElementRef el, NSInteger index, NSInteger win, NSA
id p=geometry(attr(el,@"AXPosition"),NO), s=geometry(attr(el,@"AXSize"),YES);
if(p) d[@"position"]=p; if(s) d[@"size"]=s;
CFArrayRef actions=NULL;
#ifdef CU_TEST
if([(__bridge id)el isKindOfClass:NSDictionary.class]) d[@"actions"]=attr(el,@"actions")?:@[];
else
#endif
if(AXUIElementCopyActionNames(el,&actions)==kAXErrorSuccess) d[@"actions"]=CFBridgingRelease(actions);
else d[@"actions"]=@[];
return d;
}
static void walk(AXUIElementRef el, NSInteger win, NSArray *path, NSInteger depth, NSInteger limit, NSInteger max, NSMutableArray *out, BOOL *truncated) {
if(out.count>=max || depth>limit){ *truncated=YES; return; }
[out addObject:info(el,out.count,win,path)];
NSArray *kids=attr(el,@"AXChildren");
for(NSUInteger i=0;i<kids.count;i++) {
static void walk(AXUIElementRef el, NSInteger win, NSArray *path, NSInteger depth, NSInteger limit, NSInteger max, BOOL depthIsTruncation, NSMutableArray *out, BOOL *truncated) {
// Breadth first keeps a long file listing from hiding its dialog buttons.
NSMutableArray *queue=[NSMutableArray arrayWithObject:@{@"el":(__bridge id)el,@"path":path,@"depth":@(depth)}];
for(NSUInteger cursor=0;cursor<queue.count;cursor++) {
if(out.count>=max){ *truncated=YES; break; }
walk((__bridge AXUIElementRef)kids[i],win,[path arrayByAddingObject:@(i)],depth+1,limit,max,out,truncated);
NSDictionary *item=queue[cursor];
AXUIElementRef current=(__bridge AXUIElementRef)item[@"el"];
NSArray *currentPath=item[@"path"];
NSInteger currentDepth=[item[@"depth"] integerValue];
[out addObject:info(current,out.count,win,currentPath)];
NSArray *kids=attr(current,@"AXChildren");
if(currentDepth>=limit){ if(kids.count && depthIsTruncation) *truncated=YES; continue; }
for(NSUInteger i=0;i<kids.count;i++) {
if(queue.count-cursor>=(NSUInteger)max){ *truncated=YES; break; }
[queue addObject:@{@"el":kids[i],@"path":[currentPath arrayByAddingObject:@(i)],@"depth":@(currentDepth+1)}];
}
}
}
static NSArray *observeElements(AXUIElementRef app, NSArray *ws, NSDictionary *args, BOOL listWindows, BOOL *truncated) {
NSMutableArray *out=[NSMutableArray array];
BOOL full=[args[@"detail"] isEqual:@"full"];
NSInteger limit=full?16:10, max=full?800:400;
if(!listWindows && !args[@"window_id"]) {
// Open popup menus remain useful. Hidden menu-bar descendants belong in
// the full view; summary reserves their budget for the app's actual UI.
NSInteger menuMax=max/4;
NSArray *children=attr(app,@"AXChildren");
for(NSUInteger i=0;i<children.count;i++) {
NSString *role=attr((__bridge AXUIElementRef)children[i],@"AXRole");
if([role isEqual:@"AXMenu"]) walk((__bridge AXUIElementRef)children[i],-2,@[@(i)],0,limit,menuMax/2,YES,out,truncated);
}
id menu=attr(app,@"AXMenuBar");
if(menu) walk((__bridge AXUIElementRef)menu,-1,@[],0,full?limit:1,menuMax,full,out,truncated);
}
for(NSUInteger i=0;i<ws.count;i++) {
if(args[@"window_id"] && i!=[args[@"window_id"] unsignedIntegerValue]) continue;
if(listWindows){ NSMutableDictionary *d=[info((__bridge AXUIElementRef)ws[i],i,i,@[]) mutableCopy]; d[@"title"]=d[@"label"]?:@""; [out addObject:d]; }
else walk((__bridge AXUIElementRef)ws[i],i,@[],0,limit,max,YES,out,truncated);
}
return out;
}
static BOOL cuFrame(AXUIElementRef el, CGRect *out) {
NSDictionary *p=geometry(attr(el,@"AXPosition"),NO), *z=geometry(attr(el,@"AXSize"),YES);
if(!p || !z) return NO;
*out=CGRectMake([p[@"x"] doubleValue],[p[@"y"] doubleValue],[z[@"w"] doubleValue],[z[@"h"] doubleValue]);
return YES;
}
static NSDictionary *capturableWindow(NSArray *windows, pid_t pid, NSString *name, CGRect preferred) {
NSDictionary *matched=nil;
for(NSDictionary *w in windows) {
if([w[(__bridge NSString *)kCGWindowOwnerPID] intValue]!=pid || [w[(__bridge NSString *)kCGWindowLayer] intValue]!=0) continue;
CGRect b; if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds],&b) || b.size.width<1 || b.size.height<1) continue;
if(fabs(b.origin.x-preferred.origin.x)>1 || fabs(b.origin.y-preferred.origin.y)>1 || fabs(b.size.width-preferred.size.width)>1 || fabs(b.size.height-preferred.size.height)>1) continue;
if(matched) @throw [NSException exceptionWithName:@"window" reason:@"the selected window is ambiguous; observe the app windows again" userInfo:nil];
matched=@{@"window_id":w[(__bridge NSString *)kCGWindowNumber],@"name":name?:@"App",@"points":@{@"x":@(b.origin.x),@"y":@(b.origin.y),@"w":@(b.size.width),@"h":@(b.size.height)}};
}
if(!matched) @throw [NSException exceptionWithName:@"window" reason:@"the selected app window is not capturable; observe the app windows again" userInfo:nil];
return matched;
}
static BOOL cuPressable(AXUIElementRef el) {
CFArrayRef names=NULL;
NSArray *actions=AXUIElementCopyActionNames(el,&names)==kAXErrorSuccess?CFBridgingRelease(names):@[];
@@ -115,14 +266,131 @@ static CGEventRef textEvent(NSString *text, BOOL down) {
CGEventRef event=CGEventCreateKeyboardEvent(NULL,0,down);
CGEventKeyboardSetUnicodeString(event,text.length,chars); free(chars); return event;
}
static BOOL cuTextRole(NSString *role) {
return [@[@"AXTextField",@"AXTextArea",@"AXComboBox",@"AXSearchField",@"AXSecureTextField",@"AXWebArea"] containsObject:role];
}
// NSString lengths are UTF-16 unit counts on both sides, so emoji compare
// consistently. The length check survives autocorrect/IME transforms that
// defeat the suffix check, as long as the character count is preserved.
static BOOL cuTypeVerified(NSString *before, NSString *after, NSString *text) {
return after && ([after hasSuffix:text] || (before && after.length==before.length+text.length));
}
static id cuFocusedElement(pid_t pid) {
AXUIElementRef appEl=AXUIElementCreateApplication(pid);
AXUIElementSetMessagingTimeout(appEl,2.0);
id focused=attr(appEl,@"AXFocusedUIElement");
CFRelease(appEl);
return focused;
}
/**
* Type into whatever holds focus in the bound app, then prove it landed.
* Dispatch succeeding is not delivery (a process with no text receiver drops
* the events silently), so the receipt reports `verified` from the focused
* control's own value. Failure to verify is reported, not thrown — the events
* already went out. The one throw is before any event is posted: a focused
* element that is clearly not a text control.
*/
static NSDictionary *cuType(NSDictionary *args, NSRunningApplication *inputApp, id focused, BOOL simulated) {
NSString *text=args[@"text"];
if(![text isKindOfClass:NSString.class]) @throw [NSException exceptionWithName:@"text" reason:@"text must be a string" userInfo:nil];
NSString *role=focused?attr((__bridge AXUIElementRef)focused,@"AXRole"):nil;
BOOL secure=[role isEqual:@"AXSecureTextField"];
NSString *before=nil;
// A secure field's value is never read; it verifies as unverifiable.
if(focused && !secure) { id v=attr((__bridge AXUIElementRef)focused,@"AXValue"); if([v isKindOfClass:NSString.class]) before=v; }
// Fail closed only on strong evidence: something holds focus and it is
// clearly not text. No focused element at all still receives the events —
// some apps take process-directed keys without reporting AX focus.
if(focused && !cuTextRole(role) && !before)
@throw [NSException exceptionWithName:@"focus" reason:[NSString stringWithFormat:@"focused element is a %@, not a text control — click or focus a text field first",role?:@"unknown element"] userInfo:nil];
// One grapheme per event, the way a keyboard delivers them. Batching
// several into one CGEventKeyboardSetUnicodeString is faster but Electron
// apps coalesce the pending payload and keep only the final batch, so a
// typed string silently arrives truncated to its tail.
for(NSUInteger i=0;i<text.length && !cuCancelled;) {
if([args[@"foreground_input"] boolValue]) cuRequireForeground(inputApp);
cuCheckCancelled();
NSRange range=[text rangeOfComposedCharacterSequencesForRange:NSMakeRange(i,1)];
NSString *chunk=[text substringWithRange:range];
if(!simulated) for(int down=1;down>=0;down--){ CGEventRef event=textEvent(chunk,down); if([args[@"foreground_input"] boolValue]) CGEventPost(kCGHIDEventTap,event); else CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); }
i=NSMaxRange(range); usleep(10000);
}
if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
NSString *after=nil;
if(focused && !secure) {
#ifdef CU_TEST
if(simulated) { NSString *s=((NSMutableDictionary *)focused)[@"after"]; if(s) ((NSMutableDictionary *)focused)[@"AXValue"]=s; }
else
#endif
usleep(80000);
id v=attr((__bridge AXUIElementRef)focused,@"AXValue");
if([v isKindOfClass:NSString.class]) after=v;
}
BOOL verified=cuTypeVerified(before,after,text);
NSMutableDictionary *receipt=[@{@"action_sent":@YES,@"chars":@(text.length),@"strategy":@"unicode-events",
@"keyboard_delivery":[args[@"foreground_input"] boolValue]?@"foreground-guarded":@"process",
@"verified":@(verified),@"focused_role":role?:[NSNull null]} mutableCopy];
if(!verified) receipt[@"verification_required"]=@"screenshot";
return receipt;
}
static id execute(NSDictionary *p) {
NSString *tool=p[@"tool"]; NSDictionary *args=p[@"args"]?:@{};
cuOwnerPipe=[args[@"owner_pipe"] boolValue];
BOOL mutates=[@[@"type",@"key_event",@"mouse_event",@"scroll",@"pointer_sequence",@"release_input",@"set_value",@"select_text",@"perform_action"] containsObject:tool]
|| ([tool isEqual:@"hit_test"] && [args[@"perform"] boolValue])
|| ([tool isEqual:@"app_info"] && [args[@"activate"] boolValue]);
if([tool isEqual:@"release_input"]) {
if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing" userInfo:nil];
cuLockInput();
NSDictionary *point=args[@"point"];
CGPoint at=CGPointMake([point[@"x"] doubleValue],[point[@"y"] doubleValue]);
CGMouseButton button=[args[@"button"] unsignedIntValue];
CGEventType up=button==0?kCGEventLeftMouseUp:button==1?kCGEventRightMouseUp:kCGEventOtherMouseUp;
CGEventRef event=CGEventCreateMouseEvent(NULL,up,at,button);
CGEventPost(kCGHIDEventTap,event); CFRelease(event);
return @{@"released":@YES};
}
if([tool isEqual:@"key_event"] && ![args[@"down"] boolValue] && [args[@"owned_release"] boolValue]) {
if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing" userInfo:nil];
cuLockInput();
// Release a confirmed/ambiguous press even if its original app has exited.
return cuPostKey(args,[args[@"input_app_ref"][@"pid"] intValue]);
}
cuCheckCancelled();
if([tool isEqual:@"input_capabilities"]) return @{@"input_lease":@1,@"owner_pipe":@YES,@"record_owner_pipe":@1,@"window_ocr":@1};
if([tool isEqual:@"record"]) return cuRecord(args);
if([tool isEqual:@"recognize_text"]) return cuRecognizeText(args[@"file"]);
#ifdef CU_TEST
if([tool isEqual:@"inspect_window_match"]) {
NSDictionary *b=args[@"bounds"];
CGRect bounds=CGRectMake([b[@"x"] doubleValue],[b[@"y"] doubleValue],[b[@"w"] doubleValue],[b[@"h"] doubleValue]);
return capturableWindow(args[@"windows"],[args[@"pid"] intValue],@"Fixture",bounds);
}
if([tool isEqual:@"inspect_observation"]) {
BOOL truncated=NO;
NSArray *elements=observeElements((__bridge AXUIElementRef)args[@"app"],args[@"windows"]?:@[],args,NO,&truncated);
return @{@"elements":elements,@"truncated":@(truncated)};
}
if([tool isEqual:@"test_input_lease"]) {
cuTestLockDir=args[@"lock_dir"]; cuLockInput();
cuTestReleaseFile=args[@"release_file"];
if([args[@"work_ms"] intValue]>0) {
cuPrint(@{@"action_sent":@YES,@"input_lease":@YES});
for(int elapsed=0;elapsed<[args[@"work_ms"] intValue];elapsed+=20) { cuCheckCancelled(); usleep(20000); }
}
return @{@"action_sent":@YES};
}
if([tool isEqual:@"inspect_text_event"]) {
CGEventRef event=textEvent(args[@"text"],YES); UniChar chars[4096]; UniCharCount length=0;
CGEventKeyboardGetUnicodeString(event,4096,&length,chars); CFRelease(event);
return @{@"text":[NSString stringWithCharacters:chars length:length]};
CGEventKeyboardGetUnicodeString(event,4096,&length,chars); CGEventFlags flags=CGEventGetFlags(event); CFRelease(event);
return @{@"text":[NSString stringWithCharacters:chars length:length],@"flags":@(flags)};
}
// Drives the real typing logic against a fixture focused element instead of
// a live app: `after` is the value the element reports once the text lands,
// which a fixture omits to model an app that swallows the events.
if([tool isEqual:@"inspect_type"]) {
id fixture=args[@"focused"];
return cuType(args, nil, [fixture isKindOfClass:NSDictionary.class]?[fixture mutableCopy]:nil, YES);
}
#endif
if([tool isEqual:@"permissions"]) return @{@"trusted":@(AXIsProcessTrusted())};
@@ -149,16 +417,14 @@ static id execute(NSDictionary *p) {
if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
AXUIElementRef ax=AXUIElementCreateApplication(a.processIdentifier);
NSArray *axWindows=attr(ax,@"AXWindows");
NSDictionary *preferred=axWindows.count?geometry(attr((__bridge AXUIElementRef)axWindows[0],@"AXPosition"),NO):nil;
NSInteger index=[args[@"window_id"] integerValue];
CGRect preferred;
BOOL integerIndex=!args[@"window_id"] || ([args[@"window_id"] isKindOfClass:NSNumber.class] && [args[@"window_id"] doubleValue]==index);
BOOL valid=integerIndex && index>=0 && index<axWindows.count && cuFrame((__bridge AXUIElementRef)axWindows[index],&preferred);
CFRelease(ax);
if(!valid) @throw [NSException exceptionWithName:@"window" reason:@"the selected app window has no accessibility geometry; call list_windows for a valid window index" userInfo:nil];
NSArray *windows=CFBridgingRelease(CGWindowListCopyWindowInfo(kCGWindowListOptionAll,kCGNullWindowID));
for(NSDictionary *w in windows) {
if([w[(__bridge NSString *)kCGWindowOwnerPID] intValue]!=a.processIdentifier || [w[(__bridge NSString *)kCGWindowLayer] intValue]!=0) continue;
CGRect b; if(!CGRectMakeWithDictionaryRepresentation((__bridge CFDictionaryRef)w[(__bridge NSString *)kCGWindowBounds],&b) || b.size.width<1 || b.size.height<1) continue;
if(preferred && (fabs(b.origin.x-[preferred[@"x"] doubleValue])>1 || fabs(b.origin.y-[preferred[@"y"] doubleValue])>1)) continue;
return @{@"window_id":w[(__bridge NSString *)kCGWindowNumber],@"name":a.localizedName?:@"App",@"points":@{@"x":@(b.origin.x),@"y":@(b.origin.y),@"w":@(b.size.width),@"h":@(b.size.height)}};
}
@throw [NSException exceptionWithName:@"window" reason:@"application has no capturable window" userInfo:nil];
return capturableWindow(windows,a.processIdentifier,a.localizedName,preferred);
}
// Which application owns the point a pointer event would land on. A global
// pointer event goes to whatever is on top, so this is what stops a click
@@ -191,6 +457,8 @@ static id execute(NSDictionary *p) {
if([tool isEqual:@"app_info"]) {
NSRunningApplication *a=resolve(args[@"app_ref"]?:@{});
if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
if([args[@"activate"] boolValue]) cuLockInput();
cuCheckCancelled();
if([args[@"activate"] boolValue] && !axActivate(a.processIdentifier)) [a activateWithOptions:0];
return @{@"found":@YES,@"name":a.localizedName?:@"",@"pid":@(a.processIdentifier),@"bundle_id":a.bundleIdentifier?:@"",@"frontmost":@(a.active)};
}
@@ -200,25 +468,15 @@ static id execute(NSDictionary *p) {
inputApp=resolve(args[@"input_app_ref"]);
if(!inputApp || inputApp.terminated) @throw [NSException exceptionWithName:@"focus" reason:@"input application is no longer running; open_application again" userInfo:nil];
}
if(mutates) { cuCheckCancelled(); cuLockInput(); }
if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing for Codewhale Computer Use (or the direct host)." userInfo:nil];
if([tool isEqual:@"type"]) {
NSString *text=args[@"text"];
if(![text isKindOfClass:NSString.class]) @throw [NSException exceptionWithName:@"text" reason:@"text must be a string" userInfo:nil];
// One grapheme per event, the way a keyboard delivers them. Batching
// several into one CGEventKeyboardSetUnicodeString is faster but Electron
// apps coalesce the pending payload and keep only the final batch, so a
// typed string silently arrives truncated to its tail.
for(NSUInteger i=0;i<text.length;) {
NSRange range=[text rangeOfComposedCharacterSequencesForRange:NSMakeRange(i,1)];
NSString *chunk=[text substringWithRange:range];
for(int down=1;down>=0;down--){ CGEventRef event=textEvent(chunk,down); CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); }
i=NSMaxRange(range); usleep(10000);
}
return @{@"action_sent":@YES,@"chars":@(text.length),@"strategy":@"unicode-events"};
}
if([tool isEqual:@"type"]) return cuType(args, inputApp, cuFocusedElement(inputApp.processIdentifier), NO);
if([tool isEqual:@"key_event"]) {
CGEventRef event=CGEventCreateKeyboardEvent(NULL,[args[@"code"] unsignedShortValue],[args[@"down"] boolValue]);
CGEventSetFlags(event,[args[@"flags"] unsignedLongLongValue]); CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); return @{@"action_sent":@YES};
if([args[@"foreground_input"] boolValue] && [args[@"down"] boolValue]) cuRequireForeground(inputApp);
cuCheckCancelled();
id result=cuPostKey(args,inputApp.processIdentifier);
if([args[@"input_lease"] boolValue] && [args[@"down"] boolValue]) { cuLeaseKey=args; cuLeasePid=inputApp.processIdentifier; cuLeaseApp=inputApp; }
return result;
}
if([tool isEqual:@"mouse_event"]) {
CGPoint p=CGPointMake([args[@"x"] doubleValue],[args[@"y"] doubleValue]);
@@ -231,6 +489,7 @@ static id execute(NSDictionary *p) {
CGEventSetIntegerValueField(event,91,[args[@"windowNumber"] longLongValue]);
CGEventSetIntegerValueField(event,92,[args[@"windowNumber"] longLongValue]);
}
cuCheckCancelled();
CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); return @{@"action_sent":@YES};
}
// Accessibility-first coordinate action: resolve the point against the bound
@@ -295,6 +554,7 @@ static id execute(NSDictionary *p) {
NSDictionary *element=info((__bridge AXUIElementRef)chosen,0,0,@[]);
if(![args[@"perform"] boolValue]) return @{@"found":@YES,@"element":element,@"action":@"AXPress",@"action_sent":@NO};
cuCheckCancelled();
AXError pe=AXUIElementPerformAction((__bridge AXUIElementRef)chosen,CFSTR("AXPress"));
if(pe!=kAXErrorSuccess) return @{@"found":@YES,@"element":element,@"action":@"AXPress",@"action_sent":@NO,@"reason":[NSString stringWithFormat:@"press_failed_%d",pe]};
return @{@"found":@YES,@"element":element,@"action":@"AXPress",@"action_sent":@YES};
@@ -327,10 +587,11 @@ static id execute(NSDictionary *p) {
// from the fact that decides it rather than from a frontmost read that
// the window server may not have caught up with yet.
BOOL takes=front.processIdentifier!=inputApp.processIdentifier;
cuCheckCancelled();
if(takes) {
axActivate(inputApp.processIdentifier);
for(int i=0;i<20;i++) {
if(NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier==inputApp.processIdentifier) break;
if(cuCancelled || NSWorkspace.sharedWorkspace.frontmostApplication.processIdentifier==inputApp.processIdentifier) break;
usleep(25000);
}
}
@@ -338,13 +599,22 @@ static id execute(NSDictionary *p) {
// the input hardware; a NULL-source stream delivers down and up but drops
// every mouseDragged in between.
CGEventSourceRef source=CGEventSourceCreate(kCGEventSourceStateHIDSystemState);
BOOL held[3]={NO,NO,NO};
CGPoint last=home;
for(NSDictionary *step in args[@"steps"]) {
@try { cuCheckCancelled(); } @catch(NSException *e) { break; }
CGEventRef event;
if(step[@"scroll"]) {
NSArray *d=step[@"scroll"];
event=CGEventCreateScrollWheelEvent(source,kCGScrollEventUnitLine,2,[d[1] intValue],[d[0] intValue]);
} else {
CGPoint p=CGPointMake([step[@"x"] doubleValue],[step[@"y"] doubleValue]);
last=p;
int button=[step[@"button"] intValue], kind=[step[@"type"] intValue];
if(button>=0 && button<3) {
if(kind==kCGEventLeftMouseDown || kind==kCGEventRightMouseDown || kind==kCGEventOtherMouseDown) held[button]=YES;
if(kind==kCGEventLeftMouseUp || kind==kCGEventRightMouseUp || kind==kCGEventOtherMouseUp) held[button]=NO;
}
event=CGEventCreateMouseEvent(source,[step[@"type"] unsignedIntValue],p,[step[@"button"] unsignedIntValue]);
CGEventSetIntegerValueField(event,kCGMouseEventClickState,[step[@"clickState"] longLongValue]);
}
@@ -352,6 +622,15 @@ static id execute(NSDictionary *p) {
CFRelease(event);
usleep((useconds_t)([step[@"delayMs"] intValue]?:40)*1000);
}
if([args[@"input_lease"] boolValue] && !cuCancelled) {
for(int button=0;button<3;button++) cuLeaseButtons[button]=held[button];
cuLeasePoint=last;
}
if(cuCancelled || ![args[@"input_lease"] boolValue]) for(int button=0;button<3;button++) if(held[button]) {
CGEventType up=button==0?kCGEventLeftMouseUp:button==1?kCGEventRightMouseUp:kCGEventOtherMouseUp;
CGEventRef event=CGEventCreateMouseEvent(source,up,last,button);
CGEventPost(kCGHIDEventTap,event); CFRelease(event);
}
BOOL restore=[args[@"restore"] boolValue];
if(restore) {
usleep(60000);
@@ -359,6 +638,7 @@ static id execute(NSDictionary *p) {
CGEventPost(kCGHIDEventTap,back); CFRelease(back);
}
if(source) CFRelease(source);
if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
usleep(150000); // let the window server settle before reading it back
NSString *after=NSWorkspace.sharedWorkspace.frontmostApplication.localizedName?:@"";
return @{@"action_sent":@YES,@"pointer_moved":@YES,@"restored":@(restore),
@@ -367,6 +647,7 @@ static id execute(NSDictionary *p) {
@"home":@{@"x":@(home.x),@"y":@(home.y)}};
}
if([tool isEqual:@"scroll"]) {
cuCheckCancelled();
CGEventRef event=CGEventCreateScrollWheelEvent(NULL,kCGScrollEventUnitLine,2,[args[@"dy"] intValue],[args[@"dx"] intValue]); CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); return @{@"action_sent":@YES};
}
if([tool isEqual:@"cursor_position"]) {
@@ -380,39 +661,53 @@ static id execute(NSDictionary *p) {
NSArray *ws=attr(app,@"AXWindows")?:@[];
NSDictionary *identity=@{@"found":@YES,@"name":a.localizedName?:@"",@"pid":@(a.processIdentifier),@"bundle_id":a.bundleIdentifier?:@"",@"frontmost":@(a.active)};
if([tool isEqual:@"get_app_state"] || [tool isEqual:@"list_windows"]) {
NSMutableArray *out=[NSMutableArray array]; BOOL truncated=NO;
for(NSUInteger i=0;i<ws.count;i++) {
if(args[@"window_id"] && i!=[args[@"window_id"] unsignedIntegerValue]) continue;
if([tool isEqual:@"list_windows"]){ NSMutableDictionary *d=[info((__bridge AXUIElementRef)ws[i],i,i,@[]) mutableCopy]; d[@"title"]=d[@"label"]?:@""; [out addObject:d]; }
else walk((__bridge AXUIElementRef)ws[i],i,@[],0,[args[@"detail"] isEqual:@"full"]?16:10,[args[@"detail"] isEqual:@"full"]?800:400,out,&truncated);
}
BOOL truncated=NO;
NSArray *out=observeElements(app,ws,args,[tool isEqual:@"list_windows"],&truncated);
NSMutableDictionary *d=[identity mutableCopy]; d[[tool isEqual:@"list_windows"]?@"windows":@"elements"]=out; d[@"truncated"]=@(truncated); return d;
}
if([tool isEqual:@"resolve_element"]) {
NSUInteger wi=[args[@"windowIndex"] unsignedIntegerValue];
if(wi>=ws.count) return @{@"found":@NO,@"element":[NSNull null],@"reason":@"window_not_found"};
id el=ws[wi];
NSInteger wi=[args[@"windowIndex"] integerValue];
id el=wi==-1?attr(app,@"AXMenuBar"):wi==-2?(__bridge id)app:(wi>=0 && wi<ws.count?ws[wi]:nil);
if(!el) return @{@"found":@NO,@"element":[NSNull null],@"reason":@"window_not_found"};
for(NSNumber *i in args[@"path"]?:@[]) { NSArray *kids=attr((__bridge AXUIElementRef)el,@"AXChildren"); if(i.unsignedIntegerValue>=kids.count) return @{@"found":@NO,@"element":[NSNull null],@"reason":@"path_not_found"}; el=kids[i.unsignedIntegerValue]; }
return @{@"found":@YES,@"element":info((__bridge AXUIElementRef)el,0,wi,args[@"path"]?:@[]),@"reason":[NSNull null]};
}
NSDictionary *t=args[@"target"]; NSUInteger wi=[t[@"windowIndex"] unsignedIntegerValue];
if(wi>=ws.count) @throw [NSException exceptionWithName:@"stale" reason:@"window is no longer available; observe again" userInfo:nil];
id el=ws[wi];
NSDictionary *t=args[@"target"]; NSInteger wi=[t[@"windowIndex"] integerValue];
id el=wi==-1?attr(app,@"AXMenuBar"):wi==-2?(__bridge id)app:(wi>=0 && wi<ws.count?ws[wi]:nil);
if(!el) @throw [NSException exceptionWithName:@"stale" reason:@"window is no longer available; observe again" userInfo:nil];
for(NSNumber *i in t[@"path"]) { NSArray *kids=attr((__bridge AXUIElementRef)el,@"AXChildren"); if(i.unsignedIntegerValue>=kids.count) @throw [NSException exceptionWithName:@"stale" reason:@"element is no longer available; observe again" userInfo:nil]; el=kids[i.unsignedIntegerValue]; }
if(wi>=0) {
NSArray *sheets=attr((__bridge AXUIElementRef)ws[wi],@"AXSheets");
if(sheets.count) {
BOOL inside=NO; id ancestor=el;
for(int depth=0;ancestor && depth<64;depth++) {
for(id sheet in sheets) if(CFEqual((__bridge CFTypeRef)ancestor,(__bridge CFTypeRef)sheet)) inside=YES;
if(inside) break;
ancestor=attr((__bridge AXUIElementRef)ancestor,@"AXParent");
}
if(!inside) @throw [NSException exceptionWithName:@"modal" reason:@"window blocked by modal sheet; observe and handle the dialog first" userInfo:nil];
}
}
cuCheckCancelled();
AXError e=kAXErrorFailure;
if([tool isEqual:@"set_value"]) e=AXUIElementSetAttributeValue((__bridge AXUIElementRef)el,kAXValueAttribute,(__bridge CFTypeRef)args[@"value"]);
else if([tool isEqual:@"select_text"]){ NSArray *r=args[@"text_range"]?:@[@0,@0]; if(r.count!=2 || [r[0] longValue]<0 || [r[1] longValue]<0) @throw [NSException exceptionWithName:@"range" reason:@"text_range must be [start, length], both nonnegative" userInfo:nil]; CFRange range=CFRangeMake([r[0] longValue],[r[1] longValue]); AXValueRef v=AXValueCreate(kAXValueCFRangeType,&range); e=AXUIElementSetAttributeValue((__bridge AXUIElementRef)el,kAXSelectedTextRangeAttribute,v); CFRelease(v); }
else if([tool isEqual:@"perform_action"]){ CFArrayRef actions=NULL; AXUIElementCopyActionNames((__bridge AXUIElementRef)el,&actions); NSArray *names=CFBridgingRelease(actions); if(![names containsObject:args[@"action"]]) @throw [NSException exceptionWithName:@"action" reason:@"action is not advertised by this element" userInfo:nil]; e=AXUIElementPerformAction((__bridge AXUIElementRef)el,(__bridge CFStringRef)args[@"action"]); }
else if([tool isEqual:@"perform_action"]){ CFArrayRef actions=NULL; AXUIElementCopyActionNames((__bridge AXUIElementRef)el,&actions); NSArray *names=CFBridgingRelease(actions); if(![names containsObject:args[@"action"]]) @throw [NSException exceptionWithName:@"action" reason:@"action is not advertised by this element" userInfo:nil]; cuCheckCancelled(); e=AXUIElementPerformAction((__bridge AXUIElementRef)el,(__bridge CFStringRef)args[@"action"]); }
if(e!=kAXErrorSuccess) @throw [NSException exceptionWithName:@"action" reason:[NSString stringWithFormat:@"accessibility action failed: %d",e] userInfo:nil];
return @{@"action_sent":@YES,@"strategy":@"a11y"};
} @finally { CFRelease(app); }
}
int main(int argc, const char **argv){ @autoreleasepool {
signal(SIGTERM,cuCancel); signal(SIGINT,cuCancel); signal(SIGPIPE,SIG_IGN);
@try { if(argc!=2) @throw [NSException exceptionWithName:@"args" reason:@"expected one JSON argument" userInfo:nil];
NSError *error=nil; id p=[NSJSONSerialization JSONObjectWithData:[[NSString stringWithUTF8String:argv[1]] dataUsingEncoding:NSUTF8StringEncoding] options:0 error:&error];
if(![p isKindOfClass:NSDictionary.class]) @throw [NSException exceptionWithName:@"json" reason:@"invalid request" userInfo:nil];
id result=execute(p); NSData *data=[NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingFragmentsAllowed error:&error];
id result=execute(p);
if([p[@"args"][@"input_lease"] boolValue]) { NSMutableDictionary *ack=[result mutableCopy]; ack[@"input_lease"]=@YES; result=ack; }
NSData *data=[NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingFragmentsAllowed error:&error];
if(!data) @throw [NSException exceptionWithName:@"json" reason:error.localizedDescription userInfo:nil];
puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); return 0;
} @catch(NSException *e){ fprintf(stderr,"%s\n",e.reason.UTF8String); return 1; }
puts([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding].UTF8String); fflush(stdout);
if([p[@"args"][@"input_lease"] boolValue]) cuWaitForLease();
return 0;
} @catch(NSException *e){ cuReleaseLease(); fprintf(stderr,"%s\n",e.reason.UTF8String); return 1; }
} }
@@ -0,0 +1,77 @@
#import <Cocoa/Cocoa.h>
#import <Vision/Vision.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
// Text recognition enriches an existing window observation. It neither reads
// the desktop nor creates accessibility roles or element identities.
static NSDictionary *cuOCRUnavailable(NSString *reason) {
return @{ @"status":@"unavailable", @"engine":@"apple_vision", @"reason":reason, @"blocks":@[] };
}
static NSDictionary *cuOCRPixelBounds(CGRect box, size_t width, size_t height) {
// Vision uses a normalized lower-left origin; raster targets use upper-left.
double x=MAX(0,MIN(width,floor(box.origin.x*width)));
double y=MAX(0,MIN(height,floor((1-CGRectGetMaxY(box))*height)));
double right=MAX(x,MIN(width,ceil(CGRectGetMaxX(box)*width)));
double bottom=MAX(y,MIN(height,ceil((1-box.origin.y)*height)));
return @{ @"x":@(x), @"y":@(y), @"w":@(right-x), @"h":@(bottom-y) };
}
static NSDictionary *cuRecognizeText(NSString *file) {
if(![file isKindOfClass:NSString.class] || !file.isAbsolutePath)
return cuOCRUnavailable(@"OCR needs the captured window image");
int fd=open(file.fileSystemRepresentation,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);
struct stat st;
if(fd<0) return cuOCRUnavailable(@"The captured window image could not be opened");
if(fstat(fd,&st)!=0 || !S_ISREG(st.st_mode) || st.st_size<=0 || st.st_size>64*1024*1024) {
close(fd); return cuOCRUnavailable(@"The captured window image is empty or exceeds the 64 MiB OCR limit");
}
// Read the same descriptor we checked; do not reopen a replaceable path.
NSMutableData *bytes=[NSMutableData dataWithLength:(NSUInteger)st.st_size];
size_t offset=0;
while(offset<bytes.length) {
ssize_t count=read(fd,(char *)bytes.mutableBytes+offset,bytes.length-offset);
if(count<0 && errno==EINTR) continue;
if(count<=0) { close(fd); return cuOCRUnavailable(@"The captured window image could not be read"); }
offset+=(size_t)count;
}
close(fd);
NSBitmapImageRep *bitmap=[[NSBitmapImageRep alloc] initWithData:bytes];
CGImageRef image=bitmap.CGImage;
if(!image) return cuOCRUnavailable(@"The captured window image could not be decoded");
size_t width=CGImageGetWidth(image),height=CGImageGetHeight(image);
if(!width || !height || width>50000000/height)
return cuOCRUnavailable(@"The captured window image exceeds the 50 megapixel OCR limit");
@try {
VNRecognizeTextRequest *request=[VNRecognizeTextRequest new];
request.recognitionLevel=VNRequestTextRecognitionLevelAccurate;
// Preserve literal UI strings, including code, instead of correcting words.
request.usesLanguageCorrection=NO;
if(@available(macOS 13.0,*)) request.automaticallyDetectsLanguage=YES;
VNImageRequestHandler *handler=[[VNImageRequestHandler alloc] initWithCGImage:image options:@{}];
NSError *error=nil;
if(![handler performRequests:@[request] error:&error])
return cuOCRUnavailable([NSString stringWithFormat:@"Apple Vision could not recognize this image: %@",error.localizedDescription?:@"unknown error"]);
NSMutableArray *blocks=[NSMutableArray array];
NSUInteger characters=0;
BOOL truncated=NO;
for(VNRecognizedTextObservation *observation in request.results) {
VNRecognizedText *candidate=[observation topCandidates:1].firstObject;
if(!candidate.string.length) continue;
if(blocks.count>=256 || characters+candidate.string.length>16000) { truncated=YES; break; }
CGRect box=observation.boundingBox;
if(!isfinite(box.origin.x) || !isfinite(box.origin.y) || !isfinite(box.size.width) || !isfinite(box.size.height)) continue;
NSDictionary *bounds=cuOCRPixelBounds(box,width,height);
if([bounds[@"w"] doubleValue]<=0 || [bounds[@"h"] doubleValue]<=0) continue;
[blocks addObject:@{ @"text":candidate.string, @"confidence":@(candidate.confidence), @"bounds":bounds }];
characters+=candidate.string.length;
}
return @{ @"status":@"ok", @"engine":@"apple_vision", @"coordinate_space":@"raster_pixels",
@"pixels":@{ @"w":@(width), @"h":@(height) }, @"blocks":blocks,
@"truncated":@(truncated), @"character_count":@(characters) };
} @catch(NSException *error) {
return cuOCRUnavailable([NSString stringWithFormat:@"Apple Vision is unavailable: %@",error.reason?:error.name]);
}
}
@@ -3,9 +3,17 @@
#import <ScreenCaptureKit/ScreenCaptureKit.h>
#import <AVFoundation/AVFoundation.h>
#include <signal.h>
#include <poll.h>
#include <unistd.h>
static volatile sig_atomic_t cuStopRecording = 0;
static BOOL cuRecordingOwnerPipe = NO;
static void cuRecordingSignal(int sig) { cuStopRecording = 1; }
static BOOL cuRecordingOwnerClosed(void) {
if(!cuRecordingOwnerPipe) return NO;
struct pollfd fd={STDIN_FILENO,POLLHUP,0};
return poll(&fd,1,0)>0 && (fd.revents&POLLHUP);
}
@interface CURecorder : NSObject <SCStreamOutput, SCStreamDelegate>
@property AVAssetWriter *writer;
@@ -32,14 +40,18 @@ static void cuRecordingSignal(int sig) { cuStopRecording = 1; }
}
@end
static void cuWait(dispatch_semaphore_t sem, NSTimeInterval seconds) {
static void cuWait(dispatch_semaphore_t sem, NSTimeInterval seconds, BOOL starting) {
NSDate *deadline=[NSDate dateWithTimeIntervalSinceNow:seconds];
while(dispatch_semaphore_wait(sem,DISPATCH_TIME_NOW)!=0) {
if(starting && (cuStopRecording || cuRecordingOwnerClosed()))
@throw [NSException exceptionWithName:@"cancelled" reason:@"recording owner closed during startup; partial file retained" userInfo:nil];
if(deadline.timeIntervalSinceNow<=0) @throw [NSException exceptionWithName:@"recording" reason:@"screen recorder timed out" userInfo:nil];
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.01]];
}
}
static id cuRecord(NSDictionary *args) {
cuRecordingOwnerPipe=[args[@"owner_pipe"] boolValue];
signal(SIGINT,cuRecordingSignal);signal(SIGTERM,cuRecordingSignal);
if(!CGPreflightScreenCaptureAccess()) @throw [NSException exceptionWithName:@"permission" reason:@"Screen Recording permission is missing" userInfo:nil];
NSString *file=args[@"file"];
if(![file isKindOfClass:NSString.class] || ![file isAbsolutePath] || [[NSFileManager defaultManager] fileExistsAtPath:file])
@@ -47,7 +59,7 @@ static id cuRecord(NSDictionary *args) {
__block SCShareableContent *content; __block NSError *error;
dispatch_semaphore_t sem=dispatch_semaphore_create(0);
[SCShareableContent getShareableContentExcludingDesktopWindows:NO onScreenWindowsOnly:YES completionHandler:^(SCShareableContent *c,NSError *e){ content=c;error=e;dispatch_semaphore_signal(sem); }];
cuWait(sem,15);
cuWait(sem,15,YES);
if(error) @throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
SCDisplay *display=nil;
for(SCDisplay *d in content.displays) if(d.displayID==[args[@"displayID"] unsignedIntValue]) display=d;
@@ -77,22 +89,23 @@ static id cuRecord(NSDictionary *args) {
SCStream *stream=[[SCStream alloc] initWithFilter:filter configuration:config delegate:rec];
if(![stream addStreamOutput:rec type:SCStreamOutputTypeScreen sampleHandlerQueue:queue error:&error])
@throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
signal(SIGINT,cuRecordingSignal);signal(SIGTERM,cuRecordingSignal);
[stream startCaptureWithCompletionHandler:^(NSError *e){error=e;dispatch_semaphore_signal(sem);}];cuWait(sem,15);
if(cuStopRecording || cuRecordingOwnerClosed())
@throw [NSException exceptionWithName:@"cancelled" reason:@"recording owner closed before capture started" userInfo:nil];
[stream startCaptureWithCompletionHandler:^(NSError *e){error=e;dispatch_semaphore_signal(sem);}];cuWait(sem,15,YES);
if(error) @throw [NSException exceptionWithName:@"recording" reason:error.localizedDescription userInfo:nil];
puts("{\"ready\":true}");fflush(stdout);
double duration=[args[@"durationSec"] doubleValue];
NSDate *deadline=duration>0?[NSDate dateWithTimeIntervalSinceNow:duration]:nil;
while(!cuStopRecording && (!deadline || deadline.timeIntervalSinceNow>0))
while(!cuStopRecording && !cuRecordingOwnerClosed() && (!deadline || deadline.timeIntervalSinceNow>0))
[[NSRunLoop currentRunLoop] runUntilDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
[stream stopCaptureWithCompletionHandler:^(NSError *e){error=e;dispatch_semaphore_signal(sem);}];cuWait(sem,15);
[stream stopCaptureWithCompletionHandler:^(NSError *e){error=e;dispatch_semaphore_signal(sem);}];cuWait(sem,15,NO);
dispatch_sync(queue,^{}); // Drain frames before finalizing the file.
if(error || rec.failure || !rec.started) {
[rec.writer cancelWriting];
@throw [NSException exceptionWithName:@"recording" reason:(error?:rec.failure).localizedDescription?:@"no complete video frames received" userInfo:nil];
}
[rec.input markAsFinished];
[rec.writer finishWritingWithCompletionHandler:^{dispatch_semaphore_signal(sem);}];cuWait(sem,15);
[rec.writer finishWritingWithCompletionHandler:^{dispatch_semaphore_signal(sem);}];cuWait(sem,15,NO);
if(rec.writer.status!=AVAssetWriterStatusCompleted) @throw [NSException exceptionWithName:@"recording" reason:rec.writer.error.localizedDescription?:@"video finalization failed" userInfo:nil];
return @{@"finished":@YES,@"width":@(config.width),@"height":@(config.height)};
}
@@ -10,7 +10,7 @@ import path from "node:path";
import crypto from "node:crypto";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { run, runOk, ExecError, tryJson, have } from "../exec.mjs";
import { run, runOk, ExecError, tryJson, have, withSignal, wait, throwIfAborted, currentSignal } from "../exec.mjs";
const KEY_CODES = {
return: 36, enter: 36, tab: 48, space: 49, escape: 53, esc: 53, delete: 51,
@@ -41,20 +41,24 @@ const MOUSE_MOVED = 5;
export function create({ exec }) {
const runL = (cmd, args, opts) => exec.run(cmd, args, opts);
const state = { activeDisplay: 1, lastRaster: null, inputApp: null, previewEnabled: false, pointer: null };
const state = { activeDisplay: 1, lastRaster: null, inputApp: null, foregroundInput: false, previewEnabled: false, pointer: null, pointerLease: null };
async function nativeHelper() {
let helper = process.env.CODEWHALE_CU_APP_BUNDLE
? path.join(process.env.CODEWHALE_CU_APP_BUNDLE, "Contents", "MacOS", "accessibility") : null;
if (!helper || !fs.existsSync(helper)) {
const packaged = fileURLToPath(new URL("../../bin/darwin/accessibility", import.meta.url));
if (fs.existsSync(packaged)) helper = packaged;
}
if (!helper || !fs.existsSync(helper)) {
const source = fileURLToPath(new URL("./darwin-accessibility.m", import.meta.url));
const hash = crypto.createHash("sha256").update(fs.readFileSync(source)).update(fs.readFileSync(new URL("./darwin-recording.h", import.meta.url))).digest("hex").slice(0, 16);
const hash = crypto.createHash("sha256").update(fs.readFileSync(source)).update(fs.readFileSync(new URL("./darwin-recording.h", import.meta.url))).update(fs.readFileSync(new URL("./darwin-ocr.h", import.meta.url))).digest("hex").slice(0, 16);
const dir = path.join(os.homedir(), ".codewhale-cu", "bin");
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
helper = path.join(dir, `accessibility-${hash}`);
if (!fs.existsSync(helper)) {
const tmp = `${helper}-${process.pid}`;
const r = await runL("clang", ["-fobjc-arc", "-Os", "-framework", "Cocoa", "-framework", "ApplicationServices", "-framework", "ScreenCaptureKit", "-framework", "AVFoundation", "-framework", "CoreMedia", source, "-o", tmp], { timeoutMs: 60_000 });
const r = await runL("clang", ["-fobjc-arc", "-Os", "-framework", "Cocoa", "-framework", "ApplicationServices", "-framework", "ScreenCaptureKit", "-framework", "AVFoundation", "-framework", "CoreMedia", "-framework", "Vision", source, "-o", tmp], { timeoutMs: 60_000 });
if (r.code !== 0) throw new ExecError(`native accessibility helper needs a built app or Xcode Command Line Tools: ${r.stderr}`, r);
fs.renameSync(tmp, helper);
}
@@ -64,8 +68,16 @@ export function create({ exec }) {
async function native(tool, args = {}) {
const helper = await nativeHelper();
const r = await runL(helper, [JSON.stringify({ tool, args: { ...args, input_app_ref: state.inputApp } })], { timeoutMs: 20_000 });
if (r.code !== 0) throw new ExecError(r.stderr.trim() || "native accessibility helper failed", r);
const r = await runL(helper, [JSON.stringify({ tool, args: { ...args, input_app_ref: state.inputApp, foreground_input: state.foregroundInput, owner_pipe: true } })], { timeoutMs: 20_000, ownerPipe: true });
if (r.aborted || r.timedOut || r.code !== 0) {
const error = new ExecError(r.aborted ? "computer request cancelled" : r.timedOut ? "native accessibility helper timed out" : r.stderr.trim() || "native accessibility helper failed", r);
if (r.aborted) error.code = "cancelled";
// A deterministic native refusal sent no input. A killed/timed-out
// helper may have posted the press before losing its response.
const postsPress = (tool === "key_event" && args.down) || (tool === "pointer_sequence" && args.steps?.some((step) => [1, 3, 25].includes(step.type)));
error.inputMayHaveBeenSent = postsPress && r.spawned === true && (r.aborted || r.timedOut);
throw error;
}
const result = tryJson(r.stdout, null);
if (state.previewEnabled && ["type", "key_event", "pointer_sequence", "set_value", "select_text", "perform_action", "hit_test"].includes(tool)) {
try { await updatePreview(); } catch (error) { result.preview_error = error.message; }
@@ -73,6 +85,13 @@ export function create({ exec }) {
return result;
}
async function nativeLease(tool, args) {
if (!exec.runInputLease) throw new ExecError("This executor cannot safely own held input; update Computer Use");
if ((await native("input_capabilities"))?.input_lease !== 1) throw new ExecError("The native helper needs an update for disconnect-safe held input");
const helper = await nativeHelper();
return exec.runInputLease(helper, [JSON.stringify({ tool, args: { ...args, input_app_ref: state.inputApp, foreground_input: state.foregroundInput, owner_pipe: true, input_lease: true } })]);
}
async function updatePreview(show = false) {
const win = await native("window_info", { app_ref: state.inputApp });
const dir = path.join(os.homedir(), ".codewhale-cu", "preview");
@@ -173,7 +192,14 @@ export function create({ exec }) {
...(a11yReason ? { a11y_reason: a11yReason } : {}) };
}
async function keyEvent(code, flags, down) { return native("key_event", { code, flags, down }); }
async function withPressedKey(code, flags, action) {
const lease = await nativeLease("key_event", { code, flags, down: true });
try {
return await action();
} finally {
await withSignal(null, () => lease.release());
}
}
function parseChord(text) {
const parts = String(text).split("+").map((s) => s.trim().toLowerCase()).filter(Boolean);
@@ -197,14 +223,14 @@ export function create({ exec }) {
return process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(os.homedir(), ".codewhale-cu", "recordings");
}
async function screenshot({ display, region, app_ref, path: outPath } = {}) {
async function screenshot({ display, region, app_ref, window_id, path: outPath } = {}) {
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const file = outPath || path.join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${crypto.randomBytes(3).toString("hex")}.png`);
if (!/\.png$/.test(file)) throw new ExecError("screenshot path must end in .png");
const args = ["-x", "-t", "png"];
const disp = display ?? state.activeDisplay;
const window = app_ref ? await native("window_info", { app_ref }) : null;
const window = app_ref ? await native("window_info", { app_ref, window_id }) : null;
if (window && region) throw new ExecError("choose app_ref or region, not both");
if (window) args.push("-o", "-l", String(window.window_id));
else if (disp && disp !== "all") args.push("-D", String(disp));
@@ -254,56 +280,108 @@ export function create({ exec }) {
}
// ---------- recording ----------
const rec = new Map(); // id -> {pid, file, startedAt, mode}
const rec = new Map(); // Includes starting children so session close owns them too.
function requestRecordingStop(r) {
if (r.child.exitCode == null && r.child.signalCode == null) {
r.child.stdin.end();
r.child.kill("SIGINT");
}
}
async function waitForRecordingStop(r, timeoutMs) {
let timer;
try {
return await Promise.race([r.completion, new Promise(resolve => {
timer = setTimeout(async () => {
r.child.kill("SIGKILL");
let reapTimer;
const terminated = await Promise.race([r.completion.then(() => true), new Promise(done => { reapTimer = setTimeout(() => done(false), 500); })]);
clearTimeout(reapTimer);
resolve({ code: -1, terminated, error: terminated ? "screen recorder finalization timed out; partial file retained" : "screen recorder could not be terminated; recording ownership retained for retry" });
}, timeoutMs);
})]);
} finally { clearTimeout(timer); }
}
async function recordingStart({ display, durationSec, region } = {}) {
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const id = crypto.randomBytes(4).toString("hex");
const file = path.join(dir, `rec-${id}.mov`);
const displays=await displayInfo();
const disp=display ?? state.activeDisplay;
const selected=displays.find(d=>d.index===disp);
if(!selected) throw new ExecError("choose one available display for recording");
if(durationSec!=null && (!Number.isFinite(durationSec) || durationSec<=0)) throw new ExecError("durationSec must be positive");
const helper=await nativeHelper();
const child=spawn(helper,[JSON.stringify({tool:"record",args:{file,displayID:selected.id,region,durationSec}})],{stdio:["ignore","pipe","pipe"]});
const startedAt=new Date().toISOString();
let stderr="", output="", ready=false;
const completion=new Promise(resolve=>{
child.once("error",error=>resolve({code:-1,error:error.message}));
child.once("close",code=>resolve({code,error:stderr.trim()}));
const displays = await displayInfo();
const disp = display ?? state.activeDisplay;
const selected = displays.find(d => d.index === disp);
if (!selected) throw new ExecError("choose one available display for recording");
if (durationSec != null && (!Number.isFinite(durationSec) || durationSec <= 0)) throw new ExecError("durationSec must be positive");
const capabilities = await native("input_capabilities");
if (capabilities?.record_owner_pipe !== 1) throw new ExecError("native screen recorder cannot own its client lifetime; update Computer Use before recording");
const helper = await nativeHelper();
throwIfAborted();
const child = spawn(helper, [JSON.stringify({ tool: "record", args: { file, displayID: selected.id, region, durationSec, owner_pipe: true } })], { stdio: ["pipe", "pipe", "pipe"] });
child.stdin.on("error", () => {});
const startedAt = new Date().toISOString();
let stderr = "", output = "", ready = false;
const completion = new Promise(resolve => {
child.once("error", error => resolve({ code: -1, error: error.message }));
child.once("close", code => resolve({ code, error: stderr.trim() }));
});
child.stderr.on("data",chunk=>{stderr=(stderr+chunk).slice(-4000);});
const readyPromise=new Promise((resolve,reject)=>{
const timer=setTimeout(()=>{child.kill("SIGTERM");reject(new ExecError("screen recorder startup timed out"));},20000);
child.stdout.on("data",chunk=>{
output+=chunk;
let i;
while((i=output.indexOf("\n"))>=0){
const line=output.slice(0,i);output=output.slice(i+1);
try { if(JSON.parse(line).ready){ready=true;clearTimeout(timer);resolve();} } catch {}
}
child.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-4000); });
const recording = { child, completion, pid: child.pid, file, startedAt, mode: "ScreenCaptureKit", display: disp };
rec.set(id, recording);
const signal = currentSignal();
let timer, abort;
try {
await new Promise((resolve, reject) => {
abort = () => { requestRecordingStop(recording); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" })); };
signal?.addEventListener("abort", abort, { once: true });
if (signal?.aborted) { abort(); return; }
timer = setTimeout(() => reject(new ExecError("screen recorder startup timed out")), 20_000);
child.stdout.on("data", chunk => {
output += chunk;
let i;
while ((i = output.indexOf("\n")) >= 0) {
const line = output.slice(0, i); output = output.slice(i + 1);
try { if (JSON.parse(line).ready) { ready = true; resolve(); } } catch {}
}
});
completion.then(result => { if (!ready) reject(new ExecError(result.error || "screen recorder exited before capture started")); });
});
completion.then(result=>{clearTimeout(timer);if(!ready)reject(new ExecError(result.error || "screen recorder exited before capture started"));});
});
await readyPromise;
rec.set(id,{child,completion,pid:child.pid,file,startedAt,mode:"ScreenCaptureKit",display:disp});
return {id,pid:child.pid,file,display:disp,durationSec:durationSec??null,region:region??null,fps:30,mode:"ScreenCaptureKit",startedAt};
throwIfAborted();
return { id, pid: child.pid, file, display: disp, durationSec: durationSec ?? null, region: region ?? null, fps: 30, mode: "ScreenCaptureKit", startedAt };
} catch (error) {
requestRecordingStop(recording);
const result = await waitForRecordingStop(recording, 2_000);
if (result.terminated !== false) rec.delete(id);
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
}
}
async function recordingStop({ id }) {
const r=rec.get(id);
if(!r) throw new ExecError(`unknown or already-finished recording "${id}"`);
if(r.child.exitCode==null && r.child.signalCode==null) r.child.kill("SIGINT");
let timer;
const result=await Promise.race([r.completion,new Promise(resolve=>{timer=setTimeout(()=>resolve({code:-1,error:"screen recorder finalization timed out; recording retained for retry"}),20000);})]);
clearTimeout(timer);
if(result.code!==0) throw new ExecError(result.error || "screen recorder failed; partial file retained");
const size=fs.existsSync(r.file)?fs.statSync(r.file).size:0;
if(!size) throw new ExecError("screen recorder produced no video");
const r = rec.get(id);
if (!r) throw new ExecError(`unknown or already-finished recording "${id}"`);
requestRecordingStop(r);
const result = await waitForRecordingStop(r, 20_000);
if (result.code !== 0) throw new ExecError(result.error || "screen recorder failed; partial file retained");
const size = fs.existsSync(r.file) ? fs.statSync(r.file).size : 0;
if (!size) throw new ExecError("screen recorder produced no video");
rec.delete(id);
return {id,file:r.file,mp4:null,bytes:size,mode:r.mode,startedAt:r.startedAt,stoppedAt:new Date().toISOString()};
return { id, file: r.file, mp4: null, bytes: size, mode: r.mode, startedAt: r.startedAt, stoppedAt: new Date().toISOString() };
}
async function closeSession() {
const owned = [...rec.entries()];
for (const [, recording] of owned) requestRecordingStop(recording);
const results = await Promise.all(owned.map(async ([id, recording]) => {
const result = await waitForRecordingStop(recording, 2_000);
if (result.terminated !== false) rec.delete(id);
return result;
}));
const failed = results.find(result => result.code !== 0);
if (failed) throw new ExecError(failed.error || "screen recorder failed; partial file retained");
}
async function recordingStatus({ id }) {
@@ -360,7 +438,8 @@ export function create({ exec }) {
// A bare executable has no bundle id; carrying an empty one would make the
// identity unmatchable.
state.inputApp = { pid: p.pid, ...(p.bundle_id ? { bundle_id: p.bundle_id } : {}) };
return { launched: true, activate, url: urlArg ?? null, resolved: p?.found ? { name: p.name, pid: p.pid, bundle_id: p.bundle_id, frontmost: p.frontmost } : null };
state.foregroundInput = !!activate;
return { launched: true, activate, keyboard_delivery: activate ? "foreground-guarded" : "process", url: urlArg ?? null, resolved: p?.found ? { name: p.name, pid: p.pid, bundle_id: p.bundle_id, frontmost: p.frontmost } : null };
}
// ---------- clipboard / cursor / waits ----------
@@ -410,9 +489,31 @@ export function create({ exec }) {
list_apps: listApps,
list_windows: listWindows,
open_application: openApplication,
get_app_state: async ({ app_ref, detail, depth, window_id }) => {
get_app_state: async ({ app_ref, detail, depth, window_id, include_ocr = false }) => {
const t = await native("get_app_state", { app_ref, detail, window_id });
if (!t.found) throw new ExecError("application not found — call list_apps for exact names/pids");
if (include_ocr) {
// Resolve once through AX, then capture only that exact application's
// selected window. A changing foreground cannot redirect this image.
let raster;
try {
if (!Number.isSafeInteger(t.pid) || t.pid <= 0) throw new ExecError("The observed application did not provide an exact process identity for OCR");
if ((await native("input_capabilities"))?.window_ocr !== 1) throw new ExecError("The native helper needs an update for selected-window text recognition");
raster = await screenshot({ app_ref: { pid: t.pid, ...(t.bundle_id ? { bundle_id: t.bundle_id } : {}) }, window_id });
const ocr = await native("recognize_text", { file: raster.file });
if (ocr?.status === "ok" && ocr.pixels?.w === raster.pixels.w && ocr.pixels?.h === raster.pixels.h && Array.isArray(ocr.blocks)) {
t.ocr = { ...ocr, raster, blocks: ocr.blocks.map(block => ({ ...block, target: {
type: "coordinate", x: Math.floor(block.bounds.x + block.bounds.w / 2), y: Math.floor(block.bounds.y + block.bounds.h / 2),
} })) };
} else {
t.ocr = { status: "unavailable", engine: "apple_vision", reason: ocr?.reason ?? "The native OCR helper needs an update or returned mismatched image dimensions", blocks: [], raster };
}
} catch (error) {
throwIfAborted();
if (error.code === "cancelled") throw error;
t.ocr = { status: "unavailable", engine: "apple_vision", reason: error.message, blocks: [], ...(raster ? { raster } : {}) };
}
}
return t;
},
resolve_element: async ({ app_ref, windowIndex, path: pathArr } = {}) => {
@@ -434,26 +535,39 @@ export function create({ exec }) {
middle_click: ({ target }) => pointerClick("middle", target.x, target.y, 1),
mouse_move: async ({ target }) => {
assertInScreen(target.x, target.y);
if (state.pointerLease) {
try {
const r = await state.pointerLease.send({ point: target });
state.pointer = { x: target.x, y: target.y };
return { action_sent: true, strategy: "event", at: state.pointer, ...pointerCost(r) };
} catch (error) { state.pointerLease = null; throw error; }
}
// A hover has to leave the pointer where it was asked to go.
const r = await gesture([{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 }], { restore: false, guard: target });
return { action_sent: true, strategy: "event", at: { x: target.x, y: target.y }, ...pointerCost(r) };
},
left_mouse_down: async ({ target }) => {
assertInScreen(target.x, target.y);
const r = await gesture([
{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 },
{ type: MOUSE.left.down, x: target.x, y: target.y, button: 0, clickState: 1 },
], { restore: false, guard: target });
return { action_sent: true, strategy: "event", at: { x: target.x, y: target.y }, ...pointerCost(r) };
if (state.pointerLease) throw new ExecError("this session already holds the left pointer button; release it first");
await assertOwnsPoint(target.x, target.y);
state.pointerLease = await nativeLease("pointer_sequence", { steps: [
{ type: MOUSE_MOVED, x: target.x, y: target.y, button: 0, clickState: 0 },
{ type: MOUSE.left.down, x: target.x, y: target.y, button: 0, clickState: 1 },
], restore: false });
state.pointer = { x: target.x, y: target.y };
return { action_sent: true, strategy: "event", at: state.pointer, ...pointerCost(state.pointerLease.receipt) };
},
left_mouse_up: async ({ target }) => {
if (!state.pointerLease) throw new ExecError("no agent pointer button is held by this session");
const loc = target ?? state.pointer;
if (!loc) throw new ExecError("no agent pointer position — mouse_move or left_mouse_down first");
assertInScreen(loc.x, loc.y);
// No ownership guard: the button is already held, and the drag may have
// legitimately left the originating window.
const r = await gesture([{ type: MOUSE.left.up, x: loc.x, y: loc.y, button: 0, clickState: 1 }], { restore: false });
return { action_sent: true, strategy: "event", at: { x: loc.x, y: loc.y }, ...pointerCost(r) };
try { await withSignal(null, () => state.pointerLease.release({ point: loc })); }
finally { state.pointerLease = null; }
state.pointer = { x: loc.x, y: loc.y };
return { action_sent: true, strategy: "event", at: state.pointer, pointer_moved: true, pointer_restored: false };
},
left_click_drag: async ({ from_target: from, to }) => {
assertInScreen(from.x, from.y); assertInScreen(to.x, to.y);
@@ -488,19 +602,16 @@ export function create({ exec }) {
key: async ({ text, repeat = 1 }) => {
const { flags, code, key } = parseChord(text);
for (let i = 0; i < Math.max(1, Math.min(100, repeat)); i++) {
await keyEvent(code, flags, true);
await keyEvent(code, flags, false);
if (i < repeat - 1) await new Promise((r) => setTimeout(r, 30));
await withPressedKey(code, flags, () => {});
if (i < repeat - 1) await wait(30);
}
return { action_sent: true, key, code, repeat: Math.max(1, Math.min(100, repeat)) };
return { action_sent: true, key, code, keyboard_delivery: state.foregroundInput ? "foreground-guarded" : "process", repeat: Math.max(1, Math.min(100, repeat)) };
},
hold_key: async ({ text, duration }) => {
const { flags, code, key } = parseChord(text);
const d = Math.max(0.05, Math.min(30, Number(duration) || 1));
await keyEvent(code, flags, true);
await new Promise((r) => setTimeout(r, d * 1000));
await keyEvent(code, flags, false);
return { action_sent: true, key, heldSec: d };
await withPressedKey(code, flags, () => wait(d * 1000));
return { action_sent: true, key, keyboard_delivery: state.foregroundInput ? "foreground-guarded" : "process", heldSec: d };
},
set_value: (args) => native("set_value", args),
select_text: (args) => native("select_text", args),
@@ -512,6 +623,12 @@ export function create({ exec }) {
recordingStop,
recordingStatus,
recordingList,
closeSession,
releaseInput: async () => {
if (!state.pointerLease) return;
try { await withSignal(null, () => state.pointerLease.release({ point: state.pointer })); }
finally { state.pointerLease = null; }
},
};
}
@@ -7,7 +7,7 @@ import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { run, runOk, ExecError, tryJson, have } from "../exec.mjs";
import { run as nativeRun, runOk, ExecError, tryJson, have as nativeHave, withSignal, throwIfAborted, wait } from "../exec.mjs";
import { pngSize } from "../png-size.mjs";
const XKEYS = {
@@ -34,16 +34,42 @@ function assertEventStrategy(strategy) {
}
}
export function create({ exec }) {
export function create({ exec } = {}) {
const run = exec?.run ?? nativeRun;
const have = exec?.have ?? nativeHave;
function requireInputOwner() {
if (exec?.persistentInputOwner !== true) throw Object.assign(new ExecError(
"This held-input gesture requires a connected Codewhale Computer Use desktop helper so a disconnected client cannot leave keys or buttons pressed. Start the helper and reconnect before retrying."
), { code: "input_owner_required" });
}
const tools = {};
let session = null; // "x11" | "wayland"
let probed = false;
let lastRaster = null;
let recording = null; // {id, pid, file, startedAt, mode}
let mouseHeld = false;
const heldKeys = new Set();
async function releaseMouse() {
if (!mouseHeld) return;
await withSignal(null, () => session === "x11" ? xdotool(["mouseup", "1"], { timeoutMs: 2_000 }) : ydotool(["click", "0x80"], { timeoutMs: 2_000 }));
mouseHeld = false;
}
async function releaseKey(key) {
if (!heldKeys.has(key)) return;
await withSignal(null, () => xdotool(["keyup", key], { timeoutMs: 2_000 }));
heldKeys.delete(key);
}
async function releaseInput() {
await releaseMouse();
for (const key of heldKeys) await releaseKey(key);
}
async function probeSession() {
if (probed) return session;
probed = true;
throwIfAborted();
const wayland = !!(process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === "wayland");
const x11 = !!(process.env.DISPLAY || process.env.XDG_SESSION_TYPE === "x11");
session = wayland && !x11 ? "wayland" : x11 ? "x11" : null;
@@ -56,6 +82,8 @@ export function create({ exec }) {
tools[t] = await have(t);
}
tools.pyatspi = tools.python3 && (await run("python3", ["-c", "import pyatspi"], { timeoutMs: 10_000 })).code === 0;
throwIfAborted();
probed = true;
return session;
}
@@ -97,14 +125,18 @@ export function create({ exec }) {
async function xdotool(args, opts = {}) {
need("xdotool", "input on X11");
throwIfAborted();
const r = await run("xdotool", args, opts);
throwIfAborted();
if (r.code !== 0) throw new ExecError(`xdotool ${args[0]} exited ${r.code}: ${r.stderr.trim().slice(0, 200)}`, r);
return r.stdout.trim();
}
async function ydotool(args, opts = {}) {
need("ydotool", "input on Wayland (ydotool needs its daemon running: sudo ydotoold)");
throwIfAborted();
const r = await run("ydotool", args, opts);
throwIfAborted();
if (r.code !== 0) throw new ExecError(`ydotool exited ${r.code}: ${r.stderr.trim().slice(0, 200)}`, r);
return r.stdout.trim();
}
@@ -118,6 +150,33 @@ export function create({ exec }) {
}).join("+");
}
async function waylandKey(text, { repeat = 1, holdMs = 0 } = {}) {
need("wtype", "key presses on Wayland");
const parts = String(text).split("+").map((part) => part.trim().toLowerCase());
const aliases = { control: "ctrl", meta: "logo", cmd: "logo", super: "logo" };
const modifiers = new Set(["ctrl", "alt", "shift", "logo", "win", "altgr", "capslock"]);
const rawKey = parts.pop();
const key = xdotoolKey(rawKey);
const mods = parts.map((part) => aliases[part] ?? part);
if (!key || mods.some((mod) => !modifiers.has(mod))) throw new ExecError(`unknown key combination "${text}"`);
const modKey = aliases[rawKey] ?? rawKey;
const onlyModifier = modifiers.has(modKey);
const args = mods.flatMap((mod) => ["-M", mod]);
for (let i = 0; i < repeat; i++) {
args.push(onlyModifier ? "-M" : "-P", onlyModifier ? modKey : key);
if (holdMs) args.push("-s", String(holdMs));
args.push(onlyModifier ? "-m" : "-p", onlyModifier ? modKey : key);
}
args.push(...mods.reverse().flatMap((mod) => ["-m", mod]));
// wtype owns a temporary Wayland keyboard; the compositor releases its
// keys on process exit, including cancellation. Keep the complete gesture
// in one process (https://github.com/atx/wtype#usage).
throwIfAborted();
const result = await run("wtype", args, { timeoutMs: Math.max(10_000, holdMs + 8_000) });
throwIfAborted();
if (result.code !== 0) throw new ExecError(`wtype exited ${result.code}: ${result.stderr.trim().slice(0, 200)}`, result);
}
function assertNum(v, name) {
const n = Number(v);
if (!Number.isFinite(n)) throw new ExecError(`${name} must be a finite number`);
@@ -251,20 +310,23 @@ except Exception as e:
return {
platform: "linux",
releaseInput,
probe: async () => {
const s = await probeSession();
const caps = {
screenshot: !!((session === "wayland" && tools.grim) || (session === "x11" && (tools.scrot || tools.import))),
clipboard: !!(tools.xclip || tools.xsel || (tools["wl-copy"] && tools["wl-paste"])),
recording: !!(tools.ffmpeg || tools["wf-recorder"]),
recording: false,
accessibility_tree: tools.pyatspi,
held_input: exec?.persistentInputOwner === true && !!(session === "x11" ? tools.xdotool : tools.wtype && tools.ydotool),
};
const missing = [];
if (exec?.persistentInputOwner !== true) missing.push("connected Computer Use desktop helper (held keys, held buttons and drag)");
if (session === "x11" && !tools.xdotool) missing.push("xdotool (input)");
if (session === "wayland" && !tools.ydotool) missing.push("ydotool+ydotoold (mouse input)");
if (session === "wayland" && !tools.grim) missing.push("grim (screenshots)");
if (session === "x11" && !tools.scrot && !tools.import) missing.push("scrot or imagemagick (screenshots)");
if (!caps.recording) missing.push("ffmpeg (X11) or wf-recorder (Wayland)");
missing.push("session-owned recording (unavailable in this version; use screenshots)");
if (!tools.pyatspi) missing.push("python3-pyatspi (accessibility tree)");
// Real permission probes, not just `have()`: each check is bounded to 10s.
const permissions = { input: "failed", screen_capture: "failed", accessibility: "unavailable" };
@@ -476,19 +538,37 @@ print(json.dumps({"found": True, "reason": None, "element": {
middle_click: ({ target }) => inputChain(target.x, target.y, () => clickButton(2, 1)),
mouse_move: ({ target }) => inputMove(target.x, target.y),
left_click_drag: async ({ from_target: from, to }) => {
requireInputOwner();
await inputMove(from.x, from.y);
if (session === "x11") await xdotool(["mousedown", "1"]);
else await ydotool(["click", "0x40"]);
for (let i = 1; i <= 10; i++) {
await new Promise((r) => setTimeout(r, 20));
await inputMove(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10);
}
if (session === "x11") await xdotool(["mouseup", "1"]);
else await ydotool(["click", "0x80"]);
throwIfAborted();
mouseHeld = true;
try {
if (session === "x11") await xdotool(["mousedown", "1"]);
else await ydotool(["click", "0x40"]);
for (let i = 1; i <= 10; i++) {
await wait(20);
await inputMove(from.x + ((to.x - from.x) * i) / 10, from.y + ((to.y - from.y) * i) / 10);
}
} finally { await releaseMouse(); }
return { action_sent: true, from, to };
},
left_mouse_down: ({ target }) => session === "x11" ? xdotool(["mousedown", "1"]).then(() => ({ action_sent: true })) : ydotool(["click", "0x40"]).then(() => ({ action_sent: true })),
left_mouse_up: () => session === "x11" ? xdotool(["mouseup", "1"]).then(() => ({ action_sent: true })) : ydotool(["click", "0x80"]).then(() => ({ action_sent: true })),
left_mouse_down: async ({ target } = {}) => {
requireInputOwner();
await probeSession();
if (target) await inputMove(target.x, target.y);
throwIfAborted();
mouseHeld = true;
try {
if (session === "x11") await xdotool(["mousedown", "1"]);
else await ydotool(["click", "0x40"]);
} catch (err) { await releaseMouse(); throw err; }
return { action_sent: true };
},
left_mouse_up: async () => {
if (!mouseHeld) throw Object.assign(new ExecError("no agent pointer press to release"), { code: "input_not_held" });
await releaseMouse();
return { action_sent: true };
},
scroll: async ({ target, direction = "down", amount = 3 }) => {
await inputMove(target.x, target.y);
if (session === "x11") {
@@ -514,29 +594,30 @@ print(json.dumps({"found": True, "reason": None, "element": {
key: async ({ text, repeat = 1 }) => {
await probeSession();
const k = xdotoolKey(text);
const n = Math.max(1, Math.min(100, Number(repeat) || 1));
if (session === "x11") {
await xdotool(["key", "--repeat", String(Math.max(1, Math.min(100, repeat))), "--delay", "60", k]);
return { action_sent: true, key: k };
}
need("wtype", "key presses on Wayland");
await run("wtype", ["-P", k]);
await run("wtype", ["-R", k]);
throwIfAborted();
heldKeys.add(k);
try {
await xdotool(["key", "--repeat", String(n), "--delay", "60", k]);
heldKeys.delete(k);
} finally { await releaseKey(k); }
} else await waylandKey(text, { repeat: n });
return { action_sent: true, key: k };
},
hold_key: async ({ text, duration }) => {
requireInputOwner();
await probeSession();
const k = xdotoolKey(text);
const d = Math.max(0.05, Math.min(30, Number(duration) || 1));
if (session === "x11") {
await xdotool(["keydown", k]);
await new Promise((r) => setTimeout(r, d * 1000));
await xdotool(["keyup", k]);
return { action_sent: true, key: k, heldSec: d };
}
need("ydotool", "key hold on Wayland");
await ydotool(["key", `${k}:1`]);
await new Promise((r) => setTimeout(r, d * 1000));
await ydotool(["key", `${k}:0`]);
throwIfAborted();
heldKeys.add(k);
try {
await xdotool(["keydown", k]);
await wait(d * 1000);
} finally { await releaseKey(k); }
} else await waylandKey(text, { holdMs: Math.round(d * 1000) });
return { action_sent: true, key: k, heldSec: d };
},
set_value: async ({ target, value }) => {
@@ -607,48 +688,11 @@ print(json.dumps({"found": True, "reason": None, "element": {
}
throw new ExecError("cursor position needs an X11 session in this build");
},
recordingStart: async ({ fps = 15, region } = {}) => {
await probeSession();
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const id = crypto.randomBytes(4).toString("hex");
const file = path.join(dir, `rec-${id}.${session === "wayland" ? "mkv" : "mp4"}`);
if (session === "x11") {
need("ffmpeg", "recording on X11");
const dpy = process.env.DISPLAY || ":0";
const args = ["-y", "-loglevel", "error", "-f", "x11grab", "-framerate", String(fps)];
if (region) args.push("-video_size", `${Math.round(region[2])}x${Math.round(region[3])}`);
args.push("-i", `${dpy}${region ? `+${Math.round(region[0])},${Math.round(region[1])}` : ""}`, "-c:v", "libx264", "-pix_fmt", "yuv420p", file);
const child = spawnDetached("ffmpeg", args);
await new Promise((r) => setTimeout(r, 700));
try { process.kill(child.pid, 0); } catch { throw new ExecError("ffmpeg x11grab exited immediately — check DISPLAY, XAUTHORITY and screen permissions"); }
recording = { id, pid: child.pid, file, startedAt: new Date().toISOString(), mode: "x11grab" };
return { id, pid: child.pid, file, mode: "x11grab", fps };
}
need("wf-recorder", "recording on Wayland");
const args = ["-r", String(fps), "-f", file];
if (process.env.CU_WAYLAND_OUTPUT) args.unshift("-o", process.env.CU_WAYLAND_OUTPUT);
const child = spawnDetached("wf-recorder", args);
await new Promise((r) => setTimeout(r, 700));
try { process.kill(child.pid, 0); } catch { throw new ExecError("wf-recorder exited immediately — check compositor support (wlroots)"); }
recording = { id, pid: child.pid, file, startedAt: new Date().toISOString(), mode: "wf-recorder" };
return { id, pid: child.pid, file, mode: "wf-recorder", fps };
},
recordingStop: async ({ id }) => {
if (!recording || recording.id !== id) throw new ExecError(`unknown recording "${id}"`);
process.kill(recording.pid, "SIGINT");
await new Promise((r) => setTimeout(r, 1500));
const bytes = fs.existsSync(recording.file) ? fs.statSync(recording.file).size : 0;
const out = { id, file: recording.file, bytes, mode: recording.mode, startedAt: recording.startedAt, stoppedAt: new Date().toISOString() };
recording = null;
return out;
},
recordingStatus: ({ id }) => {
if (!recording || recording.id !== id) return { id, running: false };
let alive = true;
try { process.kill(recording.pid, 0); } catch { alive = false; }
return { id, running: alive, file: recording.file, bytes: fs.existsSync(recording.file) ? fs.statSync(recording.file).size : 0, mode: recording.mode };
recordingStart: async () => {
throw Object.assign(new ExecError("Recording is unavailable on this platform until the recorder has session-owned cleanup. Use screenshots instead."), { code: "owned_recording_unavailable" });
},
recordingStop: async ({ id }) => { throw new ExecError(`unknown recording "${id}"`); },
recordingStatus: ({ id }) => ({ id, running: false }),
recordingList: async () => {
const dir = recordingsDir();
const out = fs.existsSync(dir)
@@ -657,7 +701,7 @@ print(json.dumps({"found": True, "reason": None, "element": {
return { file: path.join(dir, f), bytes: st.size, modifiedAt: st.mtime.toISOString() };
}).sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)).slice(0, 50)
: [];
return { dir, recordings: out, running: recording ? [recording.id] : [] };
return { dir, recordings: out, running: [] };
},
};
@@ -668,6 +712,7 @@ print(json.dumps({"found": True, "reason": None, "element": {
}
async function inputMove(x, y) {
await probeSession();
const nx = Math.round(assertNum(x, "x"));
const ny = Math.round(assertNum(y, "y"));
if (session === "x11") await xdotool(["mousemove", "--sync", String(nx), String(ny)]);
@@ -2,13 +2,12 @@
// travels as a base64 -EncodedCommand, so tool arguments never become shell
// syntax. Screenshots + UIA accessibility come from .NET; raw pointer and
// keyboard events come from user32 P/Invoke (SendInput/mouse_event).
// Recording uses ffmpeg gdigrab when ffmpeg is on PATH.
// Recording stays unavailable until its native process has session-owned cleanup.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { run, runOk, ExecError, tryJson } from "../exec.mjs";
import { run, ExecError, tryJson, withSignal, throwIfAborted } from "../exec.mjs";
const USER32 = `
using System;
@@ -68,7 +67,14 @@ export function create(opts = {}) {
const injectedRun = opts.exec && typeof opts.exec.run === "function" ? opts.exec.run : null;
const runner = injectedRun ?? run;
function requireInputOwner() {
if (opts.exec?.persistentInputOwner !== true) throw Object.assign(new ExecError(
"This held-input gesture requires a connected Codewhale Computer Use desktop helper so a disconnected client cannot leave keys or buttons pressed. Start the helper and reconnect before retrying."
), { code: "input_owner_required" });
}
async function ps(script, o = {}) {
throwIfAborted();
const encoded = Buffer.from(script, "utf16le").toString("base64");
return runner("powershell.exe", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded], {
timeoutMs: o.timeoutMs ?? 25_000,
@@ -79,6 +85,7 @@ export function create(opts = {}) {
/** ps() but truthful: timeout, nonzero exit, and spawn failure all throw. */
async function psOk(script, o = {}) {
const r = await ps(script, o);
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
if (r.timedOut) throw new ExecError(`powershell timed out after ${o.timeoutMs ?? 25_000}ms`, r);
if (r.code !== 0) throw new ExecError(`powershell.exe exited ${r.code}: ${(r.stderr || r.stdout).trim().slice(0, 300)}`, r);
return r;
@@ -93,7 +100,30 @@ export function create(opts = {}) {
}
let lastRaster = null;
let recording = null; // {id, pid, file, startedAt, mode}
const heldButtons = new Set();
const heldKeys = new Set();
async function releaseInput({ buttons = [...heldButtons], keys = [...heldKeys] } = {}) {
buttons = buttons.filter((button) => heldButtons.has(button));
keys = keys.filter((key) => heldKeys.has(key));
if (!buttons.length && !keys.length) return;
const releases = [
...buttons.map((button) => `[User32]::mouse_event([User32]::${button}UP, 0, 0, 0, [UIntPtr]::Zero);`),
...keys.reverse().map((vk) => `[void][User32]::SendInput(1, @([User32]::KeyInput(${vk}, 0, 2)), [System.Runtime.InteropServices.Marshal]::SizeOf([type][User32+INPUT]));`),
];
await withSignal(null, () => withUser32(releases.join("\n"), { timeoutMs: 2_000 }));
for (const button of buttons) heldButtons.delete(button);
for (const key of keys) heldKeys.delete(key);
}
function keyChord(text) {
const parts = String(text).split("+").map((part) => part.trim().toLowerCase());
const key = parts.pop();
const mods = parts.map((part) => MODVK[part]);
const vk = VK[key] ?? MODVK[key] ?? (key.length === 1 ? key.toUpperCase().charCodeAt(0) : null);
if (vk == null || mods.some((mod) => mod == null)) throw new ExecError(`unknown key combination "${text}"`);
return { key, vk, mods: [...new Set(mods)] };
}
/** Self-contained User32 invocation: prelude + script, fails truthfully. */
async function withUser32(script, opts) {
@@ -102,15 +132,14 @@ export function create(opts = {}) {
return {
platform: "win32",
releaseInput,
probe: async () => {
let ffmpeg = true;
try { await runOk("ffmpeg", ["-version"], { timeoutMs: 10_000 }); } catch { ffmpeg = false; }
const psOk = await ps("Write-Output 'ok'").then((r) => r.code === 0).catch(() => false);
return {
platform: "win32",
powershell: psOk,
capabilities: { screenshot: psOk, accessibility_tree: psOk, clipboard: psOk, recording: ffmpeg, raw_input: psOk },
note: "Recording needs ffmpeg (gdigrab) on PATH. UIA accessibility works without extra installs.",
capabilities: { screenshot: psOk, accessibility_tree: psOk, clipboard: psOk, recording: false, raw_input: psOk, held_input: psOk && opts.exec?.persistentInputOwner === true },
note: "Recording is unavailable until session-owned cleanup is implemented; use screenshots. UIA accessibility works without extra installs. Held keys, held buttons and drag require a connected Computer Use desktop helper.",
};
},
list_displays: async () => {
@@ -264,6 +293,10 @@ Write-Output '{"ok": true}';`;
return { action_sent: true, at: { x: target.x, y: target.y } };
},
left_click_drag: async ({ from_target: from, to }) => {
requireInputOwner();
throwIfAborted();
heldButtons.add("LEFT");
try {
await withUser32(`[User32]::SetCursorPos(${Math.round(from.x)}, ${Math.round(from.y)}) | Out-Null;
Start-Sleep -Milliseconds 80;
[User32]::mouse_event([User32]::LEFTDOWN, 0, 0, 0, [UIntPtr]::Zero);
@@ -272,17 +305,24 @@ Start-Sleep -Milliseconds 80;
Start-Sleep -Milliseconds 80;
[User32]::mouse_event([User32]::LEFTUP, 0, 0, 0, [UIntPtr]::Zero);
Write-Output '{"ok": true}';`, { timeoutMs: 20_000 });
heldButtons.delete("LEFT");
return { action_sent: true, from, to };
} finally { await releaseInput({ buttons: ["LEFT"], keys: [] }); }
},
left_mouse_down: async ({ target }) => {
requireInputOwner();
// Ternary must select ONLY the optional move prefix; the LEFTDOWN press
// always runs, so a targeted press both moves and presses.
const move = target ? `[User32]::SetCursorPos(${Math.round(target.x)}, ${Math.round(target.y)}) | Out-Null;\n` : "";
await withUser32(`${move}[User32]::mouse_event([User32]::LEFTDOWN, 0, 0, 0, [UIntPtr]::Zero); Write-Output '{"ok": true}'`);
throwIfAborted();
heldButtons.add("LEFT");
try { await withUser32(`${move}[User32]::mouse_event([User32]::LEFTDOWN, 0, 0, 0, [UIntPtr]::Zero); Write-Output '{"ok": true}'`); }
catch (err) { await releaseInput({ buttons: ["LEFT"], keys: [] }); throw err; }
return { action_sent: true };
},
left_mouse_up: async () => {
await withUser32(`[User32]::mouse_event([User32]::LEFTUP, 0, 0, 0, [UIntPtr]::Zero); Write-Output '{"ok": true}'`);
if (!heldButtons.has("LEFT")) throw Object.assign(new ExecError("no agent pointer press to release"), { code: "input_not_held" });
await releaseInput({ buttons: ["LEFT"], keys: [] });
return { action_sent: true };
},
scroll: async ({ target, direction = "down", amount = 3 }) => {
@@ -325,13 +365,10 @@ Write-Output ('{"ok": true, "chars": ' + $text.Length + '}');`;
return { action_sent: true, chars: text.length, strategy: "unicode-sendinput" };
},
key: async ({ text, repeat = 1 }) => {
const parts = String(text).split("+").map((s) => s.trim().toLowerCase());
const mods = parts.filter((p) => MODVK[p] != null).map((p) => MODVK[p]);
const key = parts.find((p) => MODVK[p] == null);
if (!key) throw new ExecError(`no key in "${text}"`);
let vk = VK[key];
if (vk == null && key.length === 1) vk = key.toUpperCase().charCodeAt(0);
if (vk == null) throw new ExecError(`unknown key "${key}"`);
const { key, vk, mods } = keyChord(text);
throwIfAborted();
for (const code of [...mods, vk]) heldKeys.add(code);
try {
const n = Math.max(1, Math.min(100, repeat));
await withUser32(`$ins = New-Object 'User32+INPUT[]' 0;
$add = { param($i) };
@@ -342,19 +379,26 @@ ${[...mods].reverse().map((m) => `$seq += [User32]::KeyInput(${m}, 0, 2);`).join
$arr = $seq.ToArray();
[void][User32]::SendInput($arr.Length, $arr, [System.Runtime.InteropServices.Marshal]::SizeOf([type][User32+INPUT]));
Write-Output '{"ok": true}';`);
for (const code of [...mods, vk]) heldKeys.delete(code);
return { action_sent: true, key, repeat: n };
} finally { await releaseInput({ buttons: [], keys: [...mods, vk] }); }
},
hold_key: async ({ text, duration }) => {
const parts = String(text).split("+").map((s) => s.trim().toLowerCase());
const key = parts.find((p) => MODVK[p] == null && VK[p] == null ? p.length === 1 : (VK[p] != null || p.length === 1));
const vk = VK[key] ?? (key.length === 1 ? key.toUpperCase().charCodeAt(0) : null);
if (vk == null) throw new ExecError(`unknown key "${text}"`);
requireInputOwner();
const { key, vk, mods } = keyChord(text);
const d = Math.max(0.05, Math.min(30, Number(duration) || 1));
await withUser32(`[void][User32]::SendInput(1, @([User32]::KeyInput(${vk}, 0, 0)), [System.Runtime.InteropServices.Marshal]::SizeOf([type][User32+INPUT]));
const keys = [...mods, vk];
throwIfAborted();
for (const code of keys) heldKeys.add(code);
const event = (code, flags) => `[void][User32]::SendInput(1, @([User32]::KeyInput(${code}, 0, ${flags})), [System.Runtime.InteropServices.Marshal]::SizeOf([type][User32+INPUT]));`;
try {
await withUser32(`${keys.map((code) => event(code, 0)).join("\n")}
Start-Sleep -Milliseconds ${Math.round(d * 1000)};
[void][User32]::SendInput(1, @([User32]::KeyInput(${vk}, 0, 2)), [System.Runtime.InteropServices.Marshal]::SizeOf([type][User32+INPUT]));
${[...keys].reverse().map((code) => event(code, 2)).join("\n")}
Write-Output '{"ok": true}';`, { timeoutMs: Math.max(10_000, d * 1000 + 8000) });
return { action_sent: true, key, heldSec: d };
for (const code of keys) heldKeys.delete(code);
return { action_sent: true, key, heldSec: d };
} finally { await releaseInput({ buttons: [], keys }); }
},
set_value: async ({ target, value }) => {
// UIA ValuePattern via a re-walk to target.path from the desktop root.
@@ -432,39 +476,11 @@ $p = New-Object User32+POINT;
Write-Output ('{"x": ' + $p.X + ', "y": ' + $p.Y + '}');`);
return { x: j.x, y: j.y };
},
recordingStart: async ({ fps = 15, region } = {}) => {
let ffmpegOk = true;
try { await runOk("ffmpeg", ["-version"], { timeoutMs: 10_000 }); } catch { ffmpegOk = false; }
if (!ffmpegOk) throw new ExecError("recording on Windows needs ffmpeg (gdigrab) on PATH — install ffmpeg and retry");
const dir = recordingsDir();
fs.mkdirSync(dir, { recursive: true });
const id = crypto.randomBytes(4).toString("hex");
const file = path.join(dir, `rec-${id}.mp4`);
const args = ["-y", "-loglevel", "error", "-f", "gdigrab", "-framerate", String(fps)];
if (region) args.push("-offset_x", String(Math.round(region[0])), "-offset_y", String(Math.round(region[1])), "-video_size", `${Math.round(region[2])}x${Math.round(region[3])}`);
args.push("-i", "desktop", "-c:v", "libx264", "-pix_fmt", "yuv420p", file);
const child = spawn("ffmpeg", args, { stdio: "ignore", detached: true });
child.unref();
await new Promise((r) => setTimeout(r, 800));
try { process.kill(child.pid, 0); } catch { throw new ExecError("ffmpeg gdigrab exited immediately"); }
recording = { id, pid: child.pid, file, startedAt: new Date().toISOString(), mode: "gdigrab" };
return { id, pid: child.pid, file, mode: "gdigrab", fps };
},
recordingStop: async ({ id }) => {
if (!recording || recording.id !== id) throw new ExecError(`unknown recording "${id}"`);
try { process.kill(recording.pid, "SIGINT"); } catch {}
await new Promise((r) => setTimeout(r, 1500));
const bytes = fs.existsSync(recording.file) ? fs.statSync(recording.file).size : 0;
const out = { id, file: recording.file, bytes, mode: recording.mode, startedAt: recording.startedAt, stoppedAt: new Date().toISOString() };
recording = null;
return out;
},
recordingStatus: ({ id }) => {
if (!recording || recording.id !== id) return { id, running: false };
let alive = true;
try { process.kill(recording.pid, 0); } catch { alive = false; }
return { id, running: alive, file: recording.file, bytes: fs.existsSync(recording.file) ? fs.statSync(recording.file).size : 0, mode: recording.mode };
recordingStart: async () => {
throw Object.assign(new ExecError("Recording is unavailable on this platform until the recorder has session-owned cleanup. Use screenshots instead."), { code: "owned_recording_unavailable" });
},
recordingStop: async ({ id }) => { throw new ExecError(`unknown recording "${id}"`); },
recordingStatus: ({ id }) => ({ id, running: false }),
recordingList: async () => {
const dir = recordingsDir();
const out = fs.existsSync(dir)
@@ -473,7 +489,7 @@ Write-Output ('{"x": ' + $p.X + ', "y": ' + $p.Y + '}');`);
return { file: path.join(dir, f), bytes: st.size, modifiedAt: st.mtime.toISOString() };
}).sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt)).slice(0, 50)
: [];
return { dir, recordings: out, running: recording ? [recording.id] : [] };
return { dir, recordings: out, running: [] };
},
};
@@ -482,11 +498,17 @@ Write-Output ('{"x": ' + $p.X + ', "y": ' + $p.Y + '}');`);
const flags = button === 1 ? "RIGHTDOWN, RIGHTUP" : button === 2 ? "MIDDLEDOWN, MIDDLEUP" : "LEFTDOWN, LEFTUP";
const seq = [];
for (let i = 0; i < clicks; i++) seq.push(`[User32]::mouse_event([User32]::${flags.split(",")[0].trim()}, 0, 0, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 40; [User32]::mouse_event([User32]::${flags.split(",")[1].trim()}, 0, 0, 0, [UIntPtr]::Zero); Start-Sleep -Milliseconds 60;`);
const held = button === 1 ? "RIGHT" : button === 2 ? "MIDDLE" : "LEFT";
throwIfAborted();
heldButtons.add(held);
try {
await withUser32(`[User32]::SetCursorPos(${Math.round(x)}, ${Math.round(y)}) | Out-Null;
Start-Sleep -Milliseconds 60;
${seq.join("\n")}
Write-Output '{"ok": true}';`, { timeoutMs: 20_000 });
heldButtons.delete(held);
return { action_sent: true, at: { x: Number(x), y: Number(y) }, button, clicks };
} finally { await releaseInput({ buttons: [held], keys: [] }); }
}
}
+105 -8
View File
@@ -1,5 +1,19 @@
// Process execution helper: spawn, timeout, text capture. Zero dependencies.
import { spawn } from "node:child_process";
import { AsyncLocalStorage } from "node:async_hooks";
import { setTimeout as delay } from "node:timers/promises";
const requests = new AsyncLocalStorage();
export const currentSignal = () => requests.getStore()?.signal;
// A null signal is reserved for bounded cleanup, such as releasing a held key.
export const withSignal = (signal, fn) => requests.run({ signal }, fn);
export function throwIfAborted(signal = currentSignal()) {
if (signal?.aborted) throw Object.assign(new Error("computer request cancelled"), { code: "cancelled" });
}
export async function wait(ms) {
try { await delay(ms, undefined, { signal: currentSignal() ?? undefined }); }
catch (err) { throwIfAborted(); throw err; }
}
/**
* Run a command. Never uses a shell: cmd + args array only, so tool arguments
@@ -7,6 +21,8 @@ import { spawn } from "node:child_process";
* @returns {Promise<{code:number|null, stdout:string, stderr:string, timedOut:boolean, signal:string|null}>}
*/
export function run(cmd, args = [], opts = {}) {
const signal = opts.signal === undefined ? currentSignal() : opts.signal;
if (signal?.aborted) return Promise.resolve({ code: -1, stdout: "", stderr: "computer request cancelled", timedOut: false, signal: null, aborted: true, spawned: false });
const timeoutMs = opts.timeoutMs ?? 20_000;
const maxBuffer = opts.maxBuffer ?? 32 * 1024 * 1024;
return new Promise((resolve) => {
@@ -15,35 +31,45 @@ export function run(cmd, args = [], opts = {}) {
child = spawn(cmd, args, {
env: opts.env ? { ...process.env, ...opts.env } : process.env,
cwd: opts.cwd,
stdio: ["ignore", "pipe", "pipe"],
stdio: [opts.ownerPipe ? "pipe" : "ignore", "pipe", "pipe"],
// Windows: node handles .cmd/.exe resolution for known tools via shell:false + full name
windowsHide: true,
});
} catch (err) {
resolve({ code: -1, stdout: "", stderr: String(err?.message ?? err), timedOut: false, signal: null });
resolve({ code: -1, stdout: "", stderr: String(err?.message ?? err), timedOut: false, signal: null, spawned: false });
return;
}
if (opts.ownerPipe) child.stdin.on("error", () => {});
opts.onSpawn?.(child);
let stdout = "";
let stderr = "";
let timedOut = false;
let settled = false;
const timer = setTimeout(() => {
timedOut = true;
let hardKill;
const terminate = () => {
try { child.kill("SIGTERM"); } catch {}
// Hard kill after grace period
setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 1500);
hardKill ??= setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, 1500);
};
const abort = () => terminate();
signal?.addEventListener("abort", abort, { once: true });
const timer = timeoutMs === 0 ? null : setTimeout(() => {
timedOut = true;
terminate();
}, timeoutMs);
child.stdout.on("data", (d) => {
if (stdout.length < maxBuffer) stdout += d.toString();
opts.onStdout?.(d.toString());
});
child.stderr.on("data", (d) => {
if (stderr.length < maxBuffer) stderr += d.toString();
});
const finish = (code, signal) => {
const finish = (code, exitSignal) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ code, stdout, stderr, timedOut, signal });
clearTimeout(hardKill);
signal?.removeEventListener("abort", abort);
resolve({ code, stdout, stderr, timedOut, signal: exitSignal, spawned: !!child.pid, ...(signal?.aborted ? { aborted: true } : {}) });
};
child.on("error", (err) => {
stderr += String(err?.message ?? err);
@@ -53,9 +79,80 @@ export function run(cmd, args = [], opts = {}) {
});
}
/** A native input owner acknowledges its press, then releases on stdin EOF. */
export async function runInputLease(cmd, args = [], opts = {}) {
throwIfAborted();
let child, buffer = "", stopped = false, closed = false, killTimer;
const pending = [];
const reply = () => new Promise((resolve, reject) => pending.push({ resolve, reject }));
const ready = reply();
const completion = run(cmd, args, {
...opts, ownerPipe: true, timeoutMs: 0,
onSpawn: (process) => { child = process; },
onStdout: (chunk) => {
buffer += chunk;
let nl;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl); buffer = buffer.slice(nl + 1);
const waiter = pending.shift();
if (!waiter) continue;
try { waiter.resolve(JSON.parse(line)); }
catch { waiter.reject(new ExecError("Native input owner returned an invalid receipt")); }
}
},
});
completion.then((result) => {
closed = true;
clearTimeout(killTimer);
const error = Object.assign(new ExecError(result.stderr || "Native input owner closed", result), { code: result.aborted ? "cancelled" : "input_owner_closed" });
for (const waiter of pending.splice(0)) waiter.reject(error);
});
const release = async (message = {}) => {
if (!stopped) {
stopped = true;
if (!closed) {
child?.stdin.end(JSON.stringify({ ...message, release: true }) + "\n");
killTimer = setTimeout(() => {
child?.kill("SIGTERM");
killTimer = setTimeout(() => child?.kill("SIGKILL"), 750);
}, 750);
}
}
const result = await completion;
clearTimeout(killTimer);
if (result.code !== 0) throw Object.assign(new ExecError(result.stderr || "Native input cleanup failed", result), { code: result.aborted ? "cancelled" : "input_release_failed" });
};
let timer;
try {
const receipt = await Promise.race([ready, new Promise((_, reject) => { timer = setTimeout(() => reject(new ExecError("Native input owner did not acknowledge input")), opts.timeoutMs ?? 20_000); })]);
if (receipt?.action_sent !== true || receipt?.input_lease !== true) throw new ExecError("Native input owner did not confirm a live input lease");
return { receipt, release, async send(message) {
const signal = currentSignal();
let commandTimer, abort;
try {
throwIfAborted(signal);
if (closed || stopped || child?.exitCode !== null || child?.signalCode) throw new ExecError("Native input owner is closed");
const next = reply();
const cancelled = new Promise((_, reject) => {
abort = () => reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
signal?.addEventListener("abort", abort, { once: true });
});
child.stdin.write(JSON.stringify(message) + "\n");
return await Promise.race([next, cancelled, new Promise((_, reject) => { commandTimer = setTimeout(() => reject(new ExecError("Native input owner did not acknowledge pointer motion")), opts.timeoutMs ?? 20_000); })]);
}
catch (error) { await release().catch(() => {}); throw error; }
finally { clearTimeout(commandTimer); signal?.removeEventListener("abort", abort); }
} };
} catch (error) {
await release().catch(() => {});
throw error;
} finally { clearTimeout(timer); }
}
/** run() and throw a typed error on non-zero exit / timeout. */
export async function runOk(cmd, args = [], opts = {}) {
const r = await run(cmd, args, opts);
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
if (r.timedOut) throw new ExecError(`timeout after ${opts.timeoutMs ?? 20_000}ms: ${cmd}`, r);
if (r.code !== 0) throw new ExecError(`${cmd} exited ${r.code}: ${trim(r.stderr || r.stdout)}`, r);
return r;
@@ -116,13 +116,14 @@ export const TOOLS = [
},
{
name: "get_app_state",
description: "Observe an application once: returns a bounded accessibility/UIA/uitest element tree with stable indices. Elements are the primary action targets; request screenshots only when accessibility cannot express the target.",
description: "Read an application's text, controls, actions and layout without requiring vision. The default summary keeps app content and top-level menus; full adds nested menus and tree structure. Act using observed state_id/index targets and refresh after UI changes. Missing labels or values are unknown, not an invitation to guess; request a screenshot only when useful.",
inputSchema: {
type: "object",
properties: {
app_ref: { type: "object", properties: { pid: { type: "integer" }, name: { type: "string" }, bundle_id: { type: "string" } }, additionalProperties: false },
window_id: { type: "integer", description: "Zero-based window index within the app" },
detail: { enum: ["compact", "full"] },
detail: { enum: ["summary", "compact", "full"], default: "summary", description: "Summary is the concise default; full includes nested menus and internal tree structure. Compact is a compatibility alias for summary." },
include_ocr: { type: "boolean", default: false, description: "On macOS, also recognize visible text locally from the selected app window. Requires Screen Recording permission. Returns text, confidence and raster coordinate targets for UI that accessibility cannot read; no vision model is required." },
computer: computerParam,
},
additionalProperties: false,
@@ -170,7 +171,7 @@ export const TOOLS = [
properties: {
name: { type: "string" }, bundle_id: { type: "string" }, url: { type: "string" },
pid: { type: "integer", description: "Bind to this exact process. Use when two processes share a bundle id (list_apps shows both); it takes precedence over name and bundle_id and never launches anything." },
activate: { type: "boolean", description: "Bring to foreground; defaults to false. Keep false for background work unless the user requests foreground interaction." },
activate: { type: "boolean", description: "Bring to foreground; defaults to false. On macOS true also selects foreground keyboard delivery for system dialogs, guarded against another app taking focus. Keep false for background work." },
computer: computerParam,
},
additionalProperties: false,
@@ -219,7 +220,7 @@ export const TOOLS = [
},
// ---- text & keyboard ----
{
name: "type", description: "Type text into the focused control (unicode). Focus the field first (click/element action).",
name: "type", description: "Type text into the focused control (unicode). Focus the field first (click/element action). On macOS the receipt carries `verified:true` only when the focused control's value actually reflects the typed text; on `verified:false` the text may have gone nowhere — confirm with a screenshot before relying on it.",
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, computer: computerParam }, additionalProperties: false },
},
{
@@ -254,7 +255,7 @@ export const TOOLS = [
// ---- recording ----
{
name: "recording_start",
description: "Start screen recording on a computer (mp4/mov). Darwin: ScreenCaptureKit via the signed helper (timed or until recording_stop; honors region, no recorder overlay). Linux: x11grab/wf-recorder. Windows: ffmpeg gdigrab. HarmonyOS: snapshot-series muxed with ffmpeg.",
description: "Start screen recording on a computer (mp4/mov). Darwin: ScreenCaptureKit via the native helper (timed or until recording_stop; honors region, no recorder overlay, stops on session exit). Linux and Windows: unavailable pending session-owned recorder cleanup; use screenshots. HarmonyOS: snapshot-series muxed with ffmpeg.",
inputSchema: {
type: "object",
properties: {
@@ -4,8 +4,8 @@
// - ssh: run the codewhale-cu remote agent over ssh (args travel as base64 JSON,
// so no tool argument can ever become remote shell syntax)
// - hdc: HarmonyOS device over `hdc` shell / file push-pull
import { run, runOk, ExecError } from "./exec.mjs";
import { ensureApp, appRequest } from "./app-socket.mjs";
import { run, runOk, runInputLease, ExecError, currentSignal } from "./exec.mjs";
import { ensureApp, appSessionRequest } from "./app-socket.mjs";
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
@@ -15,6 +15,16 @@ import url from "node:url";
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
export const PLUGIN_ROOT = path.resolve(__dirname, "..");
// A new MCP process always starts a fresh app/input/raster binding, even when
// the permission-owning desktop helper remains running across tasks.
export const SESSION_ID = crypto.randomUUID();
let usedApp = false;
export function closeAppSession({ releaseOnly = false } = {}) {
if (!usedApp) return Promise.resolve();
return appSessionRequest({ tool: releaseOnly ? "release_session_input" : "close_session", sessionId: SESSION_ID }, { timeoutMs: 2_500, signal: null }).then((reply) => {
if (!reply?.ok) throw Object.assign(new ExecError(reply?.error?.message ?? "Computer input cleanup failed"), { code: reply?.error?.code ?? "input_release_failed" });
});
}
export function b64(obj) {
return Buffer.from(JSON.stringify(obj), "utf8").toString("base64");
@@ -40,6 +50,7 @@ export function localExec() {
kind: "local",
run,
runOk,
runInputLease,
async readFile(p) { return fs.promises.readFile(p); },
async writeFile(p, data) { return fs.promises.writeFile(p, data); },
tmpFile(prefix) {
@@ -52,14 +63,15 @@ export function localExec() {
* App executor: the local computer driven through the desktop app's socket.
* Same `remote()` contract as ssh, but files the app writes are on this disk.
*/
export function appExec(app) {
export function appExec(app, sessionId = SESSION_ID) {
return {
...localExec(),
kind: "app",
app,
filesLocal: true,
remote(request, opts = {}) {
return appRequest(request, { timeoutMs: opts.timeoutMs ?? 30_000 });
usedApp = true;
return appSessionRequest({ ...request, sessionId }, { timeoutMs: opts.timeoutMs ?? 30_000 });
},
};
}
@@ -83,6 +95,7 @@ export function sshExec(computer) {
const r = await run("ssh", [...base, "node", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
timeoutMs: opts.timeoutMs ?? 25_000,
});
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
if (r.timedOut) throw new ExecError(`ssh ${userHost}: timed out`, r);
if (r.code !== 0) throw new ExecError(`ssh ${userHost} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
// The agent prints exactly one JSON line; anything before it is MOTD noise.
@@ -158,10 +171,13 @@ export async function executorFor(computer) {
// agent) in-process, so wire argument preparation is covered by tests.
if (process.env.CODEWHALE_CU_TEST_REMOTE === "1") {
const { handle } = await import("./app-handler.mjs");
return { ...appExec({ id: "test", name: "test app" }), remote: (request) => handle(request) };
return { ...appExec({ id: "test", name: "test app" }), remote: (request) => handle(request, { sessionId: SESSION_ID, signal: currentSignal() }) };
}
const status = await ensureApp();
if (status.via === "app") return appExec(status.app);
if (status.via === "app") {
if (status.app.sessionProtocol !== 2) throw Object.assign(new ExecError("The installed Computer Use helper needs an update for isolated sessions and disconnect cleanup. Rebuild/reinstall it, then retry."), { code: "app_upgrade_required" });
return appExec(status.app);
}
return { ...localExec(), appReason: status.reason };
}
if (computer.transport === "ssh") return sshExec(computer);
@@ -0,0 +1,157 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { create } from '../src/backends/darwin.mjs';
function fixture(t, options = {}) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-ocr-'));
const saved = [process.env.CODEWHALE_CU_APP_BUNDLE, process.env.CODEWHALE_CU_RECORDINGS_DIR];
t.after(() => {
for (const [index, name] of ['CODEWHALE_CU_APP_BUNDLE', 'CODEWHALE_CU_RECORDINGS_DIR'].entries()) {
if (saved[index] === undefined) delete process.env[name]; else process.env[name] = saved[index];
}
fs.rmSync(root, { recursive: true, force: true });
});
fs.mkdirSync(path.join(root, 'Contents', 'MacOS'), { recursive: true });
fs.writeFileSync(path.join(root, 'Contents', 'MacOS', 'accessibility'), '');
process.env.CODEWHALE_CU_APP_BUNDLE = root;
process.env.CODEWHALE_CU_RECORDINGS_DIR = path.join(root, 'captures');
const observed = { found: true, pid: 731, bundle_id: 'test.ocr', elements: [{ index: 0, role: 'AXButton', label: 'Save', actions: ['AXPress'], windowIndex: 1, path: [0] }] };
if (options.missingIdentity) delete observed.pid;
const calls = [];
const backend = create({ exec: { async run(cmd, args) {
if (cmd === 'screencapture') {
calls.push({ tool: 'screencapture', args });
if (options.captureFailure) return { code: 1, stderr: 'Screen Recording permission denied', stdout: '' };
const header = Buffer.alloc(24);
header.writeUInt32BE(200, 16); header.writeUInt32BE(100, 20);
fs.writeFileSync(args.at(-1), header);
return { code: 0, stdout: '', stderr: '' };
}
const request = JSON.parse(args[0]); calls.push(request);
let body;
if (request.tool === 'get_app_state') body = observed;
else if (request.tool === 'input_capabilities') body = { window_ocr: options.oldHelper ? 0 : 1 };
else if (request.tool === 'window_info') body = { window_id: 901, points: { x: -400, y: 200, w: 100, h: 50 } };
else if (request.tool === 'displays') body = [{ index: 1, scale: 2 }];
else if (request.tool === 'recognize_text') {
if (options.cancelled) return { code: -1, stdout: '', stderr: '', aborted: true };
body = options.ocr ?? { status: 'ok', engine: 'apple_vision', coordinate_space: 'raster_pixels', pixels: { w: 200, h: 100 }, blocks: [{ text: 'Invoice total', confidence: 0.92, bounds: { x: 20, y: 10, w: 40, h: 20 } }] };
} else assert.fail(`unexpected native tool ${request.tool}`);
return { code: 0, stdout: JSON.stringify(body), stderr: '' };
} } });
return { backend, calls, observed, root };
}
test('default app state remains AX-only without capture or OCR work', async t => {
const { backend, calls, observed } = fixture(t);
assert.deepEqual(await backend.get_app_state({ app_ref: { pid: 731 } }), observed);
assert.deepEqual(calls.map(call => call.tool), ['get_app_state']);
});
test('opt-in OCR captures the resolved selected window and exposes raster-pixel text targets', async t => {
const { backend, calls, observed } = fixture(t);
const result = await backend.get_app_state({ app_ref: { name: 'Test' }, window_id: 1, include_ocr: true });
assert.deepEqual(result.elements, observed.elements, 'AX roles and identity remain unchanged');
const window = calls.find(call => call.tool === 'window_info');
assert.deepEqual(window.args.app_ref, { pid: 731, bundle_id: 'test.ocr' });
assert.equal(window.args.window_id, 1);
const capture = calls.find(call => call.tool === 'screencapture');
assert.deepEqual(capture.args.slice(0, -1), ['-x', '-t', 'png', '-o', '-l', '901']);
assert.equal(result.ocr.status, 'ok');
assert.deepEqual(result.ocr.blocks[0].target, { type: 'coordinate', x: 40, y: 20 });
assert.equal(result.ocr.blocks[0].role, undefined, 'recognized text is not an accessibility control');
assert.deepEqual(result.ocr.raster.points, { x: -400, y: 200, w: 100, h: 50 });
assert.equal(result.ocr.raster.scale, 2);
assert.equal(calls.find(call => call.tool === 'recognize_text').args.file, result.ocr.raster.file);
});
test('capture permission failure preserves valid AX state with an explicit OCR diagnosis', async t => {
const { backend, calls, observed } = fixture(t, { captureFailure: true });
const result = await backend.get_app_state({ app_ref: { pid: 731 }, include_ocr: true });
assert.deepEqual(result.elements, observed.elements);
assert.equal(result.ocr.status, 'unavailable');
assert.match(result.ocr.reason, /Screen Recording permission denied/);
assert.equal(result.ocr.raster, undefined);
assert.ok(!calls.some(call => call.tool === 'recognize_text'));
});
test('OCR failure keeps AX state and captured geometry but never invents text targets', async t => {
const { backend, observed } = fixture(t, { ocr: { status: 'unavailable', reason: 'Apple Vision unavailable' } });
const result = await backend.get_app_state({ app_ref: { pid: 731 }, include_ocr: true });
assert.deepEqual(result.elements, observed.elements);
assert.equal(result.ocr.reason, 'Apple Vision unavailable');
assert.deepEqual(result.ocr.blocks, []);
assert.deepEqual(result.ocr.raster.pixels, { w: 200, h: 100 }, 'MCP can bind the new capture even when OCR fails');
});
test('OCR image dimension mismatch fails without discarding AX state', async t => {
const { backend, observed } = fixture(t, { ocr: { status: 'ok', pixels: { w: 400, h: 200 }, blocks: [] } });
const result = await backend.get_app_state({ app_ref: { pid: 731 }, include_ocr: true });
assert.deepEqual(result.elements, observed.elements);
assert.equal(result.ocr.status, 'unavailable');
assert.match(result.ocr.reason, /mismatched image dimensions/);
});
test('missing resolved identity cannot redirect optional OCR to the foreground application', async t => {
const { backend, calls } = fixture(t, { missingIdentity: true });
const result = await backend.get_app_state({ app_ref: { name: 'Test' }, include_ocr: true });
assert.equal(result.ocr.status, 'unavailable');
assert.match(result.ocr.reason, /exact process identity/);
assert.deepEqual(calls.map(call => call.tool), ['get_app_state']);
});
test('an older helper is refused before it can capture a different window', async t => {
const { backend, calls } = fixture(t, { oldHelper: true });
const result = await backend.get_app_state({ app_ref: { pid: 731 }, window_id: 1, include_ocr: true });
assert.equal(result.ocr.status, 'unavailable');
assert.match(result.ocr.reason, /helper needs an update/);
assert.deepEqual(calls.map(call => call.tool), ['get_app_state', 'input_capabilities']);
});
test('OCR cancellation remains cancellation', async t => {
const { backend } = fixture(t, { cancelled: true });
await assert.rejects(backend.get_app_state({ app_ref: { pid: 731 }, include_ocr: true }), error => error.code === 'cancelled');
});
test('Apple Vision recognizes a generated image with correct raster bounds and rejects invalid images', { skip: process.platform !== 'darwin' }, t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-vision-test-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const source = path.join(root, 'probe.m'), binary = path.join(root, 'probe'), image = path.join(root, 'text.png');
fs.writeFileSync(source, `#import ${JSON.stringify(path.resolve('src/backends/darwin-ocr.h'))}
int main(int argc,const char **argv) { @autoreleasepool {
NSString *file=[NSString stringWithUTF8String:argv[1]];
NSBitmapImageRep *bitmap=[[NSBitmapImageRep alloc] initWithBitmapDataPlanes:NULL pixelsWide:1000 pixelsHigh:320 bitsPerSample:8 samplesPerPixel:4 hasAlpha:YES isPlanar:NO colorSpaceName:NSDeviceRGBColorSpace bytesPerRow:0 bitsPerPixel:0];
[NSGraphicsContext saveGraphicsState];
[NSGraphicsContext setCurrentContext:[NSGraphicsContext graphicsContextWithBitmapImageRep:bitmap]];
[NSColor.whiteColor setFill]; NSRectFill(NSMakeRect(0,0,1000,320));
[@"Codewhale OCR 7319" drawAtPoint:NSMakePoint(50,220) withAttributes:@{NSFontAttributeName:[NSFont systemFontOfSize:48],NSForegroundColorAttributeName:NSColor.blackColor}];
[@"Invoice total 42.50" drawAtPoint:NSMakePoint(50,70) withAttributes:@{NSFontAttributeName:[NSFont systemFontOfSize:40],NSForegroundColorAttributeName:NSColor.blackColor}];
[NSGraphicsContext restoreGraphicsState];
[[bitmap representationUsingType:NSBitmapImageFileTypePNG properties:@{}] writeToFile:file atomically:YES];
NSDictionary *recognized=cuRecognizeText(file);
[@"invalid image" writeToFile:file atomically:YES encoding:NSUTF8StringEncoding error:nil];
NSDictionary *result=@{@"recognized":recognized,@"invalid":cuRecognizeText(file),@"missing":cuRecognizeText([file stringByAppendingString:@".missing"]),@"bounds":cuOCRPixelBounds(CGRectMake(.125,.25,.25,.5),1000,800),@"clipped":cuOCRPixelBounds(CGRectMake(-.5,-.5,2,2),1000,800)};
NSData *json=[NSJSONSerialization dataWithJSONObject:result options:0 error:nil];
puts([[NSString alloc] initWithData:json encoding:NSUTF8StringEncoding].UTF8String);
} return 0; }`);
const build = spawnSync('clang', ['-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'Vision', source, '-o', binary], { encoding: 'utf8' });
assert.equal(build.status, 0, build.stderr);
const run = spawnSync(binary, [image], { encoding: 'utf8', timeout: 20000 });
assert.equal(run.status, 0, run.stderr);
const result = JSON.parse(run.stdout);
assert.equal(result.recognized.status, 'ok', JSON.stringify(result.recognized));
const first = result.recognized.blocks.find(block => block.text === 'Codewhale OCR 7319');
const second = result.recognized.blocks.find(block => block.text === 'Invoice total 42.50');
assert.ok(first && second, JSON.stringify(result.recognized));
assert.ok(first.confidence > 0.8 && second.confidence > 0.8);
assert.ok(first.bounds.x >= 40 && first.bounds.x < 65 && first.bounds.y >= 40 && first.bounds.y < 100);
assert.ok(second.bounds.y > 200 && second.bounds.y < 260, 'Vision lower-left coordinates became top-left raster coordinates');
assert.deepEqual(result.bounds, { x: 125, y: 200, w: 250, h: 400 });
assert.deepEqual(result.clipped, { x: 0, y: 0, w: 1000, h: 800 });
assert.equal(result.invalid.status, 'unavailable');
assert.equal(result.missing.status, 'unavailable');
});
@@ -0,0 +1,113 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { create } from '../src/backends/darwin.mjs';
import { withSignal } from '../src/exec.mjs';
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-recording-owner-'));
const oldBundle = process.env.CODEWHALE_CU_APP_BUNDLE;
const oldDir = process.env.CODEWHALE_CU_RECORDINGS_DIR;
t.after(() => {
if (oldBundle === undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE = oldBundle;
if (oldDir === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldDir;
fs.rmSync(root, { recursive: true, force: true });
});
const helper = path.join(root, 'Contents', 'MacOS', 'accessibility');
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, `#!${process.execPath}\n` + `
import fs from 'node:fs';
const {args} = JSON.parse(process.argv[2]);
fs.writeFileSync(args.file, 'partial');
const stop = () => { if(args.durationSec === 8) return; fs.writeFileSync(args.file, 'finalized'); process.exit(0); };
process.on('SIGINT', stop); process.stdin.resume(); process.stdin.on('end', stop);
if(args.durationSec !== 7) console.log(JSON.stringify({ready:true}));
setInterval(()=>{},1000);
`);
// No extension: force ESM through package metadata for the fixture launcher.
fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
fs.chmodSync(helper, 0o700);
process.env.CODEWHALE_CU_APP_BUNDLE = root;
process.env.CODEWHALE_CU_RECORDINGS_DIR = path.join(root, 'recordings');
const make = (ownerPipe = 1) => create({ exec: { async run(_cmd, args) {
const tool = JSON.parse(args[0]).tool;
if (tool === 'input_capabilities') return {code:0,stdout:JSON.stringify({record_owner_pipe:ownerPipe}),stderr:''};
assert.equal(tool, 'displays');
return { code: 0, stdout: JSON.stringify([{index:1,id:1}]), stderr: '' };
} } });
return { root, make };
}
test('an old recorder helper is refused before any recording process starts', {skip:process.platform==='win32'}, async t => {
const { make, root } = fixture(t);
await assert.rejects(make(0).recordingStart(), /update Computer Use before recording/);
assert.deepEqual(fs.readdirSync(path.join(root, 'recordings')), []);
});
test('closing a session stops only its recorders and retains finalized files', {skip:process.platform==='win32'}, async t => {
const { make } = fixture(t);
const owner = make(), other = make();
const first = await owner.recordingStart();
const second = await other.recordingStart();
t.after(() => Promise.allSettled([owner.closeSession(), other.closeSession()]));
await owner.releaseInput();
assert.equal((await owner.recordingStatus({id:first.id})).running, true, 'input cancellation does not stop recording');
await owner.closeSession();
assert.equal(fs.readFileSync(first.file, 'utf8'), 'finalized');
assert.equal((await other.recordingStatus({id:second.id})).running, true);
await other.closeSession();
assert.equal(fs.readFileSync(second.file, 'utf8'), 'finalized');
});
test('recording startup cancellation owns and stops the pending child', {skip:process.platform==='win32'}, async t => {
const { make, root } = fixture(t);
const owner = make(), controller = new AbortController();
const started = withSignal(controller.signal, () => owner.recordingStart({durationSec:7}));
const rejected = assert.rejects(started, error => error.code === 'cancelled');
const dir = path.join(root, 'recordings');
const deadline = Date.now() + 2000;
while ((!fs.existsSync(dir) || !fs.readdirSync(dir).length) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
controller.abort();
await rejected;
assert.equal((await owner.recordingList()).running.length, 0);
const files = fs.readdirSync(dir);
assert.equal(files.length, 1);
assert.equal(fs.readFileSync(path.join(dir, files[0]), 'utf8'), 'finalized');
});
test('session close bounds a stubborn recorder and retains its partial file', {skip:process.platform==='win32'}, async t => {
const { make } = fixture(t);
const owner = make();
const recording = await owner.recordingStart({durationSec:8});
const started = Date.now();
await assert.rejects(owner.closeSession(), /partial file retained/);
assert.ok(Date.now() - started < 3000);
let alive = true;
const deadline = Date.now() + 500;
while (alive && Date.now() < deadline) {
try { process.kill(recording.pid, 0); } catch (error) { assert.equal(error.code, 'ESRCH'); alive = false; }
if (alive) await new Promise(resolve => setTimeout(resolve, 10));
}
assert.equal(alive, false, 'the stubborn capture process was terminated');
assert.equal(fs.readFileSync(recording.file, 'utf8'), 'partial');
assert.equal((await owner.recordingList()).running.length, 0);
});
test('native recording startup notices owner pipe EOF without capturing a screen', {skip:process.platform!=='darwin'}, async t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-recording-pipe-'));
t.after(() => fs.rmSync(root, {recursive:true, force:true}));
const source = path.join(root, 'probe.m'), binary = path.join(root, 'probe');
const header = path.resolve('src/backends/darwin-recording.h');
fs.writeFileSync(source, `#import <Cocoa/Cocoa.h>\n#import <ApplicationServices/ApplicationServices.h>\n#import ${JSON.stringify(header)}\nint main(){@autoreleasepool{cuRecordingOwnerPipe=YES;puts("ready");fflush(stdout);@try{cuWait(dispatch_semaphore_create(0),15,YES);return 2;}@catch(NSException *e){return [e.name isEqual:@"cancelled"]?0:3;}}}`);
const build = spawnSync('clang', ['-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia',source,'-o',binary], {encoding:'utf8'});
assert.equal(build.status, 0, build.stderr);
const child = spawn(binary, [], {stdio:['pipe','pipe','pipe']});
t.after(() => {if(child.exitCode===null)child.kill('SIGKILL');});
await new Promise(resolve => child.stdout.once('data', resolve));
const exited = new Promise(resolve => child.once('exit', resolve));
child.stdin.end();
assert.equal(await exited, 0);
});
@@ -3,17 +3,122 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { create } from '../src/backends/darwin.mjs';
import { withSignal, currentSignal, runInputLease } from '../src/exec.mjs';
// Simulate the native lease process: it owns cleanup itself, keeping the
// original app identity even if the JS backend is rebound before release.
function leaseExecutor(run) {
return { async run(cmd,args,opts) {
const result=await run(cmd,args,opts);
if(args[0]?.startsWith('{') && JSON.parse(args[0]).tool==='input_capabilities' && result.code===0 && JSON.parse(result.stdout).input_lease===undefined) return {code:0,stderr:'',stdout:JSON.stringify({input_lease:1})};
return result;
}, async runInputLease(cmd, argv) {
const request = JSON.parse(argv[0]);
const release = async ({point} = {}) => {
const args = request.args;
const native = request.tool === 'key_event'
? {tool:'key_event',args:{...args,down:false,owned_release:true}}
: {tool:'release_input',args:{...args,point:point??args.steps.at(-1),button:0}};
await withSignal(null,()=>run(cmd,[JSON.stringify(native)],{timeoutMs:20000,ownerPipe:true}));
};
const result = await run(cmd,argv,{timeoutMs:20000,ownerPipe:true});
if (result.code !== 0 || result.aborted || result.timedOut) {
if(result.spawned && (result.aborted || result.timedOut)) await release();
throw Object.assign(new Error(result.aborted?'computer request cancelled':result.timedOut?'native accessibility helper timed out':result.stderr),{code:result.aborted?'cancelled':'native_error'});
}
return {receipt:JSON.parse(result.stdout),release,async send({point}) {
const result=await run(cmd,[JSON.stringify({tool:'pointer_sequence',args:{...request.args,steps:[{type:6,...point,button:0}]}})],{timeoutMs:20000,ownerPipe:true});
return JSON.parse(result.stdout);
}};
}};
}
test('native summary keeps text and top-level menus without spending the UI budget on hidden menu trees', {skip:process.platform!=='darwin'}, t=>{
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-observation-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
const binary=path.join(dir,'native');
const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'});
assert.equal(build.status,0,build.stderr);
const node=(role,label,children=[])=>({AXRole:role,AXTitle:label,AXChildren:children,actions:['AXPress']});
const app={AXMenuBar:node('AXMenuBar','Menu bar',[node('AXMenuBarItem','File',[node('AXMenu','File menu',Array.from({length:150},(_,i)=>node('AXMenuItem',`Command ${i}`)))])]),AXChildren:[node('AXMenu','Popup',[node('AXMenuItem','Choose')])]};
const windows=[node('AXWindow','Fixture',Array.from({length:350},(_,i)=>({...node('AXTextField',`Field ${i}`),AXValue:`Value ${i}`,AXFocused:i===349})))];
const observe=(detail)=>{
const r=spawnSync(binary,[JSON.stringify({tool:'inspect_observation',args:{app,windows,detail}})],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);return JSON.parse(r.stdout);
};
for(const detail of [undefined,'summary','compact']) {
const state=observe(detail);
assert.equal(state.truncated,false,'intentional menu summarization is not a truncated observation');
assert.equal(state.elements.length,355);
assert.ok(state.elements.some(e=>e.label==='File'));
assert.ok(state.elements.some(e=>e.label==='Choose'),'open popup actions remain observable');
assert.ok(!state.elements.some(e=>e.label==='Command 0'));
const field=state.elements.find(e=>e.label==='Field 349');
assert.equal(field.value,'Value 349');assert.equal(field.focused,true);
assert.deepEqual(field.path,[349]);assert.equal(field.windowIndex,0);
}
const full=observe('full');
assert.equal(full.truncated,false);
assert.equal(full.elements.find(e=>e.label==='Command 149').windowIndex,-1);
assert.deepEqual(full.elements.find(e=>e.label==='Command 149').path,[0,0,149]);
assert.ok(full.elements.some(e=>e.label==='Field 349'));
});
test('native Unicode encoding round-trips through the actual CoreGraphics event', {skip:process.platform!=='darwin'}, t=>{
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-test-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
const binary=path.join(dir,'native');
const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'});
const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'});
assert.equal(build.status,0,build.stderr);
for(const text of ['Hello 世界 🐋','quote " slash \\ newline\n','e\u0301 👨‍👩‍👧‍👦']){
const r=spawnSync(binary,[JSON.stringify({tool:'inspect_text_event',args:{text}})],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).text,text);
t.diagnostic(`Constructed literal Unicode event flags: ${JSON.parse(r.stdout).flags}`);
}
});
test('native window matching refuses another process, mismatched geometry and ambiguous captures', {skip:process.platform!=='darwin'}, t=>{
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-window-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
const binary=path.join(dir,'native');
const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'});
assert.equal(build.status,0,build.stderr);
const selected={kCGWindowOwnerPID:123,kCGWindowLayer:0,kCGWindowNumber:42,kCGWindowBounds:{X:10,Y:20,Width:400,Height:300}};
const query=windows=>spawnSync(binary,[JSON.stringify({tool:'inspect_window_match',args:{pid:123,bounds:{x:10,y:20,w:400,h:300},windows}})],{encoding:'utf8'});
const wrongProcess={...selected,kCGWindowOwnerPID:999,kCGWindowNumber:43};
const wrongSize={...selected,kCGWindowNumber:44,kCGWindowBounds:{...selected.kCGWindowBounds,Width:800}};
const found=query([wrongProcess,wrongSize,selected]);
assert.equal(found.status,0,found.stderr);assert.equal(JSON.parse(found.stdout).window_id,42);
const missing=query([wrongProcess,wrongSize]);
assert.equal(missing.status,1);assert.match(missing.stderr,/selected app window is not capturable/);
const ambiguous=query([selected,{...selected,kCGWindowNumber:45}]);
assert.equal(ambiguous.status,1);assert.match(ambiguous.stderr,/selected window is ambiguous/);
});
test('native owner pipe survives forced MCP exit, releases promptly and excludes competing input', {skip:process.platform!=='darwin'}, async t=>{
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-owner-')); t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
const binary=path.join(dir,'native');
const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'});
assert.equal(build.status,0,build.stderr);
for(const workMs of [0,5000]) {
const releaseFile=path.join(dir,`released-${workMs}`);
const args={owner_pipe:true,input_lease:true,lock_dir:dir,release_file:releaseFile,work_ms:workMs};
const request=JSON.stringify({tool:'test_input_lease',args});
const parent=spawn(process.execPath,['--input-type=module','-e',`
import {runInputLease} from ${JSON.stringify(new URL('../src/exec.mjs',import.meta.url).href)};
await runInputLease(process.argv[1],[process.argv[2]]);
console.log('ready'); setInterval(()=>{},1000);
`,binary,request],{stdio:['ignore','pipe','pipe']});
t.after(()=>{if(parent.exitCode===null)parent.kill('SIGKILL');});
await new Promise((resolve,reject)=>{parent.stdout.once('data',resolve);parent.once('exit',code=>reject(new Error(`parent exited before ready: ${code}`)));});
const competing=spawnSync(binary,[JSON.stringify({tool:'test_input_lease',args:{lock_dir:dir}})],{encoding:'utf8'});
assert.equal(competing.status,1);
assert.match(competing.stderr,/another Computer Use session owns held input/);
assert.ok(!fs.existsSync(releaseFile));
const exited=new Promise(resolve=>parent.once('exit',resolve));parent.kill('SIGKILL');await exited;
const deadline=Date.now()+2000;
while((!fs.existsSync(releaseFile)||fs.readFileSync(releaseFile,'utf8')!=='released')&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,20));
assert.equal(fs.readFileSync(releaseFile,'utf8'),'released','native cleanup ran on owner pipe EOF');
const successor=await runInputLease(binary,[JSON.stringify({tool:'test_input_lease',args:{...args,work_ms:0}})]);await successor.release();
}
});
test('macOS backend binds native input to the opened process and reports denied permissions honestly', async t=>{
@@ -21,13 +126,13 @@ test('macOS backend binds native input to the opened process and reports denied
t.after(()=>{if(old===undefined)delete process.env.CODEWHALE_CU_APP_BUNDLE;else process.env.CODEWHALE_CU_APP_BUNDLE=old;fs.rmSync(bundle,{recursive:true,force:true});});
fs.mkdirSync(path.join(bundle,'Contents','MacOS'),{recursive:true});fs.writeFileSync(path.join(bundle,'Contents','MacOS','accessibility'),'');process.env.CODEWHALE_CU_APP_BUNDLE=bundle;
const calls=[];
const backend=create({exec:{async run(cmd,args,opts){
const backend=create({exec:leaseExecutor(async (cmd,args,opts)=>{
assert.ok(Number.isFinite(opts.timeoutMs));
if(cmd==='open')return {code:0,stdout:'',stderr:''};
if(cmd==='screencapture')return {code:1,stdout:'',stderr:'denied'};
const request=JSON.parse(args[0]);calls.push(request);
return {code:0,stderr:'',stdout:JSON.stringify(request.tool==='app_info'?{found:true,pid:123,bundle_id:'test.app'}:request.tool==='permissions'?{trusted:false}:{action_sent:true})};
}}});
})});
await backend.open_application({name:'TextEdit',activate:false});await backend.key({text:'cmd+n'});await backend.type({text:'Hello 世界 🐋'});
const events=calls.filter(c=>c.tool==='key_event');assert.equal(events.length,2);assert.equal(events[0].args.code,45);assert.equal(events[0].args.flags,1<<20);assert.equal(events[0].args.input_app_ref.pid,123);assert.equal(events[1].args.down,false);
assert.equal(calls.find(c=>c.tool==='type').args.text,'Hello 世界 🐋');
@@ -39,7 +144,7 @@ test('macOS background binding avoids reopen and releases at the agent pointer,
t.after(()=>{if(old===undefined)delete process.env.CODEWHALE_CU_APP_BUNDLE;else process.env.CODEWHALE_CU_APP_BUNDLE=old;fs.rmSync(bundle,{recursive:true,force:true});});
fs.mkdirSync(path.join(bundle,'Contents','MacOS'),{recursive:true});fs.writeFileSync(path.join(bundle,'Contents','MacOS','accessibility'),'');process.env.CODEWHALE_CU_APP_BUNDLE=bundle;
const calls=[];
const backend=create({exec:{async run(cmd,args){
const backend=create({exec:leaseExecutor(async (cmd,args)=>{
assert.notEqual(cmd,'open','binding a running app must not reopen its windows');
const request=JSON.parse(args[0]);calls.push(request);
assert.notEqual(request.tool,'cursor_position','release must not sample the physical pointer');
@@ -47,7 +152,7 @@ test('macOS background binding avoids reopen and releases at the agent pointer,
:request.tool==='window_at_point'?{found:true,owner_pid:123,owner_name:'TextEdit',window_id:9,layer:0}
:{action_sent:true};
return {code:0,stderr:'',stdout:JSON.stringify(body)};
}}});
})});
await backend.open_application({name:'TextEdit'});
assert.equal(calls[0].args.activate,false);
await assert.rejects(backend.left_mouse_up({}),/no agent pointer/);
@@ -55,10 +160,8 @@ test('macOS background binding avoids reopen and releases at the agent pointer,
await backend.mouse_move({target:{x:140,y:250}});
await backend.left_mouse_up({});
const release=calls.at(-1);
assert.equal(release.tool,'pointer_sequence');
assert.equal(release.args.steps.length,1);
assert.equal(release.args.steps[0].type,2,'left mouse up');
assert.deepEqual([release.args.steps[0].x,release.args.steps[0].y],[140,250],'release lands at the agent pointer');
assert.equal(release.tool,'release_input');
assert.deepEqual(release.args.point,{x:140,y:250},'release lands at the agent pointer');
assert.equal(release.args.restore,false,'a held button is not put back');
assert.equal(release.args.input_app_ref.pid,123);
assert.ok(!calls.some(c=>c.tool==='preview_notify'),'background actions do not open preview');
@@ -73,21 +176,135 @@ function stubBackend(t, reply) {
fs.writeFileSync(path.join(bundle, 'Contents', 'MacOS', 'accessibility'), '');
process.env.CODEWHALE_CU_APP_BUNDLE = bundle;
const calls = [];
const backend = create({ exec: { async run(cmd, args) {
const backend = create({ exec: leaseExecutor(async (cmd, args) => {
const request = JSON.parse(args[0]);
calls.push(request);
const body = request.tool === 'app_info' ? { found: true, pid: 321, bundle_id: 'test.app' }
: reply(request)
const custom = reply(request);
if (custom?.nativeResult) return custom.nativeResult;
const body = request.tool === 'app_info' ? custom ?? { found: true, pid: 321, bundle_id: 'test.app' }
: custom
?? (request.tool === 'window_at_point' ? { found: true, owner_pid: 321, owner_name: 'TextEdit', window_id: 9, layer: 0 }
: { action_sent: true, restored: true });
return { code: 0, stderr: '', stdout: JSON.stringify(body) };
} } });
}) });
return { backend, calls };
}
const PRESSABLE = { found: true, element: { role: 'AXButton', label: 'Tab B', actions: ['AXPress'] }, action: 'AXPress', action_sent: true };
const NOT_PRESSABLE = { found: false, reason: 'no_pressable_element' };
test('macOS refuses an old native helper before any held input is dispatched', async t => {
const {backend,calls}=stubBackend(t,request=>request.tool==='input_capabilities'?{input_lease:0}:null);
await backend.open_application({name:'Fixture'});
await assert.rejects(backend.key({text:'cmd+n'}),/helper needs an update/);
await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}),/helper needs an update/);
assert.ok(!calls.some(request=>['key_event','pointer_sequence','release_input'].includes(request.tool)));
});
test('macOS pointer cleanup keeps its original app after a background rebind', async t => {
const {backend,calls}=stubBackend(t,request=>request.tool==='app_info'?{found:true,pid:request.args.app_ref.name==='First'?321:654,bundle_id:'test.app'}:null);
await backend.open_application({name:'First',activate:true});
await backend.left_mouse_down({target:{x:70,y:80}});
await backend.open_application({name:'Second',activate:false});
await backend.releaseInput();
const release=calls.find(request=>request.tool==='release_input');
assert.equal(release.args.foreground_input,true,'release belongs to the native owner from the original binding');
assert.equal(release.args.input_app_ref.pid,321);
});
test('macOS cancellation releases a held key without replaying it', async t => {
const controller = new AbortController();
const { backend, calls } = stubBackend(t, request => {
if (request.tool === 'key_event' && request.args.down) controller.abort();
if (request.tool === 'key_event' && !request.args.down) assert.equal(currentSignal(), null);
});
await backend.open_application({name:'Fixture'});
await assert.rejects(withSignal(controller.signal, () => backend.hold_key({text:'shift+a', duration:30})), /cancelled/);
assert.deepEqual(calls.filter(r=>r.tool==='key_event').map(r=>r.args.down), [true,false]);
});
test('macOS session cleanup releases only its owned mouse press once', async t => {
const { backend, calls } = stubBackend(t, () => null);
await backend.open_application({name:'Fixture'});
await backend.releaseInput();
assert.ok(!calls.some(r=>r.tool==='release_input'));
await backend.left_mouse_down({target:{x:70,y:80}});
await withSignal(AbortSignal.abort(), () => backend.releaseInput());
await backend.releaseInput();
const releases=calls.filter(r=>r.tool==='release_input');
assert.equal(releases.length,1);
assert.deepEqual(releases[0].args.point,{x:70,y:80});
});
test('macOS foreground delivery requires explicit activation and resets on background binding', async t => {
const { backend, calls } = stubBackend(t, () => null);
await backend.open_application({name:'Fixture',activate:true});
assert.equal((await backend.key({text:'return'})).keyboard_delivery,'foreground-guarded');
assert.ok(calls.filter(r=>r.tool==='key_event').every(r=>r.args.foreground_input));
await backend.open_application({name:'Fixture',activate:false});
assert.equal((await backend.key({text:'return'})).keyboard_delivery,'process');
assert.equal(calls.at(-1).args.foreground_input,false);
});
test('macOS foreground refusal never sends an unowned global key-up', async t => {
const { backend, calls } = stubBackend(t, request => request.tool === 'key_event' && request.args.down
? { nativeResult: { code: 1, spawned: true, stdout: '', stderr: 'foreground changed to Mail (pid 999); expected Fixture (pid 321)' } } : null);
await backend.open_application({name:'Fixture',activate:true});
await assert.rejects(backend.key({text:'cmd+n'}), /foreground changed to Mail \(pid 999\)/);
await assert.rejects(backend.hold_key({text:'shift+a',duration:30}), /expected Fixture/);
assert.deepEqual(calls.filter(r=>r.tool==='key_event').map(r=>r.args.down), [true,true]);
});
test('macOS cancellation before child spawn does not release keys it never pressed', async t => {
const { backend, calls } = stubBackend(t, request => request.tool === 'key_event' && request.args.down
? { nativeResult: { code: -1, spawned: false, aborted: true, stdout: '', stderr: '' } } : null);
await backend.open_application({name:'Fixture',activate:true});
await assert.rejects(backend.hold_key({text:'shift+a',duration:30}), /cancelled/);
assert.deepEqual(calls.filter(r=>r.tool==='key_event').map(r=>r.args.down), [true]);
});
for (const failure of ['aborted','timedOut']) test(`macOS ${failure} after child spawn releases an ambiguous key press`, async t => {
const { backend, calls } = stubBackend(t, request => {
if(request.tool==='key_event' && request.args.down) return { nativeResult: { code: null, spawned: true, [failure]: true, stdout: '', stderr: '' } };
if(request.tool==='key_event' && !request.args.down) assert.equal(currentSignal(),null);
});
await backend.open_application({name:'Fixture',activate:true});
await assert.rejects(backend.key({text:'cmd+n'}), /cancelled|timed out/);
const events=calls.filter(r=>r.tool==='key_event');
assert.deepEqual(events.map(r=>r.args.down), [true,false]);
assert.equal(events[1].args.owned_release,true);
});
test('macOS refused mouse-down cannot acquire release ownership', async t => {
const { backend, calls } = stubBackend(t, request => request.tool==='window_at_point'
? {found:true,owner_pid:999,owner_name:'Mail'} : null);
await backend.open_application({name:'Fixture'});
await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}), /owned by Mail/);
await backend.releaseInput();
assert.ok(!calls.some(r=>['pointer_sequence','release_input'].includes(r.tool)));
});
test('macOS cancellation during the ownership probe cannot acquire release ownership', async t => {
const { backend, calls } = stubBackend(t, request => request.tool==='window_at_point'
? {nativeResult:{code:null,spawned:true,aborted:true,stdout:'',stderr:''}} : null);
await backend.open_application({name:'Fixture'});
await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}), /cancelled/);
await backend.releaseInput();
assert.ok(!calls.some(r=>['pointer_sequence','release_input'].includes(r.tool)));
});
test('macOS a single mouse-down ownership guard precedes the dispatch and ambiguous cleanup', async t => {
const { backend, calls } = stubBackend(t, request => request.tool==='pointer_sequence'
? {nativeResult:{code:null,spawned:true,aborted:true,stdout:'',stderr:''}} : null);
await backend.open_application({name:'Fixture'});
await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}), /cancelled/);
assert.equal(calls.filter(r=>r.tool==='window_at_point').length,1);
await backend.releaseInput();
assert.equal(calls.at(-1).args.point.x,70);
assert.equal(calls.at(-1).args.point.y,80);
assert.equal(calls.at(-1).tool,'release_input');
});
test('macOS coordinate left_click prefers the accessibility element under the point', async (t) => {
const { backend, calls } = stubBackend(t, (r) => (r.tool === 'hit_test' ? PRESSABLE : null));
await backend.open_application({ name: 'TextEdit' });
@@ -168,9 +385,52 @@ test('native hit_test fails closed without a bound application', { skip: process
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-hit-native-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const binary = path.join(dir, 'native');
const build = spawnSync('clang', ['-DCU_TEST=1', '-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'ApplicationServices', '-framework', 'ScreenCaptureKit', '-framework', 'AVFoundation', '-framework', 'CoreMedia', 'src/backends/darwin-accessibility.m', '-o', binary], { encoding: 'utf8' });
const build = spawnSync('clang', ['-DCU_TEST=1', '-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'ApplicationServices', '-framework', 'ScreenCaptureKit', '-framework', 'AVFoundation', '-framework', 'CoreMedia', '-framework', 'Vision', 'src/backends/darwin-accessibility.m', '-o', binary], { encoding: 'utf8' });
assert.equal(build.status, 0, build.stderr);
const r = spawnSync(binary, [JSON.stringify({ tool: 'hit_test', args: { x: 1, y: 1, perform: true } })], { encoding: 'utf8' });
assert.equal(r.status, 1);
assert.match(r.stderr, /open_application first/);
});
test('native type verifies delivery against the focused control and fails closed on a non-text focus', { skip: process.platform !== 'darwin' }, (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-type-native-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const binary = path.join(dir, 'native');
const build = spawnSync('clang', ['-DCU_TEST=1', '-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'ApplicationServices', '-framework', 'ScreenCaptureKit', '-framework', 'AVFoundation', '-framework', 'CoreMedia', '-framework', 'Vision', 'src/backends/darwin-accessibility.m', '-o', binary], { encoding: 'utf8' });
assert.equal(build.status, 0, build.stderr);
const type = (args) => spawnSync(binary, [JSON.stringify({ tool: 'inspect_type', args })], { encoding: 'utf8' });
let r = type({ text: 'world', focused: { AXRole: 'AXTextField', AXValue: 'Hello ', after: 'Hello world' } });
assert.equal(r.status, 0, r.stderr);
let receipt = JSON.parse(r.stdout);
assert.equal(receipt.action_sent, true);
assert.equal(receipt.chars, 5);
assert.equal(receipt.strategy, 'unicode-events');
assert.equal(receipt.verified, true, 'suffix match confirms delivery');
assert.equal(receipt.focused_role, 'AXTextField');
assert.ok(!('verification_required' in receipt));
r = type({ text: 'their', focused: { AXRole: 'AXTextArea', AXValue: 'I love ', after: 'I love thier' } });
assert.equal(JSON.parse(r.stdout).verified, true, 'the length check survives an autocorrect transform');
r = type({ text: 'x', focused: { AXRole: 'AXButton', AXTitle: 'Save' } });
assert.equal(r.status, 1, 'a clearly non-text focus refuses before any event is posted');
assert.match(r.stderr, /focused element is a AXButton, not a text control/);
for (const focused of [null, { AXRole: 'AXWebArea' }, { AXRole: 'AXSecureTextField' }, { AXRole: 'AXTextField', AXValue: 'abc' }]) {
r = type({ text: 'hi', focused });
assert.equal(r.status, 0, r.stderr);
receipt = JSON.parse(r.stdout);
assert.equal(receipt.action_sent, true, 'unverifiable readbacks still report dispatch');
assert.equal(receipt.verified, false);
assert.equal(receipt.verification_required, 'screenshot');
}
assert.equal(JSON.parse(type({ text: 'hi', focused: null }).stdout).focused_role, null);
});
test('macOS type passes the native verification receipt through untouched', async (t) => {
const nativeReceipt = { action_sent: true, chars: 5, strategy: 'unicode-events', keyboard_delivery: 'process', verified: false, focused_role: null, verification_required: 'screenshot' };
const { backend } = stubBackend(t, (r) => (r.tool === 'type' ? nativeReceipt : null));
await backend.open_application({ name: 'TextEdit' });
assert.deepEqual(await backend.type({ text: 'hello' }), nativeReceipt);
});
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { run, runOk, ExecError, have, trim } from "../src/exec.mjs";
import { run, runOk, runInputLease, ExecError, have, trim, withSignal } from "../src/exec.mjs";
import { safeRemotePath, b64, localExec, hdcExec } from "../src/transport.mjs";
test("run captures stdout/stderr and exit codes without a shell", async () => {
@@ -34,6 +34,59 @@ test("run enforces timeouts", async () => {
assert.equal(r.timedOut, true);
});
test("run distinguishes an early cancellation from a child that was dispatched", async () => {
const early = await withSignal(AbortSignal.abort(), () => run(process.execPath, ["-e", "process.exit(9)"]));
assert.equal(early.aborted, true);
assert.equal(early.spawned, false);
const dispatched = await run(process.execPath, ["-e", "setInterval(()=>{}, 1000)"], { timeoutMs: 50 });
assert.equal(dispatched.timedOut, true);
assert.equal(dispatched.spawned, true);
});
test("cancelling a later pointer command closes its original input owner promptly", async t => {
const dir=fs.mkdtempSync(path.join(os.tmpdir(),"cu-lease-cancel-"));
t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));
const released=path.join(dir,"released");
const lease=await runInputLease(process.execPath,["-e",`
const fs=require('node:fs');
console.log(JSON.stringify({action_sent:true,input_lease:true}));
process.stdin.resume();
process.stdin.on('data',()=>{});
process.stdin.on('end',()=>{fs.writeFileSync(process.argv[1],'released');process.exit(0);});
`,released]);
const controller=new AbortController();
const started=Date.now();
const motion=withSignal(controller.signal,()=>lease.send({point:{x:12,y:34}}));
setTimeout(()=>controller.abort(),50);
await assert.rejects(motion,error=>error.code==='cancelled');
assert.equal(fs.readFileSync(released,'utf8'),'released');
assert.ok(Date.now()-started<1500,'later request cancellation must not wait for the 20-second motion timeout');
await lease.release();
});
test("an exited input owner rejects later movement immediately", async () => {
const lease=await runInputLease(process.execPath,["-e",`
console.log(JSON.stringify({action_sent:true,input_lease:true}));
setTimeout(()=>process.kill(process.pid,'SIGTERM'),20);
`]);
await new Promise(resolve=>setTimeout(resolve,100));
const started=Date.now();
await assert.rejects(lease.send({point:{x:1,y:2}}),/owner is closed/);
assert.ok(Date.now()-started<500);
});
test("an unresponsive input helper is force-terminated within the MCP cleanup budget", async () => {
const lease=await runInputLease(process.execPath,["-e",`
process.on('SIGTERM',()=>{}); process.stdin.resume();
process.stdin.on('data',()=>{}); process.stdin.on('end',()=>{});
console.log(JSON.stringify({action_sent:true,input_lease:true}));
setInterval(()=>{},1000);
`]);
const started=Date.now();
await assert.rejects(lease.release(),error=>error.code==='input_release_failed' && error.result.signal==='SIGKILL');
assert.ok(Date.now()-started<2500);
});
test("have() detects real and missing tools", async () => {
assert.equal(await have("node"), true);
assert.equal(await have("definitely-not-a-real-tool-xyz"), false);
@@ -46,6 +99,14 @@ test("safeRemotePath blocks traversal, metacharacters, and absolute escapes", ()
}
});
test("one-shot SSH agent refuses operations that outlive its request", async () => {
for(const tool of ['left_mouse_down','recordingStart']) {
const result=await run(process.execPath,['agent.mjs',b64({tool,args:{target:{x:1,y:2}}})]);
assert.equal(result.code,0);
assert.equal(JSON.parse(result.stdout).error.code,'persistent_session_required');
}
});
test("b64 round-trips JSON payloads", () => {
const obj = { tool: "screenshot", args: { region: [0, 0, 10, 10] } };
assert.deepEqual(JSON.parse(Buffer.from(b64(obj), "base64").toString("utf8")), obj);
@@ -17,6 +17,13 @@ const PNG_1X1 = Buffer.from(
const ELEMENTS = [
{ index: 0, path: [0], windowIndex: 0, role: "AXWindow", label: "Main", position: { x: 0, y: 0 }, size: { w: 400, h: 300 } },
{ index: 1, path: [0, 1], windowIndex: 0, role: "AXButton", label: "OK", position: { x: 10, y: 20 }, size: { w: 60, h: 30 } },
{ index: 2, path: [], windowIndex: -1, role: "AXMenuBar", actions: [] },
{ index: 3, path: [0], windowIndex: -1, role: "AXMenuBarItem", label: "File", actions: ["AXPress"] },
{ index: 4, path: [0, 0], windowIndex: -1, role: "AXMenu", actions: [] },
{ index: 5, path: [0, 0, 0], windowIndex: -1, role: "AXMenuItem", label: "Save", actions: ["AXPress"] },
{ index: 6, path: [0], windowIndex: -2, role: "AXMenu", actions: [] },
{ index: 7, path: [0, 0], windowIndex: -2, role: "AXMenuItem", label: "Choose", actions: ["AXPress"] },
{ index: 8, path: [0, 2], windowIndex: 0, role: "AXTextField", value: "Fixture text", focused: true, enabled: true, actions: ["AXConfirm"], position: { x: 10, y: 60 }, size: { w: 150, h: 25 } },
];
function tmpPng(prefix) {
@@ -46,9 +53,13 @@ export function create() {
record("zoom", { region });
return { file: tmpPng("cu-fake-zoom-"), region };
},
async get_app_state({ app_ref } = {}) {
record("get_app_state", { app_ref });
return { found: true, name: app_ref?.name ?? "FakeApp", elements: ELEMENTS };
async get_app_state({ app_ref, detail, include_ocr } = {}) {
record("get_app_state", { app_ref, detail, include_ocr });
return { found: true, name: app_ref?.name ?? "FakeApp", elements: ELEMENTS, ...(include_ocr ? { ocr: {
status: app_ref?.name === "OCR unavailable" ? "unavailable" : "ok",
raster: { file: "/fixture/ocr.png", points: { x: 100, y: 50, w: 200, h: 100 }, pixels: { w: 400, h: 200 }, scale: 2 },
blocks: app_ref?.name === "OCR unavailable" ? [] : [{ text: "Raster text", confidence: 0.9, bounds: { x: 60, y: 20, w: 40, h: 40 }, target: { type: "coordinate", x: 80, y: 40 } }],
} } : {}) };
},
async resolve_element(args) {
record("resolve_element", args);
@@ -62,6 +73,7 @@ export function create() {
async double_click({ target } = {}) { record("double_click", { target }); return { action_sent: true, at: { x: target?.x, y: target?.y } }; },
async mouse_move({ target } = {}) { record("mouse_move", { target }); return { action_sent: true, at: { x: target?.x, y: target?.y } }; },
async perform_action(args) { record("perform_action", args); return { action_sent: true, strategy: "a11y" }; },
async set_value(args) { record("set_value", args); return { action_sent: true, strategy: "a11y" }; },
};
}
@@ -0,0 +1,49 @@
// Session-state and child-process fixture. No GUI or real input is touched.
import fs from "node:fs";
import crypto from "node:crypto";
import { runOk, withSignal } from "../../src/exec.mjs";
export function create() {
const instance = crypto.randomUUID();
const log = process.env.CU_SESSION_CALLS;
let appName = null;
let pointerDown = false;
const record = (method, extra = {}) => fs.appendFileSync(log, JSON.stringify({ instance, method, appName, ...extra }) + "\n");
return {
async probe() { return { ready: true }; },
async get_app_state({ app_ref }) {
appName = app_ref?.name;
record("get_app_state");
return { found: true, name: appName, elements: [] };
},
async type({ text }) {
if (!appName) throw Object.assign(new Error("Observe an app in this session first"), { code: "target_app_required" });
record("type", { text });
return { action_sent: true, appName };
},
async hold_key({ text }) {
record("hold_key", { text });
try {
await runOk(process.execPath, ["-e", `
const fs = require('node:fs');
const [file, instance, text] = process.argv.slice(1);
const record = method => fs.appendFileSync(file, JSON.stringify({ instance, method, text }) + '\\n');
record('child_started');
setTimeout(() => record('late_input'), 5000);
`, log, instance, text]);
} finally {
await withSignal(null, () => runOk(process.execPath, ["-e", `
const fs = require('node:fs');
fs.appendFileSync(process.argv[1], JSON.stringify({ instance: process.argv[2], method: 'child_released' }) + '\\n');
`, log, instance]));
}
return { action_sent: true };
},
async left_mouse_down() { pointerDown = true; record("pointer_down"); return { action_sent: true }; },
async releaseInput() {
record("release_input", { pointerDown });
pointerDown = false;
},
async closeSession() { record("session_closed"); },
};
}
@@ -0,0 +1,134 @@
// Platform command tests use injected runners, never a real Windows/Linux GUI.
import { test } from "node:test";
import assert from "node:assert/strict";
import windows from "../src/backends/win32.mjs";
import linux from "../src/backends/linux.mjs";
import { currentSignal, withSignal } from "../src/exec.mjs";
const success = { code: 0, stdout: '{"ok":true}', stderr: "", timedOut: false };
const decode = (args) => Buffer.from(args[args.indexOf("-EncodedCommand") + 1], "base64").toString("utf16le");
for (const [name, invoke, release] of [
["key hold", (backend) => backend.hold_key({ text: "ctrl+a", duration: 30 }), /KeyInput\(65, 0, 2\).*KeyInput\(17, 0, 2\)/s],
["drag", (backend) => backend.left_click_drag({ from_target: { x: 1, y: 2 }, to: { x: 30, y: 40 } }), /mouse_event\(\[User32\]::LEFTUP/],
["right click", (backend) => backend.right_click({ target: { x: 1, y: 2 } }), /mouse_event\(\[User32\]::RIGHTUP/],
]) {
test(`Windows ${name} cancellation releases the owned input in an uncancelled process`, async () => {
const controller = new AbortController();
const calls = [];
const backend = windows.create({ exec: { persistentInputOwner: true, run: async (_cmd, args) => {
calls.push({ script: decode(args), signal: currentSignal() });
if (calls.length === 1) {
controller.abort();
return { ...success, code: null, aborted: true };
}
return success;
} } });
await assert.rejects(withSignal(controller.signal, () => invoke(backend)), (err) => err.code === "cancelled");
assert.equal(calls.length, 2);
assert.equal(calls[1].signal, null, "cleanup must survive the cancelled request");
assert.match(calls[1].script, release);
await backend.releaseInput();
assert.equal(calls.length, 2, "released input is no longer owned");
});
}
test("Windows session cleanup releases a completed mouse-down without releasing another session", async () => {
const calls = [];
const create = () => windows.create({ exec: { persistentInputOwner: true, run: async (_cmd, args) => { calls.push(decode(args)); return success; } } });
const owner = create();
const other = create();
await owner.left_mouse_down({ target: { x: 1, y: 2 } });
await owner.key({ text: "a" });
await other.releaseInput();
assert.equal(calls.length, 2, "a normal key and another session's cleanup retain the owned mouse-down");
await owner.releaseInput();
assert.equal(calls.length, 3);
assert.match(calls.at(-1), /mouse_event\(\[User32\]::LEFTUP/);
});
function fakeLinux(t, kind, onRun) {
const saved = Object.fromEntries(["DISPLAY", "WAYLAND_DISPLAY", "XDG_SESSION_TYPE"].map((key) => [key, process.env[key]]));
delete process.env.DISPLAY;
delete process.env.WAYLAND_DISPLAY;
process.env.XDG_SESSION_TYPE = kind;
process.env[kind === "x11" ? "DISPLAY" : "WAYLAND_DISPLAY"] = "test-only";
t.after(() => { for (const [key, value] of Object.entries(saved)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } });
const calls = [];
const backend = linux.create({ exec: {
persistentInputOwner: true,
have: async () => true,
run: async (cmd, args) => {
const call = { cmd, args, signal: currentSignal() };
calls.push(call);
return await onRun?.(call) ?? success;
},
} });
return { backend, calls };
}
test("X11 cancelled hold and drag release their keys/buttons with cancellation disabled", async (t) => {
let controller = new AbortController();
const { backend, calls } = fakeLinux(t, "x11", ({ args }) => {
if (["keydown", "mousedown"].includes(args[0])) controller.abort();
});
await assert.rejects(withSignal(controller.signal, () => backend.hold_key({ text: "ctrl+a", duration: 30 })), (err) => err.code === "cancelled");
assert.deepEqual(calls.at(-1).args, ["keyup", "ctrl+a"]);
assert.equal(calls.at(-1).signal, null);
controller = new AbortController();
await assert.rejects(withSignal(controller.signal, () => backend.left_click_drag({ from_target: { x: 1, y: 2 }, to: { x: 20, y: 30 } })), (err) => err.code === "cancelled");
assert.deepEqual(calls.at(-1).args, ["mouseup", "1"]);
assert.equal(calls.at(-1).signal, null);
});
test("X11 targeted mouse-down initializes input, aims, and retains release ownership", async (t) => {
const { backend, calls } = fakeLinux(t, "x11");
await backend.left_mouse_down({ target: { x: 12, y: 34 } });
assert.deepEqual(calls.slice(-2).map((call) => call.args), [["mousemove", "--sync", "12", "34"], ["mousedown", "1"]]);
await backend.releaseInput();
assert.deepEqual(calls.at(-1).args, ["mouseup", "1"]);
const count = calls.length;
await backend.releaseInput();
assert.equal(calls.length, count);
});
test("Wayland hold and repeated shortcuts use one complete temporary keyboard gesture", async (t) => {
const { backend, calls } = fakeLinux(t, "wayland");
await backend.hold_key({ text: "ctrl+left", duration: 2 });
assert.deepEqual(calls.at(-1).args, ["-M", "ctrl", "-P", "Left", "-s", "2000", "-p", "Left", "-m", "ctrl"]);
await backend.key({ text: "alt+tab", repeat: 2 });
assert.deepEqual(calls.at(-1).args, ["-M", "alt", "-P", "Tab", "-p", "Tab", "-P", "Tab", "-p", "Tab", "-m", "alt"]);
assert.equal(calls.filter((call) => call.cmd === "wtype").length, 2);
assert.ok(!calls.some((call) => call.cmd === "ydotool"));
});
test("Wayland child cancellation is reported as cancelled instead of success", async (t) => {
const controller = new AbortController();
const { backend } = fakeLinux(t, "wayland", ({ cmd }) => {
if (cmd === "wtype") { controller.abort(); return { ...success, code: null, aborted: true }; }
});
await assert.rejects(withSignal(controller.signal, () => backend.hold_key({ text: "shift", duration: 30 })), (err) => err.code === "cancelled");
});
for (const [name, platform] of [["Windows", windows], ["Linux", linux]]) {
test(`${name} refuses recording before spawning an unowned recorder`, async () => {
const calls = [];
const backend = platform.create({ exec: { persistentInputOwner: true, run: async (...args) => { calls.push(args); return success; } } });
await assert.rejects(backend.recordingStart({ fps: 15 }), (err) => err.code === "owned_recording_unavailable");
assert.deepEqual(calls, []);
assert.deepEqual(await backend.recordingStatus({ id: "not-started" }), { id: "not-started", running: false });
});
test(`${name} direct mode refuses held gestures before moving or pressing`, async () => {
const calls = [];
const backend = platform.create({ exec: { run: async (...args) => { calls.push(args); return success; } } });
for (const [tool, args] of [
["left_mouse_down", { target: { x: 1, y: 2 } }],
["left_click_drag", { from_target: { x: 1, y: 2 }, to: { x: 3, y: 4 } }],
["hold_key", { text: "shift", duration: 1 }],
]) await assert.rejects(backend[tool](args), (err) => err.code === "input_owner_required");
await assert.rejects(backend.left_mouse_up(), (err) => err.code === "input_not_held");
await backend.releaseInput();
assert.deepEqual(calls, [], "no command or unrelated release is sent without an input owner");
});
}
@@ -95,6 +95,53 @@ after(() => {
for (const d of [stateDir, recDir, path.dirname(callsFile)]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
});
test("summary preserves readable UI and original target indices while full retains tree structure", async () => {
for (const detail of [undefined, "summary", "compact"]) {
const state = await tool("get_app_state", { detail });
assert.equal(state.detail, "summary");
assert.deepEqual(state.elements.map(e => e.index), [0, 1, 2, 3, 6, 7, 8]);
assert.ok(state.elements.every(e => !("path" in e) && !("windowIndex" in e)));
const field = state.elements.find(e => e.index === 8);
assert.deepEqual(field, { index: 8, role: "AXTextField", value: "Fixture text", focused: true, enabled: true, actions: ["AXConfirm"], position: { x: 10, y: 60 }, size: { w: 150, h: 25 } });
const action = await tool("perform_action", { target: { type: "element", state_id: state.state_id, index: 1 }, action: "AXPress" });
assert.equal(action.ok, true, JSON.stringify(action));
assert.deepEqual(calls("perform_action").at(-1).args.target.path, [0, 1]);
assert.equal(calls("perform_action").at(-1).args.target.windowIndex, 0);
}
const full = await tool("get_app_state", { detail: "full" });
assert.equal(full.elements.length, 9);
assert.deepEqual(full.elements.find(e => e.label === "Save").path, [0, 0, 0]);
assert.equal(full.elements.find(e => e.label === "Save").windowIndex, -1);
const summary = await tool("get_app_state", {});
setControl({ found: true, element: { role: "AXTextField", position: { x: 10, y: 60 }, size: { w: 150, h: 25 } } });
try {
const changed = await tool("set_value", { target: { type: "element", state_id: summary.state_id, index: 8 }, value: "Changed" });
assert.equal(changed.ok, true, JSON.stringify(changed));
assert.deepEqual(calls("set_value").at(-1).args.target.path, [0, 2], "sparse public index still addresses the original cached text field");
} finally { setControl(null); }
assert.equal((await tool("get_app_state", { detail: "guess" })).error.code, "bad_args");
assert.equal((await tool("get_app_state", { window_id: -1 })).error.code, "bad_args");
assert.equal((await tool("get_app_state", { window_id: 0.5 })).error.code, "bad_args");
assert.equal((await tool("get_app_state", { include_ocr: "yes" })).error.code, "bad_args");
});
test("optional OCR binds its exact raster for coordinate actions while preserving AX state", async () => {
const state = await tool("get_app_state", { include_ocr: true });
assert.equal(state.ok, true);
assert.equal(state.ocr.status, "ok");
assert.ok(state.elements.some(e => e.index === 8 && e.value === "Fixture text"));
assert.equal(state.ocr.blocks[0].role, undefined, "recognized text is not a fabricated semantic element");
const clicked = await tool("left_click", { target: state.ocr.blocks[0].target });
assert.equal(clicked.ok, true);
assert.deepEqual(calls("left_click").at(-1).args.target, { type: "coordinate", x: 140, y: 70, strategy: "event" });
assert.equal((await tool("left_click", { target: { type: "coordinate", x: 400, y: 0 } })).error.code, "target_outside_raster");
const unavailable = await tool("get_app_state", { include_ocr: true, app_ref: { name: "OCR unavailable" } });
assert.equal(unavailable.ok, true);
assert.equal(unavailable.ocr.status, "unavailable");
assert.ok(unavailable.elements.length > 0);
assert.equal((await tool("get_app_state", {})).ocr, undefined, "normal observations do not request OCR");
});
test("coordinate targets map raster pixels through the bound scale", async () => {
const shot = await tool("screenshot");
assert.equal(shot.ok, true);
@@ -112,6 +112,10 @@ test("out-of-process coordinate actions keep the raster refusals", async () => {
test("element targets travel as a center for pointer tools and an AX path for semantic ones", async () => {
const state = await tool("get_app_state", {});
assert.equal(state.ok, true);
assert.equal(state.detail, "summary");
assert.ok(state.elements.every(e => !("path" in e) && !("windowIndex" in e)));
assert.ok(!state.elements.some(e => e.label === "Save"));
assert.equal(state.elements.find(e => e.index === 8).value, "Fixture text");
const target = { type: "element", state_id: state.state_id, index: 1 };
const click = await tool("left_click", { target });
@@ -124,4 +128,18 @@ test("element targets travel as a center for pointer tools and an AX path for se
const semantic = calls().filter((c) => c.method === "perform_action").at(-1);
assert.deepEqual(semantic.args.target.path, [0, 1], "semantic actions address the element, not a point");
assert.equal(semantic.args.target.windowIndex, 0);
const full = await tool("get_app_state", { detail: "full" });
assert.equal(full.elements.length, 9);
assert.deepEqual(full.elements.find(e => e.label === "Save").path, [0, 0, 0]);
});
test("out-of-process OCR observation binds the raster that its text targets use", async () => {
const state = await tool("get_app_state", { include_ocr: true });
assert.equal(state.ocr.status, "ok");
const clicked = await tool("left_click", { target: state.ocr.blocks[0].target });
assert.equal(clicked.ok, true);
const target = calls().filter(c => c.method === "left_click").at(-1).args.target;
assert.deepEqual({ x: target.x, y: target.y }, { x: 140, y: 70 });
assert.equal((await tool("left_click", { target: { type: "coordinate", x: 400, y: 0 } })).error.code, "target_outside_raster");
});
@@ -0,0 +1,266 @@
// Exercise the actual helper socket and MCP lifecycle with recording backends.
// Child-process cancellation must prevent delayed input, not just hide replies.
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-session-"));
const log = path.join(dir, "calls.jsonl");
const env = {
...process.env,
CODEWHALE_CU_STATE_DIR: dir,
CODEWHALE_CU_APP_WARM: "off",
CODEWHALE_CU_TEST_BACKEND: path.join(ROOT, "tests/fixtures/session-backend.mjs"),
CU_SESSION_CALLS: log,
};
delete env.CODEWHALE_CU_APP;
delete env.CODEWHALE_CU_APP_SOCKET;
delete env.CODEWHALE_CU_TEST_REMOTE;
process.env.CODEWHALE_CU_STATE_DIR = dir;
delete process.env.CODEWHALE_CU_APP_SOCKET;
const { appRequest, appSessionRequest, openAppSession, hello } = await import("../src/app-socket.mjs");
const { appExec } = await import("../src/transport.mjs");
let daemon;
let daemonErrors = "";
const hosts = new Set();
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const calls = () => fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map(JSON.parse) : [];
async function until(check, timeoutMs = 3000) {
const end = Date.now() + timeoutMs;
do {
if (await check()) return;
await pause(20);
} while (Date.now() < end);
assert.fail(`Condition did not become true within ${timeoutMs}ms`);
}
function mcp() {
const child = spawn(process.execPath, [path.join(ROOT, "mcp/server.mjs")], { env, stdio: ["pipe", "pipe", "pipe"] });
hosts.add(child);
const replies = new Map();
let buf = "";
let id = 0;
child.stderr.on("data", () => {});
child.stdout.on("data", (chunk) => {
buf += chunk.toString();
let nl;
while ((nl = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
const msg = JSON.parse(line);
replies.set(msg.id, msg);
}
});
const send = (method, params, requestId) => child.stdin.write(JSON.stringify({ jsonrpc: "2.0", ...(requestId === undefined ? {} : { id: requestId }), method, params }) + "\n");
const start = (name, args = {}) => { const requestId = ++id; send("tools/call", { name, arguments: args }, requestId); return requestId; };
const response = async (requestId) => {
await until(() => replies.has(requestId));
const msg = replies.get(requestId);
assert.ok(msg.result, JSON.stringify(msg.error));
return JSON.parse(msg.result.content[0].text);
};
return { child, replies, start, response, cancel: (requestId) => send("notifications/cancelled", { requestId }), tool: (name, args) => response(start(name, args)) };
}
async function closeHost(host) {
const exit = new Promise((resolve) => host.child.once("exit", resolve));
host.child.stdin.end();
await exit;
hosts.delete(host.child);
}
before(async () => {
daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env, stdio: ["ignore", "ignore", "pipe"] });
daemon.stderr.on("data", (data) => { daemonErrors += data; });
await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); });
});
after(async () => {
for (const child of hosts) child.kill("SIGTERM");
if (daemon?.exitCode === null) {
const exit = new Promise((resolve) => daemon.once("exit", resolve));
daemon.kill("SIGTERM");
await exit;
}
fs.rmSync(dir, { recursive: true, force: true });
});
test("helper requires session identities while compatibility probes remain available", async () => {
assert.equal((await hello()).sessionProtocol, 2);
assert.equal((await appRequest({ tool: "platform" })).ok, true);
for (const sessionId of [undefined, "", "bad:session", "x".repeat(129)]) {
const reply = await appRequest({ tool: "type", args: { text: "must not type" }, sessionId });
assert.equal(reply.error.code, "session_required");
}
});
test("helper accepts actions only while their original socket owner is alive", async () => {
const sessionId = "lease-owner";
const lease = await openAppSession(sessionId);
assert.equal((await appRequest({ tool: "open_session", sessionId })).error.code, "session_owned");
for (const leaseToken of [undefined, "another-owner"]) {
assert.equal((await appRequest({ tool: "get_app_state", sessionId, leaseToken, args: { app_ref: { name: "Spoofed owner" } } })).error.code, "session_owner_required");
}
assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Lease owner" } } })).ok, true);
lease.socket.destroy();
await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Lease owner"));
assert.equal((await appRequest({ tool: "type", sessionId, leaseToken: lease.token, args: { text: "stale lease" } })).error.code, "session_owner_required");
assert.equal((await appSessionRequest({ tool: "probe", sessionId })).ok, true, "a dropped owner socket re-leases transparently instead of bricking the session");
assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Re-leased owner" } } })).data.name, "Re-leased owner");
assert.ok(!calls().some((item) => item.appName === "Spoofed owner" || item.text === "stale lease"));
});
test("separate sessions keep their own bound apps and closed sessions cannot revive", async () => {
const a = appExec({}, "binding-a");
const b = appExec({}, "binding-b");
await a.remote({ tool: "get_app_state", args: { app_ref: { name: "Editor A" } } });
assert.equal((await b.remote({ tool: "type", args: { text: "unbound" } })).error.code, "target_app_required");
await b.remote({ tool: "get_app_state", args: { app_ref: { name: "Editor B" } } });
assert.equal((await a.remote({ tool: "type", args: { text: "first" } })).data.appName, "Editor A");
assert.equal((await b.remote({ tool: "type", args: { text: "second" } })).data.appName, "Editor B");
assert.equal((await appSessionRequest({ tool: "close_session", sessionId: "binding-a" })).ok, true);
await appExec({}, "unrelated-new-session").remote({ tool: "probe" });
await assert.rejects(a.remote({ tool: "get_app_state", args: { app_ref: { name: "Revived" } } }), (err) => err.code === "app_session_closed");
assert.equal((await b.remote({ tool: "type", args: { text: "still alive" } })).ok, true);
});
test("disconnect cancels the child process and a queued request never posts input", async () => {
const active = new AbortController();
const queued = new AbortController();
const held = appSessionRequest({ tool: "hold_key", sessionId: "socket-active", args: { text: "socket-cancel" } }, { signal: active.signal });
const heldRejection = assert.rejects(held, (err) => err.code === "cancelled");
await until(() => calls().some((item) => item.method === "child_started" && item.text === "socket-cancel"));
const waiting = appSessionRequest({ tool: "get_app_state", sessionId: "socket-queued", args: { app_ref: { name: "Cancelled queue" } } }, { signal: queued.signal });
const queuedRejection = assert.rejects(waiting, (err) => err.code === "cancelled");
queued.abort();
active.abort();
await Promise.all([heldRejection, queuedRejection]);
await until(() => calls().some((item) => item.method === "child_released"));
assert.equal((await appExec({}, "socket-check").remote({ tool: "probe" })).ok, true);
assert.ok(!calls().some((item) => item.appName === "Cancelled queue"));
assert.ok(!calls().some((item) => item.method === "late_input"));
});
test("MCP cancellation drains input, keeps the host alive, and isolates a second host", async () => {
const a = mcp();
const b = mcp();
await a.tool("get_app_state", { app_ref: { name: "Host A" } });
assert.equal((await b.tool("type", { text: "unbound host B" })).error.code, "target_app_required");
await b.tool("get_app_state", { app_ref: { name: "Host B" } });
const id = a.start("hold_key", { text: "mcp-cancel", duration: 10 });
await until(() => calls().some((item) => item.method === "child_started" && item.text === "mcp-cancel"));
a.cancel(id);
assert.equal((await a.tool("type", { text: "after cancellation" })).ok, true);
assert.ok(!a.replies.has(id), "cancelled MCP request must not reply");
assert.equal((await b.tool("type", { text: "other host" })).appName, "Host B");
await closeHost(a);
assert.equal((await b.tool("type", { text: "after other host exits" })).ok, true);
await closeHost(b);
});
test("stop cancels active and queued actions, releases held input, and leaves probes usable", async () => {
const host = mcp();
await host.tool("get_app_state", { app_ref: { name: "Stopped host" } });
await host.tool("left_mouse_down", { target: { x: 10, y: 10 } });
const hold = host.start("hold_key", { text: "mcp-stop", duration: 10 });
await until(() => calls().some((item) => item.method === "child_started" && item.text === "mcp-stop"));
const queued = host.start("type", { text: "must never arrive after stop" });
const stopped = await host.tool("stop_computer_control");
assert.equal(stopped.ok, true, JSON.stringify(stopped));
assert.equal(stopped.inputReleased, true);
assert.equal((await host.response(queued)).error.code, "control_stopped");
assert.equal((await host.response(hold)).ok, false);
assert.ok(calls().some((item) => item.method === "release_input" && item.appName === "Stopped host" && item.pointerDown));
assert.ok(!calls().some((item) => item.text === "must never arrive after stop"));
assert.equal((await host.tool("request_access")).ok, true);
assert.equal((await host.tool("type", { text: "after stop" })).error.code, "control_stopped");
assert.ok(!calls().some((item)=>item.method==="session_closed"&&item.appName==="Stopped host"),"stop releases input but does not close the session's other owned resources");
await closeHost(host);
assert.ok(calls().some((item)=>item.method==="session_closed"&&item.appName==="Stopped host"));
});
test("another MCP host cannot redirect the selected computer", async () => {
const a = mcp();
const b = mcp();
await a.tool("get_app_state", { app_ref: { name: "Local host A" } });
await b.tool("computer_register", { computer: "session-test-pad", transport: "hdc" });
await b.tool("computer_switch", { computer: "session-test-pad" });
assert.equal((await a.tool("computer_list")).active, "local");
assert.equal((await b.tool("computer_list")).active, "session-test-pad");
const input = await a.tool("type", { text: "stay on local host A" });
assert.equal(input.ok, true, JSON.stringify(input));
assert.equal(input.computer.id, "local");
await b.tool("computer_remove", { computer: "session-test-pad" });
await closeHost(a);
await closeHost(b);
});
test("MCP forced exit releases idle held input without waiting for another client", async () => {
const dead = mcp();
const survivor = mcp();
await dead.tool("get_app_state", { app_ref: { name: "Killed idle host" } });
await survivor.tool("get_app_state", { app_ref: { name: "Surviving host" } });
await dead.tool("left_mouse_down", { target: { x: 10, y: 10 } });
assert.equal((await survivor.tool("type", {text:"must wait for held pointer"})).error.code,"input_busy");
assert.equal((await survivor.tool("request_access")).ok,true,"observation stays available while another session holds input");
const exit = new Promise((resolve) => dead.child.once("exit", resolve));
dead.child.kill("SIGKILL");
await exit;
hosts.delete(dead.child);
await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Killed idle host" && item.pointerDown));
assert.equal((await survivor.tool("type", { text: "surviving binding" })).appName, "Surviving host");
await closeHost(survivor);
});
test("MCP forced exit cancels its active child before delayed input can post", async () => {
const host = mcp();
await host.tool("get_app_state", { app_ref: { name: "Killed active host" } });
host.start("hold_key", { text: "killed-active", duration: 10 });
await until(() => calls().some((item) => item.method === "child_started" && item.text === "killed-active"));
const instance = calls().find((item) => item.method === "child_started" && item.text === "killed-active").instance;
const exit = new Promise((resolve) => host.child.once("exit", resolve));
host.child.kill("SIGKILL");
await exit;
hosts.delete(host.child);
await until(() => calls().some((item) => item.method === "child_released" && item.instance === instance));
await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Killed active host"));
assert.ok(!calls().some((item) => item.method === "late_input"));
});
test("an app update re-leases live sessions transparently; an absent app still fails without bricking", async () => {
const sessionId = "upgrade-survivor";
assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor" } } })).ok, true);
const exit = new Promise((resolve) => daemon.once("exit", resolve));
daemon.kill("SIGTERM");
await exit;
// Mid-update the app is genuinely absent: the request fails, and that
// failure is not cached against the session.
await assert.rejects(appSessionRequest({ tool: "probe", sessionId }), (err) => err.code === "app_unavailable");
daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env, stdio: ["ignore", "ignore", "pipe"] });
daemon.stderr.on("data", (data) => { daemonErrors += data; });
await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); });
const reply = await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor again" } } });
assert.equal(reply.ok, true, JSON.stringify(reply));
assert.equal(reply.data.name, "Survivor again", "the same session id works on the replacement daemon without a host reload");
});
test("MCP EOF releases a completed mouse-down and helper shutdown aborts active children", async () => {
const host = mcp();
await host.tool("get_app_state", { app_ref: { name: "Disconnected host" } });
await host.tool("left_mouse_down", { target: { x: 10, y: 10 } });
await closeHost(host);
assert.ok(calls().some((item) => item.method === "release_input" && item.appName === "Disconnected host" && item.pointerDown));
const held = appSessionRequest({ tool: "hold_key", sessionId: "helper-exit", args: { text: "helper-exit" } });
const result = held.catch((err) => ({ error: err.code }));
await until(() => calls().some((item) => item.method === "child_started" && item.text === "helper-exit"));
const instance = calls().find((item) => item.method === "child_started" && item.text === "helper-exit").instance;
const exit = new Promise((resolve) => daemon.once("exit", resolve));
daemon.kill("SIGTERM");
assert.equal(await exit, 0, daemonErrors);
await result;
assert.ok(calls().some((item) => item.method === "child_released" && item.instance === instance));
assert.ok(!calls().some((item) => item.method === "late_input"));
});
@@ -77,7 +77,7 @@ function fakePowershell(t, { exit = 0, stdout = "", stderr = "", onPath = true }
test("win32: targeted left_mouse_down both moves and presses in one self-contained command", async (t) => {
const fake = fakePowershell(t);
const backend = win32.create();
const backend = win32.create({ exec: { persistentInputOwner: true } });
const r = await backend.left_mouse_down({ target: { x: 12, y: 34 } });
assert.deepEqual(r, { action_sent: true });
const calls = fake.calls();
@@ -92,7 +92,7 @@ test("win32: targeted left_mouse_down both moves and presses in one self-contain
test("win32: PowerShell exit != 0 becomes an error, never action_sent:true", async (t) => {
fakePowershell(t, { exit: 1, stderr: "unable to find type [User32]" });
const backend = win32.create();
const backend = win32.create({ exec: { persistentInputOwner: true } });
const cases = [
["left_mouse_down", () => backend.left_mouse_down({ target: { x: 1, y: 2 } })],
["mouse_move", () => backend.mouse_move({ target: { x: 1, y: 2 } })],
@@ -110,7 +110,7 @@ test("win32: PowerShell exit != 0 becomes an error, never action_sent:true", asy
test("win32: spawn failure (powershell.exe missing) becomes an error, never action_sent:true", async (t) => {
fakePowershell(t, { onPath: false });
const backend = win32.create();
const backend = win32.create({ exec: { persistentInputOwner: true } });
await assert.rejects(backend.left_mouse_down({ target: { x: 1, y: 2 } }), (e) => {
assert.ok(e instanceof ExecError);
assert.match(e.message, /exited -1|ENOENT/);
@@ -120,7 +120,7 @@ test("win32: spawn failure (powershell.exe missing) becomes an error, never acti
test("win32: successful input still reports success", async (t) => {
const fake = fakePowershell(t);
const backend = win32.create();
const backend = win32.create({ exec: { persistentInputOwner: true } });
assert.deepEqual(await backend.mouse_move({ target: { x: 5, y: 6 } }), { action_sent: true, at: { x: 5, y: 6 } });
const click = await backend.left_click({ target: { x: 9, y: 8 } });
assert.equal(click.action_sent, true);
@@ -132,10 +132,11 @@ test("win32: successful input still reports success", async (t) => {
test("win32: every User32-backed action carries the Add-Type definition in its own process", async (t) => {
const fake = fakePowershell(t);
const backend = win32.create();
const backend = win32.create({ exec: { persistentInputOwner: true } });
const at = { x: 3, y: 4 };
const actions = [
["mouse_move", () => backend.mouse_move({ target: at })],
["left_mouse_down", () => backend.left_mouse_down({ target: at })],
["left_mouse_up", () => backend.left_mouse_up()],
["left_click", () => backend.left_click({ target: at })],
["double_click", () => backend.double_click({ target: at })],
@@ -157,7 +158,7 @@ test("win32: every User32-backed action carries the Add-Type definition in its o
test("win32: cursor_position is self-contained and parses JSON output", async (t) => {
const fake = fakePowershell(t, { stdout: '{"x": 11, "y": 22}' });
const backend = win32.create();
const backend = win32.create({ exec: { persistentInputOwner: true } });
assert.deepEqual(await backend.cursor_position(), { x: 11, y: 22 });
const script = fake.calls()[0].script;
assert.match(script, /Add-Type -TypeDefinition/, "cursor_position carries the type definition");
@@ -27,7 +27,7 @@ function mockExec({ fail = false } = {}) {
test("win32: actions run through an injected runner (no powershell needed)", async () => {
const { run, calls } = mockExec();
const mod = await import("../src/backends/win32.mjs");
const b = mod.create({ exec: { run } });
const b = mod.create({ exec: { run, persistentInputOwner: true } });
const r = await b.left_click({ target: { x: 5, y: 6 } });
assert.equal(r.action_sent, true);
assert.ok(calls.length >= 1, "the injected runner must receive the action command");
@@ -36,7 +36,7 @@ test("win32: actions run through an injected runner (no powershell needed)", asy
test("win32: input actions fail truthfully on a nonzero exit", async () => {
const { run } = mockExec({ fail: true });
const mod = await import("../src/backends/win32.mjs");
const b = mod.create({ exec: { run } });
const b = mod.create({ exec: { run, persistentInputOwner: true } });
await assert.rejects(() => b.left_click({ target: { x: 1, y: 2 } }), /exited 1/);
await assert.rejects(() => b.left_mouse_down({ target: { x: 1, y: 2 } }), /exited 1/);
});
@@ -44,7 +44,7 @@ test("win32: input actions fail truthfully on a nonzero exit", async () => {
test("win32: coordinate clicks refuse strategy=a11y instead of silently degrading", async () => {
const { run, calls } = mockExec();
const mod = await import("../src/backends/win32.mjs");
const b = mod.create({ exec: { run } });
const b = mod.create({ exec: { run, persistentInputOwner: true } });
// left_click refuses synchronously (it is not async), like every fail-closed
// guard in this backend — see backends.test.mjs for the same convention.
assert.throws(() => b.left_click({ target: { x: 1, y: 2 }, strategy: "a11y" }), /macOS-only/);
@@ -56,7 +56,7 @@ test("win32: coordinate clicks refuse strategy=a11y instead of silently degradin
test("win32: targeted left_mouse_down both moves and presses, self-contained", async () => {
const { run, calls } = mockExec();
const mod = await import("../src/backends/win32.mjs");
const b = mod.create({ exec: { run } });
const b = mod.create({ exec: { run, persistentInputOwner: true } });
await b.left_mouse_down({ target: { x: 12, y: 34 } });
const script = calls.at(-1).script;
// Self-contained: the User32 P/Invoke type travels with the action.
@@ -68,7 +68,7 @@ test("win32: targeted left_mouse_down both moves and presses, self-contained", a
test("win32: every User32 action carries the type prelude in-process", async () => {
const { run, calls } = mockExec();
const mod = await import("../src/backends/win32.mjs");
const b = mod.create({ exec: { run } });
const b = mod.create({ exec: { run, persistentInputOwner: true } });
await b.mouse_move({ target: { x: 3, y: 4 } });
await b.key({ text: "a" });
assert.ok(calls.length >= 2, "two actions should have run through the injected runner");
+42
View File
@@ -200,6 +200,16 @@ fn is_allowed_parent_env_key(key: &OsStr) -> bool {
matches!(
normalized.as_str(),
"PATH"
// Desktop connection metadata. Computer-use tools must reach
// the existing X11/Wayland and accessibility bus sessions. Keep
// this list exact; never inherit arbitrary XDG/DBUS namespaces
// or read the Xauthority credential file into the environment.
| "DISPLAY"
| "WAYLAND_DISPLAY"
| "XDG_RUNTIME_DIR"
| "XDG_SESSION_TYPE"
| "DBUS_SESSION_BUS_ADDRESS"
| "XAUTHORITY"
| "HOME"
| "USER"
| "USERNAME"
@@ -990,6 +1000,38 @@ mod tests {
}));
}
#[test]
fn reviewed_plugin_mcp_env_keeps_desktop_routing_without_secret_namespaces() {
let desktop = [
("DISPLAY", ":1"),
("WAYLAND_DISPLAY", "wayland-0"),
("XDG_RUNTIME_DIR", "/run/user/1000"),
("XDG_SESSION_TYPE", "wayland"),
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
("XAUTHORITY", "/run/user/1000/.Xauthority"),
];
let unexpected = [
("OPENAI_API_KEY", "provider-fixture"),
("XDG_PRIVATE_TOKEN", "xdg-fixture"),
("DBUS_PRIVATE_TOKEN", "dbus-fixture"),
("CODEWHALE_CU_APP_BUNDLE", "/stale/helper.app"),
];
let child = sanitized_plugin_mcp_env_from(
desktop.into_iter().chain(unexpected),
std::iter::empty::<(&str, &str)>(),
);
for (key, value) in desktop {
assert!(
child
.iter()
.any(|(found, content)| found == key && content == value)
);
}
for (key, _) in unexpected {
assert!(child.iter().all(|(found, _)| found != key));
}
}
#[test]
fn sanitized_child_env_drops_parent_secret_like_values() {
let _guard = crate::test_support::lock_test_env();
+50 -2
View File
@@ -4381,6 +4381,8 @@ impl Engine {
// state a preview has no business writing.
if self.config.features.enabled(Feature::Mcp) && mcp_access.may_connect() {
let _ = self.ensure_mcp_pool().await;
self.wait_for_explicit_mcp_boot(allowed_tools.as_deref())
.await;
}
let builder = self
.build_turn_tool_registry_builder_for_route(
@@ -6522,9 +6524,55 @@ impl Engine {
self.drain_mcp_boot_updates().await;
}
/// Explicit MCP tool selections need their schemas on the first request.
/// Keep unrelated optional servers in the background, using the existing
/// bounded connection pass and its authority-checked progress updates.
async fn wait_for_explicit_mcp_boot(&mut self, allowed_tools: Option<&[String]>) {
let requested = self
.config
.tools_always_load
.iter()
.chain(allowed_tools.into_iter().flatten())
.map(|name| name.trim().to_ascii_lowercase())
.filter(|name| name.starts_with("mcp_"))
.collect::<Vec<_>>();
if requested.is_empty() {
return;
}
while self.mcp_boot_in_flight {
self.drain_mcp_boot_updates().await;
let Some(pool) = self.mcp_pool.as_ref() else {
break;
};
let pending =
Self::mcp_connecting_names(&*pool.lock().await, &self.mcp_connection_errors);
let needs_schema = pending.iter().any(|server| {
let prefix = format!("mcp_{}_", server.to_ascii_lowercase());
requested.iter().any(|name| {
name.starts_with(&prefix)
|| name
.strip_suffix('*')
.is_some_and(|rule| prefix.starts_with(rule))
})
});
if !needs_schema {
break;
}
let Some(rx) = self.mcp_boot_rx.as_mut() else {
break;
};
let update = tokio::select! {
_ = self.cancel_token.cancelled() => None,
update = rx.recv() => update,
};
let Some(update) = update else { break };
self.apply_mcp_boot_update(update).await;
}
}
/// Start the concurrent connect pass without occupying the engine mailbox.
/// Optional servers never serialize the first model turn: `mcp_tools`
/// snapshots whatever is already ready.
/// Optional servers stay in the background unless the task explicitly
/// selects their tools; `mcp_tools` snapshots whatever is already ready.
async fn start_mcp_session_boot(&mut self) {
if !self.config.features.enabled(Feature::Mcp) {
return;
+165
View File
@@ -21088,6 +21088,171 @@ async fn mcp_boot_reports_ready_server_before_stalled_server_finishes() {
assert_incremental_mcp_boot(false).await;
}
#[tokio::test]
async fn first_turn_waits_for_explicit_mcp_schema_without_waiting_for_unrelated_server() {
let Some(node) = crate::dependencies::resolve_node() else {
return;
};
let tmp = tempdir().expect("tempdir");
let server = tmp.path().join("server.mjs");
let release = tmp.path().join("release-slow");
fs::write(&server, r#"import fs from 'node:fs';
import readline from 'node:readline';
readline.createInterface({ input: process.stdin }).on('line', async line => {
const request = JSON.parse(line);
if (request.id === undefined) return;
if (request.method === 'initialize') {
if (process.argv[2] === 'slow') while (!fs.existsSync(process.argv[3])) await new Promise(r => setTimeout(r, 10));
else await new Promise(r => setTimeout(r, 150));
}
const result = request.method === 'initialize'
? { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: process.argv[2], version: '1' } }
: { tools: ['ready', 'denied', 'hidden'].map(name => ({ name, inputSchema: { type: 'object' } })) };
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result }) + '\n');
});"#).expect("fixture");
let config_path = tmp.path().join("mcp.json");
fs::write(
&config_path,
serde_json::to_vec(&json!({
"timeouts": { "connect_timeout": 10 },
"servers": {
"fast": { "command": node, "args": [server, "fast", release] },
"slow": { "command": node, "args": [server, "slow", release] },
"failed": { "command": "codewhale-missing-mcp-fixture-38911" }
}
}))
.unwrap(),
)
.unwrap();
let api_config = Config::default();
let (mut engine, _handle) = Engine::new(
EngineConfig {
workspace: tmp.path().to_path_buf(),
mcp_config_path: config_path,
tools_always_load: HashSet::from(["mcp_fast_ready".to_string()]),
..Default::default()
},
&api_config,
);
engine.start_mcp_session_boot().await;
assert!(
engine.mcp_tools().await.is_empty(),
"ordinary startup remains nonblocking"
);
let route = TurnRouteContext {
provider: ApiProvider::Deepseek,
model: DEFAULT_TEXT_MODEL.to_string(),
capabilities: codewhale_config::route::RouteCapabilities::default(),
limits: None,
client: engine.deepseek_client.clone(),
api_config: Box::new(api_config),
locale_tag: engine.config.locale_tag.clone(),
role_models: engine.subagent_role_models(),
auto_model: false,
reasoning_effort: None,
reasoning_effort_auto: false,
};
let policy = crate::core::authority::TurnAuthority::from_effective_fields(
AppMode::Agent,
false,
false,
false,
crate::tui::approval::ApprovalMode::Suggest,
);
let build = tokio::time::timeout(
Duration::from_secs(5),
engine.build_turn_tool_registry_and_catalog(
&policy,
&[],
Some(vec![
"mcp_fast_ready".to_string(),
"mcp_failed_ready".to_string(),
]),
SubAgentWiring::Inert,
McpAccess::Connect,
route,
"",
),
)
.await;
engine.cancel_token.cancel();
tokio::time::timeout(
Duration::from_millis(100),
engine.wait_for_explicit_mcp_boot(Some(&["mcp_slow_ready".to_string()])),
)
.await
.expect("stop interrupts explicit schema wait");
engine.reset_cancel_token();
fs::write(&release, "release").unwrap();
let build = build.expect("explicit fast/failed selections must not wait for slow");
let active = build.surface.active.unwrap_or_default();
assert_eq!(
active
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>(),
["mcp_fast_ready"]
);
assert!(engine.mcp_connection_errors.contains_key("failed"));
// The unrelated connection finishes during the same turn. Refresh into a
// narrowed policy, then execute the actual tool-search activation path.
let policy = ToolSurfacePolicy::new(
ToolRegistryBuilder::new().build(ToolContext::for_empty_registry()),
Some(vec![api_tool("read")]),
AppMode::Agent,
&HashSet::new(),
&[],
false,
Some(vec![
"tool_search".into(),
"mcp_slow_ready".into(),
"mcp_slow_denied".into(),
]),
Some(vec!["mcp_slow_denied".into()]),
None,
crate::tui::approval::ApprovalMode::Suggest,
);
let mut catalog = policy.catalog.clone();
let mut active = policy.active_names.clone();
catalog.push(api_tool("mcp_removed_ready"));
active.insert("mcp_removed_ready".to_string());
tokio::time::timeout(Duration::from_secs(5), async {
while !catalog.iter().any(|tool| tool.name == "mcp_slow_ready") {
engine
.refresh_boot_mcp_catalog(&policy, &mut catalog, &mut active)
.await;
tokio::task::yield_now().await;
}
})
.await
.expect("completed tools join this turn");
assert!(
!active.contains("mcp_slow_ready"),
"fresh MCP tools stay deferred"
);
assert!(
!active.contains("mcp_removed_ready"),
"removed authority leaves active tools"
);
assert!(
catalog
.iter()
.all(|tool| !tool.name.starts_with("mcp_") || tool.name == "mcp_slow_ready")
);
let result = tool_catalog::execute_tool_search_with_cache(
"tool_search",
&json!({"query":"mcp_slow_ready", "match":"regex"}),
&catalog,
&mut active,
&mut engine.session.tool_activation_cache,
)
.expect("real search");
assert!(result.success);
assert!(active.contains("mcp_slow_ready"));
engine.wait_for_mcp_boot().await;
}
#[tokio::test]
async fn mcp_boot_does_not_restore_servers_removed_during_handshake() {
assert_incremental_mcp_boot(true).await;
+51
View File
@@ -368,6 +368,51 @@ pub(super) fn replace_runtime_mcp_tools(
}
impl Engine {
/// A connection completed during inference must be discoverable in this
/// turn, without widening its command policy or making every MCP tool eager.
pub(super) async fn refresh_boot_mcp_catalog(
&mut self,
policy: &ToolSurfacePolicy,
catalog: &mut Vec<Tool>,
active: &mut std::collections::HashSet<String>,
) {
if !self.mcp_boot_in_flight {
return;
}
self.drain_mcp_boot_updates().await;
let Some(pool) = self.mcp_pool.as_ref() else {
return;
};
let (mut universe, mut refreshed) = {
let pool = pool.lock().await;
(pool.model_tool_names(), pool.to_api_tools())
};
// A config/authority change during handshake can remove a server;
// its previous names must also leave this turn's catalog.
universe.extend(
catalog
.iter()
.filter(|tool| McpPool::is_mcp_tool(&tool.name))
.map(|tool| tool.name.clone()),
);
refreshed
.retain(|tool| policy.passes_allow_list(&tool.name) && !policy.denies_tool(&tool.name));
let before = catalog.clone();
replace_runtime_mcp_tools(
catalog,
active,
&universe,
refreshed,
self.current_mode,
&self.config.tools_always_load,
self.turn_tool_surface_budget
.unwrap_or(crate::model_profile::ToolSurfaceBudget::Standard),
);
if *catalog != before {
self.session.pending_prefix_change_reason = Some("mcp-session-boot".to_string());
}
}
pub(super) fn drain_shell_completion_events(
&self,
) -> Vec<crate::tools::shell::ShellCompletionEvent> {
@@ -733,6 +778,8 @@ impl Engine {
let _ = self.tx_event.send(Event::status("Request cancelled")).await;
return (TurnOutcomeStatus::Interrupted, None);
}
self.refresh_boot_mcp_catalog(&tool_policy, &mut tool_catalog, &mut active_tool_names)
.await;
// R1: the cumulative per-turn wall-clock budget. Checked at the
// provider-request boundary so a turn that runs out of time stops
@@ -2471,6 +2518,10 @@ impl Engine {
None
};
// Tool discovery may be the first action after a model request
// that overlapped MCP startup. Search the ready catalog now.
self.refresh_boot_mcp_catalog(&tool_policy, &mut tool_catalog, &mut active_tool_names)
.await;
let PlannedToolCalls {
plans,
hook_contexts,
+1 -2
View File
@@ -814,12 +814,11 @@ fn esm_entry_has_module_siblings(
})
}
#[cfg(target_os = "macos")]
fn is_node_command(command: &str) -> bool {
Path::new(command)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| matches!(name, "node" | "nodejs"))
.is_some_and(|name| matches!(name, "node" | "nodejs" | "node.exe" | "nodejs.exe"))
}
/// Rewrite a Node launch so a reviewed `.mjs` entrypoint keeps ESM semantics
+23 -7
View File
@@ -160,8 +160,13 @@ impl StdioTransport {
);
}
let mut child = cmd.spawn().with_context(|| {
if config.reviewed_plugin.is_some() {
let mut child = cmd.spawn().map_err(|error| {
let message = if error.kind() == std::io::ErrorKind::NotFound
&& super::is_node_command(command)
&& launch_cwd.is_none_or(|directory| directory.is_dir())
{
format!("MCP server {server_name} could not start because Node.js was not found. Install Node.js from https://nodejs.org/ and restart Codewhale with node on PATH. Built-in Computer Use requires Node.js 20 or newer.")
} else if config.reviewed_plugin.is_some() {
format!(
"MCP stdio spawn failed (transport=stdio server={server_name} reviewed-plugin argv_count={} env_count={})",
config.args.len(),
@@ -173,7 +178,8 @@ impl StdioTransport {
"MCP stdio spawn failed (transport=stdio server={server_name} cmd={command:?} args={:?} env_keys={env_keys:?})",
config.args,
)
}
};
anyhow::Error::new(error).context(message)
})?;
let stdin = child.stdin.take().context("Failed to get MCP stdin")?;
@@ -352,15 +358,25 @@ impl McpTransport for StdioTransport {
}
}
/// Drop fallback (#420): if `shutdown` was never called explicitly, still
/// fire SIGTERM before tokio's `kill_on_drop` sends SIGKILL. The two
/// signals arrive back-to-back so well-behaved servers at least see the
/// SIGTERM first; misbehaving ones get SIGKILL'd anyway.
/// Session changes can drop a pool without explicitly awaiting shutdown.
/// Keep the owned child alive for the same bounded cleanup as explicit
/// shutdown, so servers can release input and recording resources. Runtime
/// teardown still drops the cleanup future and invokes `kill_on_drop`.
impl Drop for StdioTransport {
fn drop(&mut self) {
if let Some(watch) = self.authority_cancel_watch.take() {
watch.abort();
}
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
let child = Arc::clone(&self.child);
let reviewed_launch = self._reviewed_launch.take();
runtime.spawn(async move {
let _reviewed_launch = reviewed_launch;
let mut child = child.lock().await;
terminate_child(&mut child).await;
});
return;
}
if let Ok(mut child) = self.child.try_lock()
&& !child.try_wait().is_ok_and(|status| status.is_some())
{
+94
View File
@@ -4133,6 +4133,36 @@ async fn discover_snapshot_includes_underlying_spawn_error_in_chain() {
);
}
#[tokio::test]
async fn discover_snapshot_explains_a_missing_node_runtime() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("mcp.json");
let missing_node = dir
.path()
.join(if cfg!(windows) { "node.exe" } else { "node" });
fs::write(
&config_path,
serde_json::to_vec(&serde_json::json!({
"mcpServers": { "computer": { "command": missing_node, "args": [] } }
}))
.unwrap(),
)
.unwrap();
let snapshot = discover_manager_snapshot(&config_path, None, false)
.await
.unwrap();
let error = snapshot
.servers
.iter()
.find(|server| server.name == "computer")
.unwrap()
.error
.as_ref()
.unwrap();
assert!(error.contains("Node.js 20 or newer"), "{error}");
assert!(error.contains("https://nodejs.org/"), "{error}");
}
/// The same guarantee for a server the user marked `required`. `connect_all`
/// appends a generic "required MCP server failed to initialize" entry after
/// the real per-server connect error, and every snapshot path folds the
@@ -4685,6 +4715,70 @@ async fn stdio_transport_shutdown_terminates_child() {
);
}
#[cfg(unix)]
#[tokio::test]
async fn stdio_transport_drop_allows_child_cleanup() {
let directory = tempfile::tempdir().expect("temporary cleanup receipt");
let receipt = directory.path().join("cleaned");
let config: McpServerConfig = serde_json::from_value(serde_json::json!({
"args": [
"-c",
"trap 'sleep 0.1; printf cleaned > \"$1\"; exit 0' TERM; printf 'ready\\n'; while :; do sleep 0.05; done",
"cleanup-test",
receipt.display().to_string(),
],
}))
.unwrap();
let mut transport = StdioTransport::spawn(
"drop-cleanup-test",
"/bin/sh",
&config,
tokio_util::sync::CancellationToken::new(),
)
.expect("spawn cleanup fixture");
assert_eq!(transport.recv().await.unwrap(), b"ready");
// Retaining a strong Child reference would mask immediate kill_on_drop.
drop(transport);
tokio::time::timeout(STDIO_SHUTDOWN_GRACE + Duration::from_secs(1), async {
while !receipt.exists() {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("dropped transport lets SIGTERM cleanup finish");
assert_eq!(std::fs::read_to_string(receipt).unwrap(), "cleaned");
}
#[cfg(unix)]
#[tokio::test]
async fn stdio_transport_drop_kills_child_that_ignores_cleanup() {
let config: McpServerConfig = serde_json::from_value(serde_json::json!({
"args": [
"-c",
"trap '' TERM; printf 'ready\\n'; while :; do sleep 0.05; done",
],
}))
.unwrap();
let mut transport = StdioTransport::spawn(
"drop-hung-test",
"/bin/sh",
&config,
tokio_util::sync::CancellationToken::new(),
)
.expect("spawn unresponsive fixture");
assert_eq!(transport.recv().await.unwrap(), b"ready");
let pid = transport.child.lock().await.id().expect("live child");
drop(transport);
tokio::time::timeout(STDIO_SHUTDOWN_GRACE + Duration::from_secs(1), async {
// Signal zero only observes the process; the owned Child sends kills.
while unsafe { libc::kill(pid as i32, 0) } == 0 {
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("dropped transport force-kills and reaps hung child");
}
/// Mid-run MCP server crash: the v0.8.x spawn path used `Stdio::null` for
/// stderr, so a server that died with a useful stderr message left the
/// caller with only "Stdio transport closed". Now stderr is piped into a
+66 -7
View File
@@ -8,11 +8,15 @@
//! path — installed bundles still arrive through
//! [`super::install`], and discovery, trust, and enablement are unchanged.
//!
//! The bundle is embedded with `include_str!` (the same way locale packs and
//! The bundle is embedded with `include_bytes!` (the same way locale packs and
//! the mobile client are embedded) and written under
//! `$CODEWHALE_HOME/builtin-plugins` on first run, so one binary carries it to
//! every distribution channel — npm, tarball, `cargo install`, brew — without
//! any of them learning about plugin files.
//! macOS builds also carry the native helper built from the vendored sources,
//! so operating the computer never requires a compiler or a separate app
//! installation. The helper targets macOS 13+; OS permissions are still user
//! controlled, and the MCP server uses the host's Node.js runtime.
//!
//! Two properties this must not lose:
//!
@@ -47,7 +51,7 @@ macro_rules! bundle_file {
($relative:literal) => {
(
$relative,
include_str!(concat!("../../plugins/computer-use/", $relative)),
include_bytes!(concat!("../../plugins/computer-use/", $relative)),
)
};
}
@@ -58,13 +62,15 @@ macro_rules! bundle_file {
/// `README.md`) are deliberately absent: nothing at runtime reads them, and
/// the `.mjs` extension already makes every module ESM without a
/// `"type": "module"` declaration.
const COMPUTER_USE_FILES: &[(&str, &str)] = &[
const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
bundle_file!("LICENSE"),
bundle_file!("plugin.json"),
bundle_file!("mcp.json"),
bundle_file!("commands/computer.md"),
bundle_file!("skills/computer-use/SKILL.md"),
bundle_file!("skills/recording/SKILL.md"),
bundle_file!("agent.mjs"),
bundle_file!("app/daemon.mjs"),
bundle_file!("mcp/server.mjs"),
bundle_file!("src/app-handler.mjs"),
bundle_file!("src/app-socket.mjs"),
@@ -77,20 +83,26 @@ const COMPUTER_USE_FILES: &[(&str, &str)] = &[
bundle_file!("src/backends/darwin.mjs"),
bundle_file!("src/backends/darwin-accessibility.m"),
bundle_file!("src/backends/darwin-recording.h"),
bundle_file!("src/backends/darwin-ocr.h"),
bundle_file!("src/backends/harmonyos.mjs"),
bundle_file!("src/backends/linux.mjs"),
bundle_file!("src/backends/win32.mjs"),
#[cfg(target_os = "macos")]
(
"bin/darwin/accessibility",
include_bytes!(concat!(env!("OUT_DIR"), "/computer-use-accessibility")),
),
];
/// Digest of one bundle's entire contents, including its file names, so a
/// renamed or removed file is as much a change as an edited one.
fn digest(files: &[(&str, &str)]) -> String {
fn digest(files: &[(&str, &[u8])]) -> String {
let mut hasher = Sha256::new();
for (relative, contents) in files {
hasher.update((relative.len() as u64).to_le_bytes());
hasher.update(relative.as_bytes());
hasher.update((contents.len() as u64).to_le_bytes());
hasher.update(contents.as_bytes());
hasher.update(contents);
}
super::manifest::hex_digest(hasher.finalize())
}
@@ -144,7 +156,7 @@ fn materialize() -> io::Result<Option<PathBuf>> {
/// Write one bundle into `root/<name>` when what is there is not already
/// exactly this bundle. Staged then swapped, so `root/<name>` is either the
/// previous bundle or this one — never half of either.
fn write_bundle(root: &Path, name: &str, files: &[(&str, &str)]) -> io::Result<()> {
fn write_bundle(root: &Path, name: &str, files: &[(&str, &[u8])]) -> io::Result<()> {
let destination = root.join(name);
let stamp_path = destination.join(STAMP_NAME);
let want = digest(files);
@@ -162,7 +174,12 @@ fn write_bundle(root: &Path, name: &str, files: &[(&str, &str)]) -> io::Result<(
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, contents)?;
fs::write(&path, contents)?;
#[cfg(unix)]
if *relative == "bin/darwin/accessibility" {
use std::os::unix::fs::PermissionsExt as _;
fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
}
}
// Last, so an interrupted write leaves a bundle that fails the stamp check
// and is rewritten on the next run.
@@ -234,6 +251,8 @@ mod tests {
let mut embedded: Vec<&str> = COMPUTER_USE_FILES
.iter()
.map(|(relative, _)| *relative)
// Built from the vendored native sources, never committed as an artifact.
.filter(|relative| *relative != "bin/darwin/accessibility")
.collect();
embedded.sort_unstable();
@@ -269,6 +288,46 @@ mod tests {
assert!(!home.join("plugins/state.json").exists(), "read-only");
}
/// A stock macOS install has neither a cloned plugin nor clang. The
/// reviewed runtime snapshot must carry an executable native helper.
#[cfg(target_os = "macos")]
#[test]
fn reviewed_computer_use_carries_a_runnable_native_helper() {
use std::os::unix::fs::PermissionsExt as _;
let _lock = crate::test_support::lock_test_env();
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let workspace = tmp.path().join("workspace");
fs::create_dir_all(&workspace).unwrap();
fs::create_dir_all(&home).unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
.registry_for_workspace(&workspace);
let registry = std::sync::Arc::get_mut(&mut registry).unwrap();
registry.trust(COMPUTER_USE).unwrap();
registry.enable(COMPUTER_USE).unwrap();
let plugin = registry.get(COMPUTER_USE).unwrap();
let staged = plugin.staged_root.as_ref().unwrap();
let helper = staged.join("bin/darwin/accessibility");
assert_eq!(
fs::metadata(&helper).unwrap().permissions().mode() & 0o777,
0o500
);
let result = std::process::Command::new(&helper)
.arg(r#"{"tool":"permissions","args":{}}"#)
.env("PATH", "")
.output()
.unwrap();
assert!(
result.status.success(),
"{}",
String::from_utf8_lossy(&result.stderr)
);
let reply: serde_json::Value = serde_json::from_slice(&result.stdout).unwrap();
assert!(reply.get("trusted").is_some());
}
#[test]
fn a_stale_or_tampered_bundle_is_rewritten() {
let tmp = tempfile::tempdir().unwrap();
@@ -71,7 +71,8 @@ fn offline_queue_late_unbracketed_submit_keeps_composer_and_commands_responsive(
wait_or_panic(&mut tui, "New session", STARTUP_WAIT, "launch card");
tui.wait_for_idle(Duration::from_millis(100), SETTLE_WAIT)
.expect("composer ready");
tui.send(keys::key::ctrl('u')).expect("clear suggested prompt");
tui.send(keys::key::ctrl('u'))
.expect("clear suggested prompt");
tui.send(keys::key::text("late queue draft"))
.expect("raw prompt bytes");
+8
View File
@@ -15,6 +15,14 @@ This branch describes the **v0.9.13 source candidate**. Install commands that us
trail the source candidate. A candidate is not a published install until the
matching package, tag, checksums, and release assets exist.
Computer Use is included in this source candidate and becomes available after
plugin review and enablement. Its CLI server needs **Node.js 20 or newer on
PATH**. npm installations already use Node; the Homebrew formula declares it
as a dependency. Cargo and direct binary users can install it from
[Node.js](https://nodejs.org/) and restart Codewhale. macOS builds include the
native helper, so no separate Computer Use app or compiler is needed. OS
Accessibility and Screen Recording permissions remain under your control.
## Recommended: official GitHub Releases
For a new macOS or Linux install:
+4
View File
@@ -179,3 +179,7 @@ echo " export PATH=\"$BIN_DIR:\$PATH\""
echo " hash -r"
echo " command -v codewhale codew"
echo "Keep the directory first in your shell profile after verifying it."
if ! command -v node >/dev/null 2>&1; then
echo "Computer Use is included and needs Node.js 20 or newer on PATH."
echo "Install Node.js from https://nodejs.org/, then restart Codewhale to enable Computer Use."
fi
+4
View File
@@ -339,3 +339,7 @@ say " export PATH=\"$install_dir:\$PATH\""
say " hash -r"
say " command -v codewhale codew"
say "Keep the directory first in your shell profile after verifying it."
if ! command -v node >/dev/null 2>&1; then
say "Computer Use is included and needs Node.js 20 or newer on PATH."
say "Install Node.js from https://nodejs.org/, then restart Codewhale to enable Computer Use."
fi