mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(computer-use): report unknown outcomes and refuse ambiguous targets
Audit 0.10.1 js-ops, computer-use plugin (CU01):
- CU01-07 (Refs #6556): app-socket requestConnection marks every failure
after the request line is written (timeout, cancel, dropped connection,
malformed reply) requestDispatched, so the server receipt carries
outcome_unknown instead of a plain cancelled/app_timeout that invites a
blind retry. The same holds for one-shot ssh/docker agent timeouts and
cancels after spawn, for a local backend error flagged
inputMayHaveBeenSent, and for a helper reply that reports it
(app-handler now forwards request_dispatched).
- CU01-08 (Refs #6558): browser-cdp send() is bounded (30 s default,
CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS) and cancellable; a timeout is
outcome-unknown and a late reply is dropped. stop/close use a 3 s,
signal-free teardown bound, so an open but silent peer can no longer
hang release.
- CU01-05 (Refs #6561): destroyDockerComputer removes only containers
whose session label is this MCP process; computer remove of another
session's disposable desktop is refused (computer_owned_elsewhere) and
leaves its container and registry entry alone.
- CU01-10 (Refs #6557): harmony centerOf re-finds the element by its
observed tree path and requires the observed role and label; a
reordered tree or a bare index is element_stale and sends nothing.
- CU01-11 (Refs #6556): the Darwin native resolver refuses a name or
bundle matching several running apps (ambiguous_application, as
kill_app already did) instead of taking the first listed process.
- CU01-01 (Refs #6561): already fixed before the audit candidate
(433bae98d8, recordingsOutputPath + server strips source); no change.
refusal-codes.md documents outcome_unknown, the widened
ambiguous_application and computer_owned_elsewhere.
Validation (node v26.10.0, macOS, docker available):
- node --test tests/*.test.mjs (plugin): tests 396, pass 380, fail 0,
skipped 16.
- Negative controls (fix file reverted to HEAD, new test run, restored
byte-identical):
app-socket.mjs -> exec-transport "unknown outcome" test: pass 0 fail 1
browser-cdp.mjs -> "unanswered CDP command" test: cancelled 1
('test timed out after 5000ms'; the send never settles)
spawn.mjs -> "another session spawned" test: pass 0 fail 1
harmonyos.mjs -> "reordered tree" test: pass 0 fail 1
darwin-accessibility.m -> native app-targeting test (clang-built
fixture with two "Twin" processes): pass 0 fail 1
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
1 parent
8b6e87bc35
commit
d9015df5b8
15 files changed
+208
-42
No files matched your search
@@ -901,6 +901,11 @@ async function callTool(params) {
|
||||
try { teardown = await destroyDockerComputer(entry); }
|
||||
catch (err) { teardown = { destroyed: false, cleanup_error: err.message ?? String(err) }; }
|
||||
}
|
||||
if (teardown?.reason === "other_session") {
|
||||
// Another live (or crashed) MCP session owns this desktop. Leave its
|
||||
// container and registry entry alone; the owner removes both at exit.
|
||||
return { content: [{ type: "text", text: JSON.stringify(fail(entry, "computer_owned_elsewhere", `computer "${entry.id}" was spawned by another session and is still its disposable desktop — it is removed when that session ends. If that session is gone, remove the container with docker rm -f ${entry.container}.`)) }], isError: true };
|
||||
}
|
||||
const res = registry.remove(args.computer);
|
||||
if (activeComputerId === args.computer) activeComputerId = "local";
|
||||
res.active = activeComputerId;
|
||||
@@ -1107,7 +1112,12 @@ async function callTool(params) {
|
||||
const timeoutMs = backendMethod.startsWith("recording") || backendMethod === "get_app_state" ? 60_000 : 30_000;
|
||||
const invoke = async (tool, a) => {
|
||||
const r = await remoteCall({ tool, args: a }, { timeoutMs });
|
||||
if (!r.ok) throw new ServerError(r.error?.code ?? "remote_error", r.error?.message ?? "remote agent failed");
|
||||
if (!r.ok) {
|
||||
const error = new ServerError(r.error?.code ?? "remote_error", r.error?.message ?? "remote agent failed");
|
||||
// The helper's backend reported that input may already have landed.
|
||||
if (r.error?.request_dispatched === true) error.requestDispatched = true;
|
||||
throw error;
|
||||
}
|
||||
return r.data;
|
||||
};
|
||||
data = name === "type" ? await invokeType(invoke, wireArgs) : await invoke(backendMethod, wireArgs);
|
||||
@@ -1246,7 +1256,9 @@ async function callTool(params) {
|
||||
// A failed open_application cleared the backend's input binding before it
|
||||
// attempted anything — the tracked bound app must not claim otherwise.
|
||||
if (name === "open_application") boundApps.delete(computer.id);
|
||||
let outcomeUnknown = !!err.requestDispatched;
|
||||
// A local backend that timed out or was cancelled after posting input
|
||||
// (inputMayHaveBeenSent) is as unknown as a transport that lost the reply.
|
||||
let outcomeUnknown = !!(err.requestDispatched || err.inputMayHaveBeenSent);
|
||||
if (dispatched && !outcomeUnknown) {
|
||||
// A transport/backend can fail after delivering input. Reconcile its
|
||||
// captured route on failure too, without replacing the original error
|
||||
|
||||
@@ -30,7 +30,7 @@ Never retry a refusal unchanged — re-observe, re-target, or change route.
|
||||
| `menu_item_not_found` | exact title not present (menus expose items only while open) | check the exact title; an ellipsis is part of it |
|
||||
| `menu_item_disabled` | item present but the app refuses it right now (often a missing key window) | use the window's own control element instead |
|
||||
| `app_not_found` | selector missed — `open_application` names/bundle ids that resolve nowhere and dead pids report it too | `list_apps` (or `all:true`) for exact names/pids |
|
||||
| `ambiguous_application` | `kill_app` name matched several running apps | pass `pid` to choose one |
|
||||
| `ambiguous_application` | an app name or bundle id matched several running apps (observation, input or `kill_app`) | pass `pid` to choose one |
|
||||
| `protected_application` | the target is the Computer Use helper or its host | name the intended app instead; these cannot be terminated through the plugin |
|
||||
| `browser_not_running` | browser action before `browser {action:"start"}` (or the browser went away) | start it; a closed CDP connection clears the session state |
|
||||
| `browser_not_installed` | no Chromium-family browser found | install one, or set `CODEWHALE_CU_BROWSER_APP` to the app path |
|
||||
@@ -56,6 +56,7 @@ Never retry a refusal unchanged — re-observe, re-target, or change route.
|
||||
| `spawn_failed` | provisioning failed or the desktop did not become ready | read the message; the failed container is removed automatically — fix the cause and spawn again |
|
||||
| `invalid_container` | a docker registry entry lacks a valid container name | register it through `computer spawn`, never by hand |
|
||||
| `cleanup_failed` | `docker rm` failed while tearing down a spawned computer | the registry entry is still removed; check `docker ps` for the labeled container and remove it manually |
|
||||
| `computer_owned_elsewhere` | `computer remove` named a desktop another session spawned | leave it; its session removes it at exit (the message names the container if that session is gone) |
|
||||
| `script_error` | osascript exited non-zero; stderr is in the message | read the error, check the app's scripting dictionary (`sdef`), fix the script |
|
||||
| `script_timeout` | the script — or a consent dialog — was still open at the deadline | narrow the script; a consent prompt is the person's choice, report it |
|
||||
| `script_cancelled` | the script's own dialog was cancelled (-128) | the user declined in-app; stop or ask |
|
||||
@@ -75,5 +76,7 @@ computer; a typing pause does not authorize foreground control.
|
||||
not task success. Verify the effect with a fresh observation.
|
||||
- `front_lease` / `front_restored` describe focus accounting for window-record
|
||||
deliveries in explicitly authorized foreground mode. `front_restored:false` is a person-visible event: say it out loud.
|
||||
- `input_may_have_been_sent` on an error means the press left before the
|
||||
failure: observe the target before doing anything else.
|
||||
- `outcome_unknown: true` (with `request_dispatched: true`) on an error means
|
||||
the action was already handed to the helper, remote agent or browser when it
|
||||
timed out, was cancelled or lost its reply: observe the target before doing
|
||||
anything else, and never retry the action blindly.
|
||||
@@ -226,7 +226,7 @@ export async function handle(req, { computerId = "local", sessionId = "direct",
|
||||
return { ok: true, platform: process.platform, tool, data };
|
||||
}));
|
||||
} catch (err) {
|
||||
return { ok: false, platform: process.platform, tool, error: { code: err?.code ?? "tool_error", message: String(err?.message ?? err) } };
|
||||
return { ok: false, platform: process.platform, tool, error: { code: err?.code ?? "tool_error", message: String(err?.message ?? err), ...(err?.inputMayHaveBeenSent || err?.requestDispatched ? { request_dispatched: true } : {}) } };
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
session.requests.delete(controller);
|
||||
|
||||
@@ -74,27 +74,37 @@ export function writeRegistration(reg) {
|
||||
fs.writeFileSync(registrationPath(), JSON.stringify({ ...reg, registeredAt: new Date().toISOString() }, null, 2) + "\n");
|
||||
}
|
||||
|
||||
/** Send one request to the app and await its single-line reply. */
|
||||
/**
|
||||
* Send one request to the app and await its single-line reply.
|
||||
*
|
||||
* Once the request line is written the helper may already have acted on it,
|
||||
* so every failure after that point (timeout, cancel, a dropped connection, a
|
||||
* malformed reply) carries `requestDispatched: true`. The MCP server turns
|
||||
* that into `outcome_unknown` instead of a plain cancel that invites a blind
|
||||
* retry of an input action that may have landed.
|
||||
*/
|
||||
function requestConnection(request, { timeoutMs = 30_000, signal = currentSignal(), keepOpen = false } = {}) {
|
||||
throwIfAborted(signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
const sock = net.connect(socketPath());
|
||||
let buf = "";
|
||||
let settled = false;
|
||||
let written = false;
|
||||
const failure = (message, code) => Object.assign(new ExecError(message), { code }, written ? { requestDispatched: true } : {});
|
||||
const done = (fn, v) => { if (settled) return; settled = true; clearTimeout(timer); signal?.removeEventListener("abort", abort); if (!keepOpen || fn === reject) sock.destroy(); fn(v); };
|
||||
const abort = () => done(reject, Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
|
||||
const timer = setTimeout(() => done(reject, Object.assign(new ExecError(`${APP_NAME}: request timed out after ${timeoutMs}ms`), { code: "app_timeout" })), timeoutMs);
|
||||
const abort = () => done(reject, failure("computer request cancelled", "cancelled"));
|
||||
const timer = setTimeout(() => done(reject, failure(`${APP_NAME}: request timed out after ${timeoutMs}ms`, "app_timeout")), timeoutMs);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
sock.on("error", (err) => done(reject, Object.assign(new ExecError(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`), { code: "app_unavailable" })));
|
||||
sock.on("connect", () => sock.write(JSON.stringify(request) + "\n"));
|
||||
sock.on("error", (err) => done(reject, failure(`${APP_NAME} is not reachable at ${socketPath()}: ${err.code ?? err.message}`, "app_unavailable")));
|
||||
sock.on("connect", () => { written = true; sock.write(JSON.stringify(request) + "\n"); });
|
||||
sock.on("data", (d) => {
|
||||
buf += d.toString("utf8");
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) return;
|
||||
try { const reply = JSON.parse(buf.slice(0, nl)); done(resolve, keepOpen ? { reply, socket: sock } : reply); }
|
||||
catch { done(reject, Object.assign(new ExecError(`${APP_NAME}: malformed reply`), { code: "app_bad_reply" })); }
|
||||
catch { done(reject, failure(`${APP_NAME}: malformed reply`, "app_bad_reply")); }
|
||||
});
|
||||
sock.on("close", () => done(reject, Object.assign(new ExecError(`${APP_NAME}: connection closed before a reply`), { code: "app_unavailable" })));
|
||||
sock.on("close", () => done(reject, failure(`${APP_NAME}: connection closed before a reply`, "app_unavailable")));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -856,13 +856,23 @@ static NSRunningApplication *resolve(NSDictionary *ref) {
|
||||
} else return nil;
|
||||
}
|
||||
NSString *bundle=ref[@"bundle_id"], *name=ref[@"name"];
|
||||
// A name or bundle shared by several running processes (two instances of
|
||||
// one browser, a helper and its app) must not resolve to whichever happens
|
||||
// to be listed first: that sends observation and input to the wrong window.
|
||||
// Refuse like kill_app does; the observed pid disambiguates.
|
||||
NSMutableArray<NSRunningApplication *> *hits=[NSMutableArray array];
|
||||
for(NSRunningApplication *a in NSWorkspace.sharedWorkspace.runningApplications) {
|
||||
if(ref[@"pid"] && a.processIdentifier!=[ref[@"pid"] intValue]) continue;
|
||||
if(bundle && !matchesName(a.bundleIdentifier,bundle)) continue;
|
||||
if(name && !matchesName(a.localizedName,name)) continue;
|
||||
return a;
|
||||
[hits addObject:a];
|
||||
}
|
||||
return nil;
|
||||
if(hits.count>1) {
|
||||
NSMutableArray *desc=[NSMutableArray array];
|
||||
for(NSRunningApplication *a in hits) [desc addObject:[NSString stringWithFormat:@"%@ (pid %d)",a.localizedName?:@"?",a.processIdentifier]];
|
||||
@throw [NSException exceptionWithName:@"app" reason:[NSString stringWithFormat:@"several running applications match (%@); pass pid to choose one",[desc componentsJoinedByString:@", "]] userInfo:nil];
|
||||
}
|
||||
return hits.firstObject;
|
||||
}
|
||||
static CGEventRef textEvent(NSString *text, BOOL down) {
|
||||
UniChar *chars=calloc(text.length,sizeof(UniChar)); [text getCharacters:chars range:NSMakeRange(0,text.length)];
|
||||
|
||||
@@ -105,12 +105,22 @@ export function create({ exec }) {
|
||||
return { action_sent: true, strategy: "event", backend: "uitest" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-find an observed element in a fresh dump. uitest indexes are
|
||||
* positional and shift whenever a sibling appears, so the index alone can
|
||||
* name a different control; the element is addressed by its observed tree
|
||||
* path and must still carry the observed role and label, or nothing is sent.
|
||||
*/
|
||||
async function centerOf(target) {
|
||||
rejectAppSelectors(target);
|
||||
const stale = (why) => Object.assign(new ExecError(`element_stale — ${why}; re-run get_app_state (uitest indexes change with the UI)`), { code: "element_stale" });
|
||||
if (!Array.isArray(target?.path)) throw stale("the element target carries no observed tree path");
|
||||
const tree = await dumpLayout();
|
||||
const els = flatten(tree);
|
||||
const el = els[target.index];
|
||||
if (!el || !el.bounds) throw new ExecError("element_stale — re-run get_app_state; uitest indexes change with the UI");
|
||||
const key = JSON.stringify(target.path);
|
||||
const el = flatten(tree).find((candidate) => JSON.stringify(candidate.path) === key);
|
||||
if (!el || !el.bounds) throw stale("the observed element is gone");
|
||||
if (target.role !== undefined && el.role !== target.role) throw stale(`role changed (${target.role} → ${el.role})`);
|
||||
if (target.label !== undefined && el.label !== target.label) throw stale(`label changed (${target.label} → ${el.label})`);
|
||||
return el.bounds;
|
||||
}
|
||||
|
||||
|
||||
@@ -191,6 +191,12 @@ function defaultConnect(url, { timeoutMs = 8_000 } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-command CDP reply deadline; teardown uses its own shorter bound. */
|
||||
const commandTimeoutMs = () => Number(process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS) || 30_000;
|
||||
// Teardown ignores the (possibly already cancelled) request signal and gives
|
||||
// an unanswered peer at most three seconds per command.
|
||||
const teardown = () => ({ timeoutMs: Math.min(3_000, commandTimeoutMs()), signal: null });
|
||||
|
||||
/** id-matched JSON-RPC over the WebSocket, plus CDP event fan-out. */
|
||||
function makeChannel(ws) {
|
||||
let nextId = 0;
|
||||
@@ -199,7 +205,7 @@ function makeChannel(ws) {
|
||||
let closed = false;
|
||||
const failAll = (reason) => {
|
||||
closed = true;
|
||||
for (const [, entry] of pending) entry.reject(Object.assign(new ExecError(reason), { code: "browser_not_running" }));
|
||||
for (const entry of [...pending.values()]) entry.reject(Object.assign(new ExecError(reason), { code: "browser_not_running" }));
|
||||
pending.clear();
|
||||
};
|
||||
ws.addEventListener("message", (event) => {
|
||||
@@ -218,11 +224,29 @@ function makeChannel(ws) {
|
||||
ws.addEventListener("error", () => {});
|
||||
return {
|
||||
get alive() { return !closed; },
|
||||
send(method, params = {}, sessionId) {
|
||||
/**
|
||||
* One CDP command. Bounded: a peer that keeps the socket open but never
|
||||
* answers must not hang the request (or a stop/teardown awaiting it)
|
||||
* forever. After the command is written its effect is unknown, so a
|
||||
* timeout or cancel carries requestDispatched; a late reply is dropped.
|
||||
*/
|
||||
send(method, params = {}, sessionId, { timeoutMs = commandTimeoutMs(), signal = currentSignal() } = {}) {
|
||||
if (closed) return Promise.reject(Object.assign(new ExecError("the browser is no longer reachable (CDP connection closed)"), { code: "browser_not_running" }));
|
||||
if (signal?.aborted) return Promise.reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled" }));
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
pending.set(id, { resolve, reject });
|
||||
let timer = null;
|
||||
const finish = () => { clearTimeout(timer); signal?.removeEventListener("abort", onAbort); pending.delete(id); };
|
||||
const onAbort = () => { finish(); reject(Object.assign(new ExecError("computer request cancelled"), { code: "cancelled", requestDispatched: true })); };
|
||||
pending.set(id, {
|
||||
resolve: (value) => { finish(); resolve(value); },
|
||||
reject: (error) => { finish(); reject(error); },
|
||||
});
|
||||
timer = setTimeout(() => {
|
||||
finish();
|
||||
reject(Object.assign(new ExecError(`CDP ${method} got no reply within ${timeoutMs}ms`), { code: "timeout", requestDispatched: true }));
|
||||
}, timeoutMs);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
||||
});
|
||||
},
|
||||
@@ -280,8 +304,8 @@ export function createBrowser({
|
||||
const { targetInfo: info } = await state.channel.send("Target.getTargetInfo", { targetId });
|
||||
return { url: info?.url ?? "", title: info?.title ?? "" };
|
||||
}
|
||||
async function listTabs() {
|
||||
const { targetInfos } = await state.channel.send("Target.getTargets", {});
|
||||
async function listTabs(options) {
|
||||
const { targetInfos } = await state.channel.send("Target.getTargets", {}, undefined, options);
|
||||
return (targetInfos ?? []).filter((t) => t.type === "page");
|
||||
}
|
||||
async function ensureDom() {
|
||||
@@ -570,17 +594,17 @@ export function createBrowser({
|
||||
if (!state.channel) return { running: false, note: "no browser session for this computer session" };
|
||||
if (state.attached) {
|
||||
// The person's browser: never close a tab or the browser, only detach.
|
||||
if (state.sessionId) await state.channel.send("Target.detachFromTarget", { sessionId: state.sessionId }).catch(() => {});
|
||||
if (state.sessionId) await state.channel.send("Target.detachFromTarget", { sessionId: state.sessionId }, undefined, teardown()).catch(() => {});
|
||||
state.channel.close();
|
||||
state.channel = null; state.targetId = null; state.sessionId = null; state.pageEnabled = false; state.domEnabled = false; state.attached = false;
|
||||
return { running: false, detached: true, browser_closed: false, note: "detached from the shared browser; its window and tabs stay as they are" };
|
||||
}
|
||||
try { await state.channel.send("Target.closeTarget", { targetId: state.targetId }); } catch { /* the tab may already be gone */ }
|
||||
try { await state.channel.send("Target.closeTarget", { targetId: state.targetId }, undefined, teardown()); } catch { /* the tab may already be gone */ }
|
||||
let remaining = null;
|
||||
try { remaining = (await listTabs()).length; } catch { remaining = null; }
|
||||
try { remaining = (await listTabs(teardown())).length; } catch { remaining = null; }
|
||||
let browserClosed = false;
|
||||
if (remaining === 0) {
|
||||
try { await state.channel.send("Browser.close"); browserClosed = true; } catch {}
|
||||
try { await state.channel.send("Browser.close", {}, undefined, teardown()); browserClosed = true; } catch {}
|
||||
await sleep(200);
|
||||
}
|
||||
state.channel.close();
|
||||
|
||||
@@ -116,19 +116,23 @@ export async function spawnDockerComputer({ id, image = DEFAULT_IMAGE } = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy a spawned container — but only one this plugin created. A docker
|
||||
* computer whose container lacks our spawn label is left running and reported
|
||||
* not_spawned; removing its registry entry is still the caller's choice.
|
||||
* Destroy a spawned container — but only one this MCP process created. A
|
||||
* docker computer whose container lacks our spawn label is left running and
|
||||
* reported not_spawned; one spawned by another session (the registry is
|
||||
* shared between MCP processes) is left running and reported other_session,
|
||||
* so one task cannot tear down a desktop another task is driving.
|
||||
*/
|
||||
export async function destroyDockerComputer(computer) {
|
||||
export async function destroyDockerComputer(computer, command = docker) {
|
||||
const container = computer?.container;
|
||||
if (!container || !CONTAINER_RE.test(container)) {
|
||||
throw new SpawnError("invalid_container", "docker computer has no valid container name");
|
||||
}
|
||||
const insp = await docker(["container", "inspect", "--format", `{{index .Config.Labels "${SPAWN_LABEL}"}}`, container], { timeoutMs: 10_000, signal: null });
|
||||
const insp = await command(["container", "inspect", "--format", `{{index .Config.Labels "${SPAWN_LABEL}"}}|{{index .Config.Labels "${SESSION_LABEL}"}}`, container], { timeoutMs: 10_000, signal: null });
|
||||
if (insp.code !== 0) return { destroyed: false, reason: "container_gone" };
|
||||
if (insp.stdout.trim() !== "1") return { destroyed: false, reason: "not_spawned" };
|
||||
const r = await docker(["rm", "-f", container], { timeoutMs: 20_000, signal: null });
|
||||
const [spawned, session] = insp.stdout.trim().split("|");
|
||||
if (spawned !== "1") return { destroyed: false, reason: "not_spawned" };
|
||||
if (session !== SESSION_ID) return { destroyed: false, reason: "other_session" };
|
||||
const r = await command(["rm", "-f", container], { timeoutMs: 20_000, signal: null });
|
||||
if (r.code !== 0) throw new SpawnError("cleanup_failed", `docker rm -f ${container} failed: ${trim(r.stderr)}`, r);
|
||||
return { destroyed: true };
|
||||
}
|
||||
|
||||
@@ -218,8 +218,10 @@ export function sshExec(computer, binding) {
|
||||
const r = await run("ssh", [...base, "node", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
|
||||
timeoutMs: opts.timeoutMs ?? 25_000,
|
||||
});
|
||||
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
|
||||
if (r.timedOut) throw new ExecError(`ssh ${userHost}: timed out`, r);
|
||||
// A spawned one-shot agent may have acted before it was cancelled or
|
||||
// timed out; say so rather than inviting a blind retry.
|
||||
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" }, r.spawned ? { requestDispatched: true } : {});
|
||||
if (r.timedOut) throw Object.assign(new ExecError(`ssh ${userHost}: timed out`, r), { code: "remote_timeout", requestDispatched: true });
|
||||
if (r.code !== 0) throw new ExecError(`ssh ${userHost} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
|
||||
// The agent prints exactly one JSON line; anything before it is MOTD noise.
|
||||
const line = r.stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop();
|
||||
@@ -254,8 +256,8 @@ export function dockerExec(computer, binding) {
|
||||
const r = await run("docker", ["exec", container, "/bin/sh", remoteAgent, b64({ args: request.args ?? {}, tool: request.tool, nonce: crypto.randomBytes(6).toString("hex") })], {
|
||||
timeoutMs: opts.timeoutMs ?? 25_000,
|
||||
});
|
||||
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" });
|
||||
if (r.timedOut) throw new ExecError(`docker exec ${container}: timed out`, r);
|
||||
if (r.aborted) throw Object.assign(new ExecError("computer request cancelled", r), { code: "cancelled" }, r.spawned ? { requestDispatched: true } : {});
|
||||
if (r.timedOut) throw Object.assign(new ExecError(`docker exec ${container}: timed out`, r), { code: "remote_timeout", requestDispatched: true });
|
||||
if (r.code !== 0) throw new ExecError(`docker exec ${container} exited ${r.code}: ${r.stderr.trim().slice(0, 400)}`, r);
|
||||
const line = r.stdout.trim().split("\n").filter((l) => l.startsWith("{")).pop();
|
||||
const reply = line ? JSON.parse(line) : null;
|
||||
|
||||
@@ -71,6 +71,17 @@ test('real handler/backend/native resolver never redirects an explicit app refer
|
||||
assert.equal(reply.ok,false,`${tool} must reject ${JSON.stringify(app_ref)}`);
|
||||
assert.match(reply.error.message,/application not found/);
|
||||
}
|
||||
// A shared name or bundle must never pick whichever process is listed first.
|
||||
for(const app_ref of [{name:'Twin'},{bundle_id:'test.twin'},{name:'twin',bundle_id:'test.twin'}]) {
|
||||
const reply=await call(tool,{app_ref});
|
||||
assert.equal(reply.ok,false,`${tool} must refuse ambiguous ${JSON.stringify(app_ref)}`);
|
||||
assert.equal(reply.error.code,'ambiguous_application',JSON.stringify(reply));
|
||||
assert.match(reply.error.message,/Twin \(pid 200\), Twin \(pid 201\)/);
|
||||
}
|
||||
}
|
||||
for(const pid of [200,201]) {
|
||||
const reply=await call('list_windows',{app_ref:{pid,name:'Twin'}});
|
||||
assert.equal(reply.ok,true,JSON.stringify(reply));assert.equal(reply.data.pid,pid);
|
||||
}
|
||||
} finally { delete process.env.CU_TARGETING_NATIVE; }
|
||||
});
|
||||
@@ -47,7 +47,7 @@ function harmonyFixtureExec(t) {
|
||||
return fakeJpeg(168, 120);
|
||||
},
|
||||
};
|
||||
return { exec, calls };
|
||||
return { exec, calls, layout };
|
||||
}
|
||||
|
||||
test("harmony: parseBounds handles uitest bounds strings", () => {
|
||||
@@ -102,6 +102,24 @@ test("harmony: click routes through uitest uiInput with validated args", async (
|
||||
assert.deepEqual(ui.args, ["uitest", "uiInput", "click", "124", "45"]);
|
||||
});
|
||||
|
||||
test("harmony: element actions re-find the observed element and refuse a reordered tree", async () => {
|
||||
const { exec, calls, layout } = harmonyFixtureExec();
|
||||
const mod = await import("../src/backends/harmonyos.mjs");
|
||||
const b = mod.create({ exec });
|
||||
const ok = (await b.get_app_state({})).elements.find((e) => e.label === "OK");
|
||||
const target = { index: ok.index, path: ok.path, role: ok.role, label: ok.label };
|
||||
await b.perform_action({ target, action: "click" });
|
||||
assert.deepEqual(calls.filter((c) => c.args?.[1] === "uiInput").at(-1).args, ["uitest", "uiInput", "click", "200", "230"]);
|
||||
// A new sibling ahead of OK shifts every uitest index: the stored index now
|
||||
// names "Delete". Nothing may be clicked.
|
||||
layout.children.unshift({ attributes: { type: "Button", text: "Delete", bounds: "[0,300][100,360]" }, children: [] });
|
||||
const before = calls.filter((c) => c.args?.[1] === "uiInput").length;
|
||||
await assert.rejects(b.perform_action({ target, action: "click" }), (error) => error.code === "element_stale" && /label changed/.test(error.message));
|
||||
await assert.rejects(b.set_value({ target, value: "x" }), (error) => error.code === "element_stale");
|
||||
await assert.rejects(b.perform_action({ target: { index: ok.index }, action: "click" }), (error) => error.code === "element_stale", "a bare index has no identity to verify");
|
||||
assert.equal(calls.filter((c) => c.args?.[1] === "uiInput").length, before, "no input after the tree changed");
|
||||
});
|
||||
|
||||
test("harmony: clipboard and select_text fail closed with named reasons", async () => {
|
||||
const { exec } = harmonyFixtureExec();
|
||||
const mod = await import("../src/backends/harmonyos.mjs");
|
||||
|
||||
@@ -271,3 +271,19 @@ test('findBrowserApp uses actual Windows vendor folders and executable names', (
|
||||
assert.equal(findBrowserApp('win32', { [root]: 'C:\\fixture' }, candidate => candidate === expected), expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("an unanswered CDP command times out as outcome-unknown and stop still finishes", async (t) => {
|
||||
const prior = process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS;
|
||||
process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS = "60";
|
||||
t.after(() => { if (prior === undefined) delete process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS; else process.env.CODEWHALE_CU_BROWSER_COMMAND_TIMEOUT_MS = prior; });
|
||||
// An open peer that never answers these commands (a wedged renderer).
|
||||
const script = { ...defaultScript(pageState()), "Input.insertText": undefined, "Target.closeTarget": undefined, "Browser.close": undefined };
|
||||
const { browser, sockets } = harness(t, { script });
|
||||
await browser.start({});
|
||||
await assert.rejects(browser.type({ text: "hello" }), (error) => error.code === "timeout" && error.requestDispatched === true && /Input\.insertText/.test(error.message));
|
||||
const stopped = await Promise.race([browser.stop(), new Promise((resolve) => setTimeout(() => resolve("hung"), 2_000))]);
|
||||
assert.notEqual(stopped, "hung", "stop must not wait forever on an unanswered peer");
|
||||
assert.equal(stopped.closed, true);
|
||||
assert.equal(sockets[0].ws.sentOf("Target.closeTarget").length, 1);
|
||||
assert.equal((await browser.status()).running, false);
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { run, runOk, runInputLease, ExecError, have, trim, withSignal } from "../src/exec.mjs";
|
||||
import { safeRemotePath, b64, localExec, hdcExec, executorFor } from "../src/transport.mjs";
|
||||
import { ensureApp, writeRegistration } from "../src/app-socket.mjs";
|
||||
import { appRequest, ensureApp, writeRegistration } from "../src/app-socket.mjs";
|
||||
|
||||
test("a missing registered bundle gives a repair path without falling back to host input",async t=>{
|
||||
const directory=fs.mkdtempSync(path.join(os.tmpdir(),"cu-missing-app-"));
|
||||
@@ -208,3 +208,28 @@ test("hdc pullFile rejects traversal and shell metacharacters before execution",
|
||||
await assert.rejects(ex.pullFile(remote, "/unused-fixture-output"), /refusing unsafe remote path/);
|
||||
}
|
||||
});
|
||||
|
||||
test("a helper request that was written reports an unknown outcome when it times out or is cancelled", {skip:process.platform==="win32"}, async t => {
|
||||
const dir=fs.mkdtempSync(path.join(os.tmpdir(),"cu-silent-helper-"));
|
||||
const saved=process.env.CODEWHALE_CU_APP_SOCKET;
|
||||
process.env.CODEWHALE_CU_APP_SOCKET=path.join(dir,"app.sock");
|
||||
const received=[];
|
||||
// Accepts and reads the request, then never answers (a wedged helper).
|
||||
const server=net.createServer(socket=>socket.on("data",data=>received.push(String(data))));
|
||||
await new Promise(resolve=>server.listen(process.env.CODEWHALE_CU_APP_SOCKET,resolve));
|
||||
t.after(async()=>{
|
||||
server.close();
|
||||
if(saved===undefined) delete process.env.CODEWHALE_CU_APP_SOCKET; else process.env.CODEWHALE_CU_APP_SOCKET=saved;
|
||||
fs.rmSync(dir,{recursive:true,force:true});
|
||||
});
|
||||
await assert.rejects(appRequest({tool:"left_click",args:{}},{timeoutMs:80,signal:null}),error=>error.code==="app_timeout"&&error.requestDispatched===true);
|
||||
const controller=new AbortController();
|
||||
const pending=appRequest({tool:"left_click",args:{}},{timeoutMs:5_000,signal:controller.signal});
|
||||
while(received.length<2) await new Promise(resolve=>setTimeout(resolve,5));
|
||||
controller.abort();
|
||||
await assert.rejects(pending,error=>error.code==="cancelled"&&error.requestDispatched===true);
|
||||
assert.equal(received.length,2,"each request reached the helper before its failure");
|
||||
// Nothing written: an unreachable helper is a plain failure, safe to retry.
|
||||
process.env.CODEWHALE_CU_APP_SOCKET=path.join(dir,"absent.sock");
|
||||
await assert.rejects(appRequest({tool:"left_click",args:{}},{timeoutMs:1_000,signal:null}),error=>error.code==="app_unavailable"&&error.requestDispatched===undefined);
|
||||
});
|
||||
@@ -20,7 +20,12 @@
|
||||
CUFixtureApplication *target=[CUFixtureApplication new], *front=[CUFixtureApplication new];
|
||||
target.processIdentifier=123; target.localizedName=@"Fixture"; target.bundleIdentifier=@"test.fixture";
|
||||
front.processIdentifier=999; front.localizedName=@"Other"; front.bundleIdentifier=@"test.other";
|
||||
return @[front,target];
|
||||
// Two running instances sharing one name and bundle (a second browser
|
||||
// profile, an `open -n` copy): only a pid may choose between them.
|
||||
CUFixtureApplication *twinA=[CUFixtureApplication new], *twinB=[CUFixtureApplication new];
|
||||
twinA.processIdentifier=200; twinA.localizedName=@"Twin"; twinA.bundleIdentifier=@"test.twin";
|
||||
twinB.processIdentifier=201; twinB.localizedName=@"Twin"; twinB.bundleIdentifier=@"test.twin";
|
||||
return @[front,target,twinA,twinB];
|
||||
}
|
||||
- (NSRunningApplication *)frontmostApplication { return (NSRunningApplication *)self.runningApplications[0]; }
|
||||
@end
|
||||
@@ -50,7 +55,9 @@ int main(int argc, const char *argv[]) { @autoreleasepool {
|
||||
return 0;
|
||||
}
|
||||
if(![@[@"list_windows",@"get_app_state",@"resolve_element",@"window_info"] containsObject:request[@"tool"]]) return 2;
|
||||
NSRunningApplication *app=resolve(request[@"args"][@"app_ref"]);
|
||||
NSRunningApplication *app=nil;
|
||||
@try { app=resolve(request[@"args"][@"app_ref"]); }
|
||||
@catch(NSException *error) { fprintf(stderr,"%s\n",error.reason.UTF8String); return 1; }
|
||||
if(!app) { fputs("application not found\n",stderr); return 1; }
|
||||
NSDictionary *receipt=@{@"found":@YES,@"pid":@(app.processIdentifier),@"name":app.localizedName,@"bundle_id":app.bundleIdentifier,@"windows":@[],@"elements":@[]};
|
||||
NSData *json=[NSJSONSerialization dataWithJSONObject:receipt options:0 error:nil];
|
||||
|
||||
@@ -231,3 +231,17 @@ test("Docker desktop entrypoint survives repeated orderly restarts", { ...NEED_D
|
||||
assert.equal(observed, true, `desktop unavailable after restart ${cycle}: ${last}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("destroyDockerComputer never removes a desktop another session spawned", async () => {
|
||||
const issued = [];
|
||||
const labels = (value) => async (args) => {
|
||||
issued.push(args);
|
||||
return args[0] === "container" ? { code: 0, stdout: `${value}\n`, stderr: "" } : { code: 0, stdout: "", stderr: "" };
|
||||
};
|
||||
const computer = { container: "cu-spawn-other-ab12cd" };
|
||||
assert.deepEqual(await spawnMod.destroyDockerComputer(computer, labels("1|another-session")), { destroyed: false, reason: "other_session" });
|
||||
assert.deepEqual(await spawnMod.destroyDockerComputer(computer, labels("|")), { destroyed: false, reason: "not_spawned" });
|
||||
assert.equal(issued.filter((args) => args[0] === "rm").length, 0, "no docker rm for a container this session does not own");
|
||||
assert.deepEqual(await spawnMod.destroyDockerComputer(computer, labels(`1|${SESSION_ID}`)), { destroyed: true });
|
||||
assert.deepEqual(issued.at(-1), ["rm", "-f", "cu-spawn-other-ab12cd"]);
|
||||
});
|
||||
Reference in new issue
Block a user