Integrate Computer Use 0.3.0 controls and catalog identity

Prioritize the registered standalone helper over embedded native input so human Pause and Stop govern local actions. Fail closed when a registered helper is unavailable. Embed its runtime modules, accept bounded inline PNG identity, and expose publisher, homepage and supported platforms without changing plugin trust. Pin the first-party catalog to the published 0.3.0 marketplace.

Validation: npm test: 66 packaging, 9 SDK, 459 web passed; npm run check:web passed (2 existing image lint warnings). Vendored control/update/transport tests: 21 passed. cargo fmt --all -- --check passed. Canonical/marketplace/Core runtime mirror check passed. Initial Rust plugin suite: 194 passed, 1 failed, 1 ignored; corrected the extra embedded development files and the focused embed-list test passes. Full Rust rebuild was interrupted to serialize with the 0.9.13 release owner; final-head Rust qualification and CLI install remain with that task.
This commit is contained in:
CodeWhale Bot committed 2026-09-12 23:45:12 -07:00
1 parent b24abd5285
commit 3859ad81de
27 files changed
+775 -43

No files matched your search

@@ -24,6 +24,14 @@ 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.
When the standalone Computer Use helper is registered, it owns local input
even when Codewhale carries an embedded native helper. Version 0.3.0 adds its
whale menu, permission setup, a disposable background check and human
Pause/Stop controls. A registered helper that cannot start causes a clear
error; the client does not silently bypass its controls. Without a registered
standalone app, the included helper remains available under the host's
permission identity.
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/>.
@@ -0,0 +1,92 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import crypto from "node:crypto";
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import { handle, closeSession } from "../src/app-handler.mjs";
/** Uses the same daemon backend and cancellation path as connected hosts. */
export async function runBackgroundCheck({ bundle, demoDirectory } = {}) {
if (process.platform !== "darwin" || !bundle) throw new Error("The background check requires the installed macOS app.");
const executable = path.join(bundle, "Contents", "Resources", "Practice.app", "Contents", "MacOS", "practice");
if (!fs.existsSync(executable)) throw new Error("Update the Computer Use app to run the background check.");
const sessionId = `setup-${crypto.randomUUID()}`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
const child = spawn(executable, [], { stdio: ["ignore", "pipe", "pipe"] });
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "cu-setup-capture-"));
let ready = false, applied = null, latest = null, buffer = "", spawnError = null;
child.on("error", error => { spawnError = error; });
child.stderr.on("data", () => {});
child.stdout.setEncoding("utf8");
child.stdout.on("data", chunk => {
buffer += chunk;
if (buffer.length > 16_384) { controller.abort(); return; }
let newline;
while ((newline = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
try { const result = JSON.parse(line); latest = result; if (result.event === "ready") ready = true; if (result.event === "applied") applied = result; } catch { /* Cocoa diagnostics are not receipts. */ }
}
});
async function until(predicate) {
while (!predicate()) {
if (spawnError) throw spawnError;
if (controller.signal.aborted || child.exitCode !== null) throw new Error("The check was interrupted. You can run it again when ready.");
await delay(40);
}
}
async function call(tool, args = {}) {
const reply = await handle({ tool, args }, { sessionId, signal: controller.signal, persistentInputOwner: true });
if (!reply.ok) throw new Error(reply.error.message);
return reply.data;
}
try {
await until(() => ready);
await call("open_application", { pid: child.pid, activate: false });
const state = await call("get_app_state");
if (demoDirectory) {
fs.mkdirSync(demoDirectory, { recursive: true });
// AX can register before WindowServer makes a new window capturable.
// Retry only this read, before sending any input, within the check limit.
while (true) {
try { await call("screenshot", { app_ref: { pid: child.pid }, path: path.join(demoDirectory, "01-ready.png") }); break; }
catch (error) {
if (controller.signal.aborted || !error.message.includes("not capturable")) throw error;
await delay(100, undefined, { signal: controller.signal });
}
}
}
const entry = state.elements.find(element => element.role === "AXTextField" && element.label === "Practice text");
const apply = state.elements.find(element => element.role === "AXButton" && element.label === "Apply");
if (!entry || !apply) throw new Error("The practice controls could not be read. Check Accessibility permission and retry.");
// Backend targets are resolved AX records, using the exact state index.
const focus = await call("resolve_element", { app_ref: { pid: child.pid }, windowIndex: entry.windowIndex, path: entry.path });
if (!focus.found) throw new Error("The practice text field changed. Run the check again.");
await call("left_click", { target: { ...focus.element, type: "element", app_ref: { pid: child.pid } } });
const phrase = "Background check complete 🐋";
await call("type", { text: phrase });
if (demoDirectory) await call("screenshot", { app_ref: { pid: child.pid }, path: path.join(demoDirectory, "02-entered.png") });
const button = await call("resolve_element", { app_ref: { pid: child.pid }, windowIndex: apply.windowIndex, path: apply.path });
if (!button.found) throw new Error("The practice Apply button changed. Run the check again.");
await call("left_click", { target: { ...button.element, type: "element", app_ref: { pid: child.pid } } });
await until(() => applied);
if (applied.value !== phrase) throw new Error("The text received by the practice window did not match. Check the app log and retry.");
const capture = await call("screenshot", { app_ref: { pid: child.pid }, path: path.join(scratch, "practice.png") });
if (demoDirectory) fs.copyFileSync(capture.file, path.join(demoDirectory, "03-verified.png"));
if (capture.app_ref?.pid !== child.pid || !capture.pixels?.w || !capture.pixels?.h) throw new Error("The practice window screenshot could not be verified. Check Screen Recording permission.");
const count = latest.samples;
await until(() => latest.samples > count);
const isolated = latest.samples > 0 && latest.pointerChanges === 0 && latest.foregroundChanges === 0;
return { ok: isolated, edited: true, screenshot: true, samples: latest.samples, pointerChanges: latest.pointerChanges, foregroundChanges: latest.foregroundChanges,
message: isolated ? "Text entered, Apply verified and the practice window captured. Your foreground app and pointer stayed unchanged."
: "Edit and screenshot verified. Your app or pointer moved during the check, so background isolation is inconclusive." };
} finally {
clearTimeout(timer);
try { await closeSession(sessionId); }
finally {
if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
fs.rmSync(scratch, { recursive: true, force: true });
}
}
}
+88 -16
View File
@@ -16,7 +16,10 @@
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 path from "node:path";
import { handle, closeSession, closeAllSessions, releaseSessionInput, reopenSession, ALLOWED, controlStatus, setControlMode } from "../src/app-handler.mjs";
import { runBackgroundCheck } from "./background-check.mjs";
import { checkForUpdate, prepareUpdate, restartWithUpdate } from "./updates.mjs";
import { APP_ID, APP_NAME, APP_VERSION, socketPath, runInfoPath, writeRegistration, defaultLaunch, hello } from "../src/app-socket.mjs";
import { stateDir } from "../src/registry.mjs";
@@ -25,7 +28,7 @@ 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, backgroundProtocol: 1, pid: process.pid, platform: process.platform, node: process.version, bundle, startedAt, socket: socketPath() };
return { id: APP_ID, name: APP_NAME, version: APP_VERSION, sessionProtocol: 2, backgroundProtocol: 1, controlProtocol: 1, pid: process.pid, platform: process.platform, node: process.version, bundle, startedAt, socket: socketPath() };
}
if (await hello({ timeoutMs: 1_500 })) {
@@ -41,6 +44,87 @@ if (process.platform !== "win32") {
const leases = new Map();
let shuttingDown = false;
let backgroundCheck = null;
let checking = false;
let update = null;
let updating = false;
let controlError = null;
const controlFile = path.join(stateDir(), "control.json");
async function userControl(mode) {
const work = setControlMode(mode);
if (mode === "ready") await work;
const temporary = `${controlFile}.${process.pid}.tmp`;
let storageError;
try {
fs.writeFileSync(temporary, JSON.stringify({ mode }), { mode: 0o600 });
fs.renameSync(temporary, controlFile);
} catch (error) { storageError = error; }
const result = await work;
if (storageError) throw new Error("Control changed, but its restart preference could not be saved. Check disk space before reopening the app.");
return result;
}
try {
const saved = JSON.parse(fs.readFileSync(controlFile, "utf8"));
if (saved.mode !== "ready") await setControlMode(["paused", "stopped"].includes(saved.mode) ? saved.mode : "stopped");
} catch (error) { if (error.code !== "ENOENT") await setControlMode("stopped"); }
// An inherited socketpair joins the menu-bar owner and its child. This has
// no filesystem endpoint, no reusable credential and no MCP equivalent.
// Losing the human control process fails closed before accepting more work.
if (process.env.CODEWHALE_CU_CONTROL_FD === "3") {
const control = new net.Socket({ fd: 3, readable: true, writable: true });
delete process.env.CODEWHALE_CU_CONTROL_FD;
let input = "";
control.setEncoding("utf8");
const status = () => ({ ...controlStatus(), version: APP_VERSION, checking, backgroundCheck, error: controlError,
update: update ? { available: update.available, version: update.version, message: update.message, busy: updating } : null });
const send = (id, error) => {
if (error) controlError = String(error.message ?? error).slice(0, 400);
if (!control.destroyed) control.write(JSON.stringify({ id, ...status() }) + "\n");
};
control.on("data", chunk => {
input += chunk;
if (input.length > 8192) { control.destroy(); return; }
let newline;
while ((newline = input.indexOf("\n")) >= 0) {
const line = input.slice(0, newline); input = input.slice(newline + 1);
let request;
try { request = JSON.parse(line); } catch { continue; }
const { id, command } = request;
if (command === "status") send(id);
else if (["pause", "resume", "stop"].includes(command)) {
const mode = { pause: "paused", resume: "ready", stop: "stopped" }[command];
userControl(mode).then(() => {
controlError = null;
if (mode === "stopped") {
// Keep old owner sockets alive but invalidate their leases. An
// already queued request can never silently obtain a fresh one.
for (const lease of leases.values()) lease.stopped = true;
}
send(id);
}).catch(error => send(id, error));
} else if (command === "check") {
if (checking || controlStatus().sessions.some(s => s.action)) { send(id, new Error("Wait for the current action to finish before running the check.")); continue; }
checking = true; backgroundCheck = null; send(id);
runBackgroundCheck({ bundle }).then(result => { backgroundCheck = result; }).catch(error => {
backgroundCheck = { ok: false, message: error.message };
}).finally(() => { checking = false; send(id); });
} else if (command === "updates" && !updating) {
updating = true; update = { available: false, message: "Checking for updates…" }; send(id);
checkForUpdate().then(result => { update = result; }).catch(error => { update = { available: false, message: error.message }; }).finally(() => { updating = false; send(id); });
} else if (command === "install_update" && update?.available && !updating && bundle) {
updating = true; update.message = "Downloading and verifying the update…"; send(id);
prepareUpdate(update).then(async prepared => {
await userControl("stopped");
await restartWithUpdate(prepared, bundle);
update.message = "Restarting Computer Use…"; send(id);
}).catch(error => { updating = false; update.message = error.message; send(id); });
} else send(id, new Error("Unknown or unavailable control command"));
}
});
control.on("error", () => {});
control.on("close", () => { userControl("stopped").catch(error => log(`control owner cleanup: ${error.message}`)); });
}
async function serve(conn) {
let buf = "";
let chain = Promise.resolve();
@@ -85,6 +169,8 @@ async function serve(conn) {
}
} 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 (leases.get(req.sessionId).stopped && !["close_session", "release_session_input"].includes(req.tool)) {
reply = { ok: false, error: { code: "control_stopped", message: "The user stopped this computer session. Start a new task after they allow control in the menu bar." } };
} else if (["close_session", "release_session_input"].includes(req.tool)) {
try {
if (req.tool === "close_session") await closeSession(req.sessionId);
@@ -114,22 +200,8 @@ server.listen(sock, () => {
} 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;
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { spawnSync } from "node:child_process";
/** Publish a verified bundle in one rename, retaining the previous install. */
export function replaceMacBundle(source, destination, { prepare = () => {}, verify = verifySignature } = {}) {
const parent = path.dirname(destination);
fs.mkdirSync(parent, { recursive: true });
if (fs.existsSync(destination) && !fs.existsSync(path.join(destination, "Contents", "Resources", "plugin", "app", "daemon.mjs"))) throw new Error("The installation destination contains a different application.");
if (fs.existsSync(destination) && fs.lstatSync(destination).isSymbolicLink()) throw new Error("The installation destination must not be a symlink.");
const staging = fs.mkdtempSync(path.join(parent, ".codewhale-cu-update-"));
const next = path.join(staging, path.basename(destination));
let backup = null;
try {
fs.cpSync(source, next, { recursive: true });
prepare(next);
verify(next);
if (fs.existsSync(destination)) {
const backups = path.join(parent, ".codewhale-cu-backups");
fs.mkdirSync(backups, { recursive: true, mode: 0o700 });
backup = path.join(backups, `${Date.now()}-${crypto.randomUUID()}.app`);
fs.renameSync(destination, backup);
}
try { fs.renameSync(next, destination); }
catch (error) { if (backup) fs.renameSync(backup, destination); throw error; }
return { backup };
} finally { fs.rmSync(staging, { recursive: true, force: true }); }
}
export function verifySignature(bundle) {
const result=spawnSync("codesign",["--verify","--deep","--strict",bundle],{encoding:"utf8"});
if(result.status!==0) throw new Error(`The app signature did not verify: ${result.stderr?.trim() ?? "codesign unavailable"}`);
}
export function verifyReleaseBundle(bundle) {
verifySignature(bundle);
const requirement='anchor apple generic and identifier "net.codewhale.computer-use" and certificate leaf[subject.OU] = "5RDNSHA5TY"';
for(const [command,args] of [["/usr/bin/codesign",["--verify","--strict","-R",requirement,bundle]],["/usr/sbin/spctl",["--assess","--type","execute","--verbose=2",bundle]]]) {
const result=spawnSync(command,args,{encoding:"utf8"});
// Gatekeeper ships with macOS. Requiring its notarized source also rejects
// local allow-list overrides; consumer Macs do not need Xcode's stapler.
if(result.status!==0 || (command.endsWith("/spctl") && !/^source=Notarized Developer ID\r?$/m.test(result.stderr))) throw new Error("The update is not a valid notarized Codewhale release. Your current app has been kept.");
}
if(!fs.existsSync(path.join(bundle,"Contents","MacOS","node"))) throw new Error("The release is missing its bundled runtime.");
}
@@ -0,0 +1,125 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import crypto from "node:crypto";
import { spawn, spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { inflateRawSync } from "node:zlib";
import { replaceMacBundle, verifyReleaseBundle } from "./install-macos.mjs";
import { APP_VERSION, APP_NAME } from "../src/app-socket.mjs";
const repository="https://github.com/Hmbown/codewhale-cu-plugin";
const limit=256*1024*1024;
async function responseBytes(response, maximum) {
const chunks=[]; let size=0;
for await(const chunk of response.body) { size+=chunk.length; if(size>maximum) throw new Error("The update service exceeded its response size limit."); chunks.push(chunk); }
return Buffer.concat(chunks);
}
export function newerVersion(candidate,current) {
const parse=value=>/^\d+\.\d+\.\d+$/.test(value)?value.split(".").map(Number):null;
const a=parse(candidate),b=parse(current); if(!a||!b) return false;
for(let i=0;i<3;i++) { if(a[i]!==b[i]) return a[i]>b[i]; } return false;
}
export function releaseUpdate(release,current=APP_VERSION) {
const version=release?.tag_name?.replace(/^v/,"");
if(!version||release.draft||release.prerelease||!newerVersion(version,current)) return {available:false,message:`You have Computer Use ${current}. No newer stable installer is available.`};
const name=`Codewhale-Computer-Use-${version}-macos-universal.zip`;
const asset=release.assets?.find(asset=>asset.name===name);
const url=`${repository}/releases/download/v${version}/${name}`;
if(!asset||asset.browser_download_url!==url||!/^sha256:[a-f0-9]{64}$/.test(asset.digest)||!Number.isSafeInteger(asset.size)||asset.size<=0||asset.size>limit) return {available:false,message:`Version ${version} has no verified macOS installer yet.`};
return {available:true,version,url,sha256:asset.digest.slice(7),size:asset.size,message:`Computer Use ${version} is available. Install it to restart the helper; existing computer sessions will stop.`};
}
export async function checkForUpdate() {
const response=await fetch("https://api.github.com/repos/Hmbown/codewhale-cu-plugin/releases/latest",{redirect:"error",headers:{Accept:"application/vnd.github+json","X-GitHub-Api-Version":"2022-11-28"},signal:AbortSignal.timeout(10_000)});
if(response.status===404) return {available:false,message:"No stable installer has been published yet. Your current app is unchanged."};
if(!response.ok) throw new Error(`The update service is unavailable (${response.status}). Try again later.`);
return releaseUpdate(JSON.parse((await responseBytes(response,1024*1024)).toString("utf8")));
}
/** Inspect both ZIP headers before extraction: no links, traversal or bombs. */
export function validateReleaseZip(bytes) {
const minimum=Math.max(0,bytes.length-65557); let end=-1;
for(let i=bytes.length-22;i>=minimum;i--) if(bytes.readUInt32LE(i)===0x06054b50&&i+22+bytes.readUInt16LE(i+20)===bytes.length) { end=i; break; }
if(end<0||bytes.readUInt16LE(end+4)||bytes.readUInt16LE(end+6)) throw new Error("Invalid update archive.");
const count=bytes.readUInt16LE(end+10); let position=bytes.readUInt32LE(end+16),total=0;
if(!count||count>2000||bytes.readUInt16LE(end+8)!==count||position+bytes.readUInt32LE(end+12)!==end) throw new Error("Invalid update archive index.");
const seen=new Set();
for(let i=0;i<count;i++) {
if(position+46>end||bytes.readUInt32LE(position)!==0x02014b50) throw new Error("Invalid update entry.");
const flags=bytes.readUInt16LE(position+8),method=bytes.readUInt16LE(position+10),length=bytes.readUInt16LE(position+28),extra=bytes.readUInt16LE(position+30),comment=bytes.readUInt16LE(position+32);
const name=bytes.subarray(position+46,position+46+length).toString("utf8");
const kind=(bytes.readUInt32LE(position+38)>>>16)&0xf000,offset=bytes.readUInt32LE(position+42),compressed=bytes.readUInt32LE(position+20);
const size=bytes.readUInt32LE(position+24); total+=size;
if(flags&1||![0,8].includes(method)||![0,0x4000,0x8000].includes(kind)||total>512*1024*1024||position+46+length+extra+comment>end) throw new Error("Unsupported update entry.");
if(!name.startsWith(`${APP_NAME}.app/`)||name.includes("\\")||name.includes(":")||name.includes("\0")||name.split("/").some(part=>part===".."||part===".")||seen.has(name)) throw new Error("Unsafe update path.");
seen.add(name);
if(offset+30>position||bytes.readUInt32LE(offset)!==0x04034b50) throw new Error("Invalid update file header.");
const localLength=bytes.readUInt16LE(offset+26),localExtra=bytes.readUInt16LE(offset+28);
if(offset+30+localLength+localExtra+compressed>bytes.readUInt32LE(end+16)||bytes.subarray(offset+30,offset+30+localLength).toString("utf8")!==name) throw new Error("Inconsistent update file header.");
if(bytes.readUInt16LE(offset+8)!==method||bytes.readUInt16LE(offset+6)!==flags||(!(flags&8)&&(bytes.readUInt32LE(offset+18)!==compressed||bytes.readUInt32LE(offset+22)!==size))) throw new Error("Inconsistent update sizes or compression.");
const start=offset+30+localLength+localExtra;
// Header sizes are untrusted. Bound actual expansion before ditto writes
// anything, including a compressed payload whose headers understate size.
const payload=bytes.subarray(start,start+compressed);
let expanded;
try { expanded=method===0?payload.length:inflateRawSync(payload,{maxOutputLength:Math.max(size,1)}).length; }
catch { throw new Error("Invalid or oversized compressed update entry."); }
if(expanded!==size) throw new Error("The update entry size did not match its contents.");
position+=46+length+extra+comment;
}
if(position!==end) throw new Error("Invalid update archive length.");
return count;
}
export async function prepareUpdate(update) {
if(!update?.available) throw new Error("Check for an available update first.");
if(!newerVersion(update.version,APP_VERSION)||update.url!==`${repository}/releases/download/v${update.version}/Codewhale-Computer-Use-${update.version}-macos-universal.zip`||!Number.isSafeInteger(update.size)||update.size<=0||update.size>limit) throw new Error("The update identity is invalid.");
// Only GitHub's fixed release URL and its asset CDN can serve the bytes.
let url=update.url, response;
for(let redirects=0;redirects<4;redirects++) {
response=await fetch(url,{redirect:"manual",signal:AbortSignal.timeout(60_000)});
if(![301,302,303,307,308].includes(response.status)) break;
const next=new URL(response.headers.get("location"),url);
if(next.protocol!=="https:"||!["github.com","release-assets.githubusercontent.com","objects.githubusercontent.com"].includes(next.hostname)) throw new Error("The update download redirected to an unexpected host.");
url=next.href;
}
if(!response?.ok) throw new Error("The update could not be downloaded. Your current app is unchanged.");
const bytes=await responseBytes(response,update.size);
if(bytes.length!==update.size||crypto.createHash("sha256").update(bytes).digest("hex")!==update.sha256) throw new Error("The update checksum did not match. Your current app is unchanged.");
validateReleaseZip(bytes);
const stage=fs.mkdtempSync(path.join(os.tmpdir(),"codewhale-cu-release-"));
try {
const archive=path.join(stage,"release.zip"); fs.writeFileSync(archive,bytes,{mode:0o600});
const result=spawnSync("ditto",["-x","-k",archive,stage],{encoding:"utf8"});
if(result.status!==0) throw new Error("The update could not be unpacked.");
const bundle=path.join(stage,`${APP_NAME}.app`); verifyReleaseBundle(bundle);
const version=spawnSync("/usr/libexec/PlistBuddy",["-c","Print :CFBundleShortVersionString",path.join(bundle,"Contents","Info.plist")],{encoding:"utf8"});
if(version.status!==0||version.stdout.trim()!==update.version) throw new Error("The downloaded app has a different version.");
return {stage,bundle};
} catch(error) { fs.rmSync(stage,{recursive:true,force:true}); throw error; }
}
export async function restartWithUpdate(prepared,destination) {
const logDir=path.join(os.homedir(),"Library","Logs",APP_NAME); fs.mkdirSync(logDir,{recursive:true});
const log=fs.openSync(path.join(logDir,"update.log"),"a",0o600);
const child=spawn(process.execPath,[fileURLToPath(import.meta.url),"--apply",prepared.bundle,destination,String(process.pid),String(process.ppid)],{detached:true,stdio:["ignore",log,log]});
try { await new Promise((resolve,reject)=>{child.once("spawn",resolve);child.once("error",reject);}); child.unref(); }
finally { fs.closeSync(log); }
}
if(process.argv[1]===fileURLToPath(import.meta.url)&&process.argv[2]==="--apply") {
const [source,destination,owner,launcher]=process.argv.slice(3);
try {
verifyReleaseBundle(source);
process.kill(Number(owner),"SIGTERM");
for(let i=0;i<100;i++) { try { process.kill(Number(owner),0); } catch { break; } await new Promise(resolve=>setTimeout(resolve,100)); }
try { process.kill(Number(owner),0); throw new Error("The running helper did not stop; update cancelled."); } catch(error) { if(error.code!=="ESRCH") throw error; }
// LaunchServices must not send Reopen to the retiring menu-bar process.
for(let i=0;i<100;i++) { try { process.kill(Number(launcher),0); } catch { break; } await new Promise(resolve=>setTimeout(resolve,100)); }
try { process.kill(Number(launcher),0); throw new Error("The menu-bar app did not exit; update cancelled."); } catch(error) { if(error.code!=="ESRCH") throw error; }
const receipt=replaceMacBundle(source,destination,{verify:verifyReleaseBundle});
const installed=spawnSync("/usr/libexec/PlistBuddy",["-c","Print :CFBundleShortVersionString",path.join(destination,"Contents","Info.plist")],{encoding:"utf8"});
console.log(JSON.stringify({version:installed.stdout.trim(),...receipt,installedAt:new Date().toISOString()}));
} catch(error) { console.error(error.message); process.exitCode=1; }
finally { spawnSync("open",["-g","-a",destination]); }
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "codewhale-cu",
"version": "0.2.2",
"version": "0.3.0",
"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",
+24 -9
View File
@@ -1,9 +1,11 @@
{
"$schema": "https://agent-plugins.org/schemas/plugin.json",
"name": "computer-use",
"version": "0.2.2",
"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" },
"version": "0.3.0",
"description": "Let Codewhale see and operate your apps.",
"author": {
"name": "Codewhale"
},
"license": "MIT",
"keywords": [
"computer-use",
@@ -16,12 +18,25 @@
"extensions": {
"net.codewhale": {
"display_name": "Computer Use",
"commands": { "path": "commands" },
"skills": { "path": "skills" },
"commands": {
"path": "commands"
},
"skills": {
"path": "skills"
},
"when": {
"binaries": ["node"],
"os": ["macos", "linux", "windows"]
}
"binaries": [
"node"
],
"os": [
"macos",
"linux",
"windows"
]
},
"icon": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAIfUlEQVR4nOVbiVdUZRR/x479Ac1j07TjDIyK4jaZ+mZAkEWhVFZJ0VxSQVGPLZa5RCpaCea+oqa54JamuaQZlSYoai6lgIIbLpEiGq4o3s7vi5lYZh5v3iwP9J5zz3kw8M29v+9+d30fx5kjb++XeY0Qq9II63mNPlelFu7xGj3VJ66QObdChz7QiZNCLs2EKJVaKFBaAfsDos9/Ra2PFFE95iVeLaQqLajDWS2kcBzXoIb6/Iug/P/8ZQ2z55UXyqms0hjCTQ4P5+OFA0AtXGCOEd5eaWGsYVetn93WcvE0xHAqjT5daaWsYY82weTi5Uuu2q72WG8tp9II5+qVBTT3o06jk6hFz8Hk3qqbrevlciq1UKq0UtayJrgfJeRcIcPEVHLzDiBeI8haR6UWSjlHCOjiaaDgiKGUnLqUtu3cT2dz86nkzj907/4DKi8vp7+KbtGJ0znss/GffUUdu8VaaQVdadgfBTTi3FWK2vwDNWrbnVyayzsSnD0Vb+cbSQuXp9PV60VkLS1euUHy93j4BFNk+i4GAHhQ1mnyDIkjd2YNCgDQ3i+KNmzdQ2VPnpAcKr13n4SQOOkW5uVLwrgZJgDA8bmXyTAhxWq/wNmiuJvWlyZMm8NMWy7haITHjbb6u5v5RVcBwMjha3aQu08QuWh9HQuAly6UDmQek604fMGOPRn0RqB159/Iri386d3jeWZB6Lc3kxp3CJNkDZycL+8S0o8uXCokW+jYyTO0+8cDlJl9gg5mHaeMA4cpdf7XFBqTQK5ete8edrnX6m0m8+84cjKpA2LJvXUgS5bgJ9y8HQDA6wF9qOjvYnIkYf0Va7dS5IAx9KqlXfQyUMdRSQyA0EXryK1VoCxL4qz541ZdetGlK9ckK7Fs1SYaPjaJwvokUGDvIdR/+Mc0f9k6Fgal0uPHZfTTr4fNWkXTzr1ZPtCkU09ZyvPWAOCu9aMjx07XKvCpP/NoyKhJ5CESl1/zCaI9+w9KBmHukrVm13Fr6U8ByQvJwyfI8QDMWrBKfKfKymj6rGUMKCnrYUeXr/m2VuXTvtli0SdAcYREucrzUgEw9OgvGuNv3rrNTFyOAIMSJ1L28aqW9ezZM8rKPkn9ho0T/39PQ0Uq7GAA9v+SJRrHERVsEQLcrG0IC4lYCyHW1vXsBkCPqOEWlcdOxQx632nCKgLAlu37LAKwbtNOxRVwKADqdt3p0aPHFp0ewuJzDcDwsUkWdx/Fj9LCOxyA9G93WwSgVg/9PABwvuCyWeWfPi2nJq3lpZ71BoBGLf1ZxWaObhWXOFVIJFe+Ye9QwvtTWCr948+ZdPpMHl25eoP1Eox0524p3Si6Sd/v+ZkVVTYB0Dm4r0Xzv3j5qkMVRksNCk9Mnkt7fzpURUmphMQtNDpePgCh0fEWF/+n9J7dlUa6+2bsCFqyciMVXvuL7EFIo2UDEB43WnTx5h3ftIviSKHTVm+2qkKUSus375QPQHDEUNHFJ0+fL1tpgAfzPpOTT46kpV9vkg9Ah67RoovD2bS0og5HeYx+wK59B2Q3Ty3RlcLrbLc/nJzCjm6vvomSN4kTK05qo4KLhRTRf0yt5xot779v2r+LhP4EQK1eLnfrNZh9PnDEJ/IB4DV6yd1e9PW+nLuCDTmSPl/AenvYaUcoDcK6iR9OY9HCnNyQBam6pn132wDIyj5JdY3QPDXngHX+MabnvPyLLFeQcjQ5sQ9nL1pNdYVQen8xZ7nZ7lBj7wDWQK3svJE02QxAaEwC1QV68vQpjRqXXCOSGJ8HjZxAjVr4s+ede39lTlGsJykZAFcvX8ldYFsIOUBI5DDmvav7HZzlAfHjazi51l16s2d0j+Lf+4w9B/QczCwF0UCK8ryUhsi0lMUOByD3/EXWKW5riKDikjum36MWQUleWR44trhhH1Vp1qIww2Zh2ILBLI6E3QBo1jaEbpfcdYqZo8qsTEiWKsuCIQlG7kbvj4aq8ex/MnUO+5/YIR9IVp6X2hSdlDyPnE0bt/1QRQaccThlY2hDsTRv6X/zAhwfDFCkpL6yAHDX+rHy01mEMNa0Ur/Bs0MPNkMwZp6dgt6mfRmHmEW00Uew4gm+SkrclwUAr9Gz+f39Bw8drvyDBw/JN3SA6XvRJkdOr9WFsZ/xNgmm0t6de5L29TAGVvHtOwwUa5XnrZ0NItxUP6fWEMro2rLL0R/PYN+FMPbRp6k0fspsU+yH80PW6SOEM0YxBcCk1P12AYDX6GnkB1MtdorFKOfcBXZGxSh9yy5TITY4cRLbYeOLGDj/6PTAKSMMwtujAyTn5QqbAOArRmVS02QIOXXmIrabYtbz59nzZvuM8PIw+THjZzDvj3oDDg/Jjr679Ndq7AoAX2lqhK7L2byCKiXutRtFLCOD0JgtzJy7giUoYi226jMGeHlEAsR5OEH4hcNHT5nqAWtKcYcBwFdjCGqcDmO34DPO5V8StZDLhddNhQwcHUwfMR25PCwC80LMIGA9yEeqp8R1CgB3rR8Fhb/LskdYRW2U/fsf1OKNt6o0YrEGssKhYz6ljINHWDb48OEjZmnw/PaUl7c3AEhIpOQLiAToG0BZePj2XaMpZuB79PnsNGbeRieLHZ+zeE0VkOo0AHyl87sgbT3r25sj5Pv4DKP16o4Rr9asWv8dRQ8cK7miq3MA8JUY8Rq+AGkrwtz23Rn0y29HWb8fz99s2M7y+75Dx7GsztHy1ABAVQ9flrYbq/V38bp8nuKCKMc5XMW9uhfVAtbAB/RRXBCF2MWzSzTH6XQNVWr9eaWFcTbjcihnvE2KG5UvHAAaoXf1i5MpSgvlPBa+MHd3tgGv0c9UXjin3BqteXXWSLhR+Tz6BOhUw+w5S6TTNcSlQtyrQ6ysj8lShcw50IF5e52uoTlV/wVadEUpa12fcgAAAABJRU5ErkJggg=="
}
}
},
"homepage": "https://codewhale.net/computer-use",
"repository": "https://github.com/Hmbown/codewhale-cu-plugin"
}
@@ -19,6 +19,18 @@ computer unless given `computer`.
- Every receipt names the computer it happened on. Read it before continuing —
never assume the action landed on the machine you meant.
## Human controls
When the local helper is installed, it owns the input route even when the
host also includes a native binary. A disconnected helper is an error, never
permission to bypass it with direct input. `control_paused` and
`control_stopped` mean the person paused or stopped Computer Use. Stop acting
and wait for them; do not change environment variables, restart the helper,
create another session or use another tool to defeat their choice. After Stop,
the old session remains invalid even when the person allows new sessions.
The helper's own setup, permission and safety controls belong to the person.
Do not operate them or approve the host's pending authorization yourself.
## Core loop
Observe once, act once, then verify.
@@ -37,6 +37,39 @@ async function backend(computerId, sessionId, persistentInputOwner) {
}
const sessions = new Map();
let controlMode = "ready";
let controlGeneration = 0;
let cleanupPending = false;
/** Human-facing state contains app identity and action names, never task text. */
export function controlStatus() {
return { mode: controlMode, cleanupPending, sessions: [...sessions.values()]
.filter((s) => !s.closed && s.target)
.map((s) => ({ target: s.target, mode: s.mode, action: s.action ?? null })) };
}
// Called only by the launcher's inherited control channel, never an MCP tool.
// Abort before queuing cleanup so even a held gesture yields to the person.
export async function setControlMode(mode) {
if (!["ready", "paused", "stopped"].includes(mode)) throw new Error("Unknown control mode");
if (mode === "ready") {
if (cleanupPending) throw new Error("Input is still being released; try again in a moment.");
controlMode = mode;
return controlStatus();
}
controlMode = mode;
const generation = ++controlGeneration;
cleanupPending = true;
const results = await Promise.allSettled([...sessions.keys()].map((key) => {
const colon = key.indexOf(":");
return releaseSessionInput(key.slice(colon + 1), key.slice(0, colon), { close: mode === "stopped" });
}));
const failure = results.find((r) => r.status === "rejected");
// A cleanup failure stays blocked. Resume cannot hide owned input.
if (failure) throw failure.reason;
if (generation === controlGeneration) cleanupPending = false;
return controlStatus();
}
function enqueue(fn) {
const next = queue.then(fn);
@@ -97,6 +130,8 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
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 };
if (controlMode !== "ready") return { ok: false, error: { code: `control_${controlMode}`, message: `Computer Use is ${controlMode} by the user. Wait for them to resume it in the menu bar.` } };
const generation = controlGeneration;
const key = `${computerId}:${sessionId}`;
let session = sessions.get(key);
if (!session) {
@@ -125,8 +160,11 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
try {
return await enqueue(() => withSignal(controller.signal, async () => {
throwIfAborted();
if (generation !== controlGeneration || controlMode !== "ready") throw Object.assign(new Error("Computer control was interrupted by the user."), { code: "cancelled" });
const instance = await backend(computerId, sessionId, persistentInputOwner);
throwIfAborted();
session.action = tool;
if (tool === "open_application") { session.target = null; session.mode = null; }
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." } };
}
@@ -147,6 +185,10 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
throw error;
}
if (tool === "left_mouse_up" && heldPointers.get(computerId) === key) heldPointers.delete(computerId);
if (tool === "open_application" && data?.resolved) {
session.target = { name: String(data.resolved.name ?? "Application").slice(0, 128), pid: data.resolved.pid };
session.mode = data.shared_pointer || data.activate ? "foreground" : "background";
}
return { ok: true, platform: process.platform, tool, data };
}));
} catch (err) {
@@ -154,6 +196,7 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
} finally {
signal?.removeEventListener("abort", abort);
session.requests.delete(controller);
session.action = null;
session.touched = Date.now();
}
}
@@ -156,9 +156,6 @@ 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 };
@@ -171,7 +168,7 @@ export async function ensureApp({ launch = true } = {}) {
}
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)` };
throw Object.assign(new ExecError(`${APP_NAME} is installed but not responding. Open it from Applications and retry; its controls must remain in charge of input.`), { code: "app_unavailable" });
}
lastLaunchAt = Date.now();
launchApp(reg);
@@ -182,5 +179,5 @@ export async function ensureApp({ launch = true } = {}) {
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()}` };
throw Object.assign(new ExecError(`${APP_NAME} did not answer within 8s. Open it from Applications and check its status before retrying.`), { code: "app_unavailable" });
}
@@ -568,6 +568,7 @@ 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([a.bundleIdentifier isEqual:@"net.codewhale.computer-use"] && [args[@"activate"] boolValue]) @throw [NSException exceptionWithName:@"protected" reason:@"Computer Use safety controls belong to the user." userInfo:nil];
if([args[@"activate"] boolValue]) cuLockInput();
cuCheckCancelled();
if([args[@"activate"] boolValue] && !axActivate(a.processIdentifier)) [a activateWithOptions:0];
@@ -583,6 +584,7 @@ static id execute(NSDictionary *p) {
if(![args[@"input_app_ref"] isKindOfClass:NSDictionary.class]) @throw [NSException exceptionWithName:@"focus" reason:@"open_application first to bind the input destination" userInfo:nil];
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([inputApp.bundleIdentifier isEqual:@"net.codewhale.computer-use"]) @throw [NSException exceptionWithName:@"protected" reason:@"Computer Use safety controls belong to the user." 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];
@@ -767,6 +769,7 @@ static id execute(NSDictionary *p) {
}
NSRunningApplication *a=resolve(args[@"app_ref"]?:args[@"target"][@"app_ref"]);
if(!a) @throw [NSException exceptionWithName:@"app" reason:@"application not found" userInfo:nil];
if(mutates && [a.bundleIdentifier isEqual:@"net.codewhale.computer-use"]) @throw [NSException exceptionWithName:@"protected" reason:@"Computer Use safety controls belong to the user." userInfo:nil];
AXUIElementRef app=AXUIElementCreateApplication(a.processIdentifier);
AXUIElementSetMessagingTimeout(app,2.0);
@try {
@@ -558,6 +558,7 @@ export function create({ exec }) {
p = await native("app_info", { app_ref: find, activate });
}
if (activate && p?.frontmost === false) throw Object.assign(new ExecError("The selected application did not become frontmost; no input mode was enabled. Continue with background control or wait for the user."), { code: "activation_not_confirmed" });
if (p?.bundle_id === "net.codewhale.computer-use") throw Object.assign(new ExecError("The Computer Use setup and safety controls belong to the user and cannot be operated by this plugin."), { code: "protected_application" });
// 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 } : {}) };
+1 -1
View File
@@ -131,7 +131,7 @@ export async function runInputLease(cmd, args = [], opts = {}) {
let commandTimer, abort;
try {
throwIfAborted(signal);
if (closed || stopped || child?.exitCode !== null || child?.signalCode) throw new ExecError("Native input owner is closed");
if (closed || stopped || child?.exitCode !== null || child?.signalCode) throw Object.assign(new ExecError("Native input owner is closed"), { code: "input_owner_closed" });
const next = reply();
const cancelled = new Promise((_, reject) => {
abort = () => reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
@@ -0,0 +1,57 @@
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 net from "node:net";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { setTimeout as delay } from "node:timers/promises";
const root = fileURLToPath(new URL("../", import.meta.url));
test("human Pause cancels queued input; Stop invalidates owners; MCP cannot resume", async t => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-control-"));
const log = path.join(dir, "calls.jsonl");
const daemon = spawn(process.execPath, [path.join(root, "app/daemon.mjs")], { env: { ...process.env,
CODEWHALE_CU_STATE_DIR: dir, CODEWHALE_CU_APP_SOCKET: path.join(dir, "app.sock"), CODEWHALE_CU_APP_WARM: "off",
CODEWHALE_CU_TEST_BACKEND: path.join(root, "tests/fixtures/session-backend.mjs"), CU_SESSION_CALLS: log, CODEWHALE_CU_CONTROL_FD: "3" }, stdio: ["ignore", "ignore", "pipe", "pipe"] });
let errors = ""; daemon.stderr.on("data", chunk => { errors += chunk; });
const sockets = [];
t.after(async () => { sockets.forEach(socket => socket.destroy()); daemon.kill("SIGTERM"); await new Promise(resolve => daemon.once("exit", resolve)); fs.rmSync(dir, { recursive: true, force: true }); });
async function until(predicate) { for(let i=0;i<250;i++) { if(predicate()) return; await delay(20); } throw new Error(`Timed out: ${errors}`); }
await until(() => fs.existsSync(path.join(dir, "app.sock")));
function request(payload, keepOpen = false) {
const socket = net.connect(path.join(dir, "app.sock")); sockets.push(socket);
return new Promise((resolve, reject) => {
socket.on("error", reject); socket.on("connect", () => socket.write(JSON.stringify(payload)+"\n"));
let buffer=""; socket.on("data", chunk => { buffer += chunk; if(!buffer.includes("\n")) return; if(!keepOpen) socket.destroy(); resolve(JSON.parse(buffer.split("\n")[0])); });
});
}
let seq=0, buffer=""; const pending=new Map();
daemon.stdio[3].on("data", chunk => { buffer+=chunk; let n; while((n=buffer.indexOf("\n"))>=0) { const state=JSON.parse(buffer.slice(0,n)); buffer=buffer.slice(n+1); pending.get(state.id)?.(state); pending.delete(state.id); } });
function control(command) { const id=++seq; return new Promise(resolve => { pending.set(id,resolve); daemon.stdio[3].write(JSON.stringify({id,command})+"\n"); }); }
const sessionId="human-controls";
const { leaseToken }=await request({tool:"open_session",sessionId},true);
const call=(tool,args={})=>request({tool,args,sessionId,leaseToken});
assert.equal((await call("get_app_state",{app_ref:{name:"Practice"}})).ok,true);
const held=call("hold_key",{text:"must be cancelled"});
await until(()=>fs.existsSync(log)&&fs.readFileSync(log,"utf8").includes("child_started"));
const queued=call("type",{text:"must not replay"});
const paused=await control("pause");
assert.equal(paused.mode,"paused"); assert.equal(paused.cleanupPending,false);
assert.equal((await held).ok,false); assert.equal((await queued).ok,false);
assert.equal((await call("type",{text:"while paused"})).error.code,"control_paused");
for(const tool of ["resume","set_control_mode","control","updates"]) assert.equal((await call(tool)).error.code,"tool_not_allowed");
assert.equal((await control("resume")).mode,"ready");
assert.equal((await call("type",{text:"allowed again"})).ok,true);
assert.equal((await control("stop")).mode,"stopped");
await control("resume");
assert.equal((await call("type",{text:"old stopped owner"})).error.code,"control_stopped");
const records=fs.readFileSync(log,"utf8").trim().split("\n").map(JSON.parse);
assert.ok(records.some(record=>record.method==="child_released"));
assert.deepEqual(records.filter(record=>record.method==="type").map(record=>record.text),["allowed again"]);
// Losing the only human-control owner stops a new session too.
daemon.stdio[3].destroy(); await delay(100);
const fresh=await request({tool:"open_session",sessionId:"fresh"},true);
assert.equal((await request({tool:"get_app_state",sessionId:"fresh",leaseToken:fresh.leaseToken})).error.code,"control_stopped");
});
@@ -88,12 +88,19 @@ test("cancelling a later pointer command closes its original input owner promptl
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}));
console.log(JSON.stringify({action_sent:true,input_lease:true,pid:process.pid}));
setTimeout(()=>process.kill(process.pid,'SIGTERM'),20);
`]);
await new Promise(resolve=>setTimeout(resolve,100));
const deadline=Date.now()+5000;
while(true) {
try { process.kill(lease.receipt.pid,0); }
catch(error) { if(error.code==='ESRCH') break; throw error; }
assert.ok(Date.now()<deadline,'the fixture owner must exit');
await new Promise(resolve=>setTimeout(resolve,20));
}
await new Promise(resolve=>setImmediate(resolve));
const started=Date.now();
await assert.rejects(lease.send({point:{x:1,y:2}}),/owner is closed/);
await assert.rejects(lease.send({point:{x:1,y:2}}),error=>error.code==='input_owner_closed');
assert.ok(Date.now()-started<500);
});
@@ -0,0 +1,48 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { deflateRawSync } from "node:zlib";
import { newerVersion, releaseUpdate, validateReleaseZip } from "../app/updates.mjs";
import { replaceMacBundle } from "../app/install-macos.mjs";
const release = () => ({ tag_name:"v0.4.0",assets:[{name:"Codewhale-Computer-Use-0.4.0-macos-universal.zip",browser_download_url:"https://github.com/Hmbown/codewhale-cu-plugin/releases/download/v0.4.0/Codewhale-Computer-Use-0.4.0-macos-universal.zip",digest:`sha256:${"a".repeat(64)}`,size:1024}] });
test("updates only offer a newer stable installer with the exact release identity",()=>{
assert.equal(newerVersion("0.10.0","0.9.13"),true);
for(const value of ["0.9.13","0.8.0","0.10.0-beta","v0.10.0","nonsense"]) assert.equal(newerVersion(value,"0.9.13"),false);
assert.equal(releaseUpdate(release(),"0.3.0").available,true);
for(const change of [{draft:true},{prerelease:true},{tag_name:"v0.2.0"},{assets:[]}]) assert.equal(releaseUpdate({...release(),...change},"0.3.0").available,false);
for(const change of [{digest:null},{size:Infinity},{size:512*1024*1024},{browser_download_url:"https://example.org/app.zip"},{name:"unexpected.zip"}]) {
const data=release(); Object.assign(data.assets[0],change); assert.equal(releaseUpdate(data,"0.3.0").available,false);
}
});
function zip(name,{kind=0x8000,localName=name,size=1,payload=Buffer.from("x"),method=0}={}) {
const local=Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(Buffer.byteLength(localName),26); local.writeUInt32LE(payload.length,18); local.writeUInt32LE(size,22); local.writeUInt16LE(method,8);
const contents=Buffer.concat([local,Buffer.from(localName),payload]);
const central=Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(Buffer.byteLength(name),28); central.writeUInt32LE((kind*65536)>>>0,38); central.writeUInt32LE(payload.length,20); central.writeUInt32LE(size,24); central.writeUInt16LE(method,10);
const index=Buffer.concat([central,Buffer.from(name)]),end=Buffer.alloc(22);end.writeUInt32LE(0x06054b50);end.writeUInt16LE(1,8);end.writeUInt16LE(1,10);end.writeUInt32LE(index.length,12);end.writeUInt32LE(contents.length,16);
return Buffer.concat([contents,index,end]);
}
test("the updater refuses traversal, links, bombs and inconsistent ZIP headers before extraction",()=>{
const name="Codewhale Computer Use.app/Contents/MacOS/node";
assert.equal(validateReleaseZip(zip(name)),1);
for(const unsafe of ["/tmp/escape","Codewhale Computer Use.app/../escape","Codewhale Computer Use.app/a/../../escape","Codewhale Computer Use.app/Contents/evil\\path","Codewhale Computer Use.app/Contents/a:b"]) assert.throws(()=>validateReleaseZip(zip(unsafe)));
for(const options of [{kind:0xa000},{localName:"../escape"},{size:1024*1024*1024}]) assert.throws(()=>validateReleaseZip(zip(name,options)));
assert.throws(()=>validateReleaseZip(Buffer.from("not a ZIP")));
const payload=deflateRawSync(Buffer.alloc(1024*1024));
assert.throws(()=>validateReleaseZip(zip(name,{method:8,payload,size:1})),/oversized/);
assert.equal(validateReleaseZip(zip(name,{method:8,payload,size:1024*1024})),1);
});
test("failed update preparation or verification preserves the complete previous app",t=>{
const directory=fs.mkdtempSync(path.join(os.tmpdir(),"cu-atomic-install-"));t.after(()=>fs.rmSync(directory,{recursive:true,force:true}));
const source=path.join(directory,"source.app"),destination=path.join(directory,"installed.app"),relative="Contents/Resources/plugin/app/daemon.mjs";
for(const [root,value] of [[source,"new"],[destination,"old"]]) {fs.mkdirSync(path.dirname(path.join(root,relative)),{recursive:true});fs.writeFileSync(path.join(root,relative),value);}
assert.throws(()=>replaceMacBundle(source,destination,{verify:()=>{throw new Error("bad signature");}}),/bad signature/);
assert.equal(fs.readFileSync(path.join(destination,relative),"utf8"),"old");
assert.throws(()=>replaceMacBundle(source,destination,{prepare:()=>{throw new Error("disk error");},verify:()=>{}}),/disk error/);
assert.equal(fs.readFileSync(path.join(destination,relative),"utf8"),"old");
const result=replaceMacBundle(source,destination,{verify:()=>{}});
assert.equal(fs.readFileSync(path.join(destination,relative),"utf8"),"new");
assert.equal(fs.readFileSync(path.join(result.backup,relative),"utf8"),"old");
});
+6
View File
@@ -552,6 +552,7 @@ pub fn parse_kimi_plugin_json(text: &str, root: &Path) -> Result<PluginManifest,
license: kimi.license,
keywords: kimi.keywords,
display_name,
icon: None,
},
skills,
commands,
@@ -633,6 +634,8 @@ pub struct CodewhalePluginExtension {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<PluginPathSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commands: Option<PluginPathSpec>,
@@ -654,6 +657,7 @@ impl CodewhalePluginExtension {
#[must_use]
fn is_empty(&self) -> bool {
self.display_name.is_none()
&& self.icon.is_none()
&& self.skills.is_none()
&& self.commands.is_none()
&& self.agents.is_none()
@@ -931,6 +935,7 @@ pub fn standard_to_manifest(
license: standard.license,
keywords: standard.keywords,
display_name: extension.display_name,
icon: extension.icon,
},
skills,
commands: extension.commands,
@@ -1016,6 +1021,7 @@ pub fn manifest_to_standard(
let extension = CodewhalePluginExtension {
display_name: display_name.clone(),
icon: manifest.plugin.icon.clone(),
skills: manifest
.skills
.clone()
+3
View File
@@ -75,6 +75,9 @@ const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
bundle_file!("skills/recording/SKILL.md"),
bundle_file!("agent.mjs"),
bundle_file!("app/daemon.mjs"),
bundle_file!("app/background-check.mjs"),
bundle_file!("app/install-macos.mjs"),
bundle_file!("app/updates.mjs"),
bundle_file!("mcp/server.mjs"),
bundle_file!("src/app-handler.mjs"),
bundle_file!("src/app-socket.mjs"),
+64
View File
@@ -72,6 +72,9 @@ pub struct PluginMeta {
/// slugified to satisfy the Agent Plugins name rule.
#[serde(default)]
pub display_name: Option<String>,
/// Bounded inline PNG artwork. Never a remote fetch or executable SVG.
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub homepage: Option<String>,
#[serde(default)]
@@ -602,6 +605,9 @@ impl PluginManifest {
validate_optional_text("description", self.plugin.description.as_deref(), 1_024)?;
validate_optional_text("author", self.plugin.author.as_deref(), 256)?;
validate_optional_text("display name", self.plugin.display_name.as_deref(), 128)?;
if let Some(icon) = &self.plugin.icon {
validate_icon(icon)?;
}
validate_optional_text("homepage", self.plugin.homepage.as_deref(), 2_048)?;
validate_optional_text("repository", self.plugin.repository.as_deref(), 2_048)?;
validate_optional_text("license", self.plugin.license.as_deref(), 128)?;
@@ -1719,6 +1725,29 @@ pub(crate) fn capability_hash_v1(inventory: &PluginInventory) -> String {
/// Historical v2 capability digest. Kept only to prove that receipts from the
/// Skills/MCP-only activation policy fail closed when v3 enables additional
/// declarative adapters.
/// Catalog and manifest artwork follows one inert, bounded wire format.
pub fn validate_icon(value: &str) -> Result<(), String> {
use base64::Engine;
if value.len() > 32_768 {
return Err("plugin icon exceeds 32 KiB".into());
}
let encoded = value
.strip_prefix("data:image/png;base64,")
.ok_or("plugin icon must be an inline PNG")?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|_| "plugin icon has invalid base64")?;
if bytes.len() < 33 || &bytes[..8] != b"\x89PNG\r\n\x1a\n" || &bytes[12..16] != b"IHDR" {
return Err("plugin icon has an invalid PNG header".into());
}
let width = u32::from_be_bytes(bytes[16..20].try_into().map_err(|_| "invalid PNG width")?);
let height = u32::from_be_bytes(bytes[20..24].try_into().map_err(|_| "invalid PNG height")?);
if width == 0 || height == 0 || width > 256 || height > 256 {
return Err("plugin icon must fit within 256 by 256 pixels".into());
}
Ok(())
}
#[cfg(test)]
pub(crate) fn capability_hash_v2(inventory: &PluginInventory) -> String {
let mut hasher = Sha256::new();
@@ -2366,3 +2395,38 @@ args = ["server.js", "--mode=worker", "-e", "console.log('ready')"]
}
}
}
#[cfg(test)]
mod icon_tests {
use super::validate_icon;
use base64::Engine;
#[test]
fn artwork_is_inline_bounded_png_only() {
let manifest: serde_json::Value =
serde_json::from_str(include_str!("../../plugins/computer-use/plugin.json")).unwrap();
let icon = manifest["extensions"]["net.codewhale"]["icon"]
.as_str()
.unwrap();
assert!(validate_icon(icon).is_ok());
for invalid in [
"https://publisher.example/tracker.png",
"data:image/svg+xml,<svg/>",
"data:image/png;base64,invalid",
] {
assert!(validate_icon(invalid).is_err());
}
let mut png = base64::engine::general_purpose::STANDARD
.decode(icon.strip_prefix("data:image/png;base64,").unwrap())
.unwrap();
png[16..20].copy_from_slice(&100_000_u32.to_be_bytes());
assert!(
validate_icon(&format!(
"data:image/png;base64,{}",
base64::engine::general_purpose::STANDARD.encode(png)
))
.is_err()
);
assert!(validate_icon(&"x".repeat(32_769)).is_err());
}
}
@@ -326,6 +326,7 @@ fn parse_claude_entry(
Some(MarketplaceCandidate {
id: MarketplaceCandidateId::new(catalog_id, &name),
catalog_id: catalog_id.clone(),
icon: None,
name,
display_name,
description: description.map(ToString::to_string),
@@ -28,7 +28,17 @@ use super::super::types::{
use super::{MarketplaceDocument, str_field, unknown_fields_warning};
const TOP_LEVEL_FIELDS: &[&str] = &["name", "description", "version", "plugins"];
const ENTRY_FIELDS: &[&str] = &["name", "source", "description", "version", "homepage"];
const ENTRY_FIELDS: &[&str] = &[
"name",
"source",
"description",
"version",
"homepage",
"display_name",
"author",
"icon",
"platforms",
];
pub fn parse_codewhale_catalog(document: MarketplaceDocument) -> MarketplaceCatalog {
let MarketplaceDocument {
@@ -215,14 +225,77 @@ fn parse_codewhale_entry(
entry_diags.push(diag);
}
let mut labels = Vec::new();
for field in ["display_name", "author", "icon"] {
let (value, bad) = str_field(obj, field);
if let Some(diag) = bad {
entry_diags.push(diag);
}
let bounded = value.filter(|text| {
field == "icon" || (text.chars().count() <= 128 && !text.chars().any(char::is_control))
});
if value.is_some() && bounded.is_none() {
entry_diags.push(MarketplaceDiagnostic::error(
"INVALID_IDENTITY",
format!("{field} must fit on one line within 128 characters"),
Some(name.clone()),
Some(index),
));
}
labels.push(bounded.map(ToString::to_string));
}
let mut icon = labels.pop().flatten();
if let Some(value) = &icon {
if let Err(reason) = crate::plugins::manifest::validate_icon(value) {
entry_diags.push(MarketplaceDiagnostic::error(
"INVALID_ICON",
reason,
Some(name.clone()),
Some(index),
));
icon = None;
}
}
let author = labels.pop().flatten();
let display_name = labels.pop().flatten();
let platforms = obj
.get("platforms")
.and_then(Value::as_array)
.filter(|values| {
values.len() <= 3
&& values
.iter()
.enumerate()
.all(|(i, value)| !values[..i].contains(value))
})
.and_then(|values| {
values
.iter()
.map(|value| {
value
.as_str()
.filter(|os| ["macos", "linux", "windows"].contains(os))
.map(ToString::to_string)
})
.collect::<Option<Vec<_>>>()
});
if obj.contains_key("platforms") && platforms.is_none() {
entry_diags.push(MarketplaceDiagnostic::error(
"INVALID_PLATFORMS",
"platforms must be an array of macos, linux or windows",
Some(name.clone()),
Some(index),
));
}
Some(MarketplaceCandidate {
id: MarketplaceCandidateId::new(catalog_id, &name),
catalog_id: catalog_id.clone(),
icon,
name,
display_name: None,
display_name,
description: description.map(ToString::to_string),
version: version.map(ToString::to_string),
author: None,
author,
homepage: homepage.map(ToString::to_string),
repository: None,
license: None,
@@ -237,7 +310,10 @@ fn parse_codewhale_entry(
publisher: None,
source_url: None,
},
when: None,
when: platforms.map(|os| crate::plugins::manifest::PluginWhen {
os: Some(os),
binaries: None,
}),
diagnostics: entry_diags,
})
}
@@ -264,6 +264,7 @@ fn parse_codex_entry(
Some(MarketplaceCandidate {
id: MarketplaceCandidateId::new(catalog_id, &name),
catalog_id: catalog_id.clone(),
icon: None,
name,
display_name: None,
description: None,
@@ -232,6 +232,7 @@ fn parse_kimi_entry(
Some(MarketplaceCandidate {
id: MarketplaceCandidateId::new(catalog_id, &name),
catalog_id: catalog_id.clone(),
icon: None,
name,
display_name,
description: description.map(ToString::to_string),
@@ -138,6 +138,40 @@ fn parse_auto(id: &str, body: &str) -> super::types::MarketplaceCatalog {
parse(id, MarketplaceFormat::Auto, body)
}
#[test]
fn native_catalog_keeps_identity_without_fetching_artwork_or_granting_trust() {
let manifest: Value =
serde_json::from_str(include_str!("../../../plugins/computer-use/plugin.json")).unwrap();
let icon = manifest["extensions"]["net.codewhale"]["icon"]
.as_str()
.unwrap();
let mut entry = serde_json::json!({"name":"computer-use","source":"github:Hmbown/codewhale-cu-plugin","display_name":"Computer Use","author":"Codewhale","icon":icon,"platforms":["macos","windows","linux"]});
let catalog = parse(
"identity",
MarketplaceFormat::Codewhale,
&serde_json::json!({"name":"identity","plugins":[entry.clone()]}).to_string(),
);
let candidate = catalog.candidate_by_name("computer-use").unwrap();
assert_eq!(candidate.display_name.as_deref(), Some("Computer Use"));
assert_eq!(candidate.author.as_deref(), Some("Codewhale"));
assert_eq!(candidate.icon.as_deref(), Some(icon));
assert_eq!(
candidate.when.as_ref().unwrap().os.as_ref().unwrap().len(),
3
);
assert_eq!(candidate.provenance.tier, CatalogTier::Community);
assert!(!candidate.has_errors());
entry["icon"] = serde_json::json!("https://publisher.example/tracker.png");
let rejected = parse(
"identity",
MarketplaceFormat::Codewhale,
&serde_json::json!({"name":"identity","plugins":[entry]}).to_string(),
);
let candidate = rejected.candidate_by_name("computer-use").unwrap();
assert!(candidate.icon.is_none());
assert!(candidate.has_errors());
}
#[test]
fn kimi_official_marketplace_parses_all_five_real_entries() {
let catalog = parse_auto("kimi", KIMI_OFFICIAL_MARKETPLACE);
@@ -272,6 +272,8 @@ pub struct MarketplaceCandidate {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
+1
View File
@@ -509,6 +509,7 @@ mod tests {
catalog_id: MarketplaceCatalogId::new(catalog),
name: name.to_string(),
display_name: Some(format!("{name} plugin")),
icon: None,
description: Some(format!("{name} integration")),
version: None,
author: None,
+21 -4
View File
@@ -64,6 +64,10 @@ pub(super) struct PluginSummaryEntry {
pub(super) id: String,
pub(super) name: String,
pub(super) display_name: Option<String>,
pub(super) icon: Option<String>,
pub(super) author: Option<String>,
pub(super) homepage: Option<String>,
pub(super) platforms: Vec<String>,
pub(super) version: String,
pub(super) description: Option<String>,
pub(super) scope: &'static str,
@@ -133,8 +137,6 @@ pub(super) struct PluginReviewPayload {
pub(super) struct PluginDetailResponse {
#[serde(flatten)]
pub(super) summary: PluginSummaryEntry,
pub(super) author: Option<String>,
pub(super) homepage: Option<String>,
pub(super) repository: Option<String>,
pub(super) license: Option<String>,
pub(super) keywords: Vec<String>,
@@ -242,6 +244,15 @@ fn plugin_summary(plugin: &LoadedPlugin) -> PluginSummaryEntry {
id: plugin.id.as_str().to_string(),
name: plugin.name().to_string(),
display_name: plugin.manifest.plugin.display_name.clone(),
icon: plugin.manifest.plugin.icon.clone(),
author: plugin.manifest.plugin.author.clone(),
homepage: plugin.manifest.plugin.homepage.clone(),
platforms: plugin
.manifest
.when
.as_ref()
.and_then(|when| when.os.clone())
.unwrap_or_default(),
version: plugin.manifest.plugin.version.clone(),
description: plugin.manifest.plugin.description.clone(),
scope: plugin.scope.as_str(),
@@ -347,8 +358,6 @@ fn review_payload(plugin: &LoadedPlugin) -> PluginReviewPayload {
fn plugin_detail(plugin: &LoadedPlugin) -> PluginDetailResponse {
PluginDetailResponse {
summary: plugin_summary(plugin),
author: plugin.manifest.plugin.author.clone(),
homepage: plugin.manifest.plugin.homepage.clone(),
repository: plugin.manifest.plugin.repository.clone(),
license: plugin.manifest.plugin.license.clone(),
keywords: plugin.manifest.plugin.keywords.clone(),
@@ -562,6 +571,8 @@ pub(super) struct MarketplaceInstallPlanEntry {
pub(super) struct MarketplaceCandidateEntry {
pub(super) name: String,
pub(super) display_name: Option<String>,
pub(super) icon: Option<String>,
pub(super) platforms: Vec<String>,
pub(super) description: Option<String>,
pub(super) version: Option<String>,
pub(super) author: Option<String>,
@@ -645,6 +656,12 @@ fn marketplace_candidate_entry(
MarketplaceCandidateEntry {
name: candidate.name.clone(),
display_name: candidate.display_name.clone(),
icon: candidate.icon.clone(),
platforms: candidate
.when
.as_ref()
.and_then(|when| when.os.clone())
.unwrap_or_default(),
description: candidate.description.clone(),
version: candidate.version.clone(),
author: candidate.author.clone(),