mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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 } : {}) };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user