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:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 00:35:36 -07:00
1 parent 8b6e87bc35
commit d9015df5b8
15 files changed
+208 -42

No files matched your search

+14 -2
View File
@@ -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();
+11 -7
View File
@@ -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"]);
});