mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Merge verified contributor and Computer Use source for 0.9.13
Preserve the contributor/security branch and all five Computer Use commits through050f916afas 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:314bd33f47Local 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 head13c0f4ac2still 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:
commit
67430af67c
48 files changed
+3276
-471
No files matched your search
@@ -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}"
|
||||
|
||||
@@ -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 @@
|
||||
906b4330931f990880d24cad4b0f748cc25e32b3
|
||||
eb5eac7d06e3ef5c1f0d9f246cbe87f3f706c47f
|
||||
@@ -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.
|
||||
@@ -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
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: [] }); }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user