fix(computer): re-vendor Computer Use plugin at eb5eac7 with verified type delivery and session re-lease

Vendor codewhale-cu-plugin eb5eac7d06e3ef5c1f0d9f246cbe87f3f706c47f, two fixes over the 4ab2b7d snapshot: the type tool now verifies delivery against the focused control and fails closed on clearly non-text focus (d0cf694), and app-session leases re-open transparently after a daemon restart or update instead of bricking the MCP session (eb5eac7). The macOS helper is rebuilt from the vendored sources by crates/tui/build.rs with the canonical clang line; vendored README/package.json stay lane-specific and the Kimi manifest test stays unvendored with its manifest.

Validation: vendored npm test 130 passed 0 failed; cargo test -p codewhale-tui --lib plugins 168 passed 0 failed with RUST_MIN_STACK=16777216 (default 2MB test-thread stack overflows kimi_managed_import_is_read_only_until_hash_bound_approval at HEAD before this change; pre-existing, unrelated); builtin tests 5 passed including the embed-consistency guard and the runnable native helper with empty PATH; rebuilt arm64 helper ad-hoc runtime-signed as net.codewhale.computer-use.helper and answers the permissions probe; 40 of 43 vendored upstream files byte-match eb5eac7, the 3 exceptions being the deliberate lane divergences above.

Integration gate evidence (integrate/cu-launch-runtime-20260907, rebased onto origin/main aedb88b95, 2026-09-07, CARGO_TARGET_DIR=target-cu-integration, RUST_MIN_STACK=16777216):
- cargo nextest run -p codewhale-tui --all-features --locked --profile ci: 12252 passed, 0 failed, 13 skipped in 146.8s (12252 tests across 4 binaries)
- cargo clippy -p codewhale-tui --all-targets --all-features --locked -- -D warnings (-A uninlined_format_args/too_many_arguments/unnecessary_map_or, same allowances as CI): exit 0 in ~102s
- cargo test -p codewhale-tui --all-features --locked --doc: 0 run, 8 ignored, exit 0
- cargo test -p codewhale-tui --lib --locked plugins: 168 passed, 0 failed (11760 filtered out) in 1.4s
- vendored node suite (cd crates/tui/plugins/computer-use && npm test): 130 passed, 0 failed in 5.8s
- node --test crates/tui/tests/runtime_web_client.test.mjs: 36 passed, 0 failed
- cargo fmt --all -- --check: clean
- bash .github/scripts/update-homebrew-tap.test.sh: passed
- ./scripts/release/check-versions.sh --range-audit-advisory: OK
Not run here: workspace-wide nextest/clippy, Windows/Linux legs, vscode extension suites, OHOS lane; scope of this slice is crates/tui Rust + vendored plugin + install scripts only.
This commit is contained in:
CodeWhale Bot committed 2026-09-07 16:48:04 -07:00
1 parent 9990987c8e
commit 050f916afd
9 files changed
+175 -27

No files matched your search

+1 -1
View File
@@ -1 +1 @@
4ab2b7d80419a579ae3e1ba8ae74e4c7bec5fbab
eb5eac7d06e3ef5c1f0d9f246cbe87f3f706c47f
@@ -16,7 +16,7 @@
import fs from "node:fs";
import net from "node:net";
import crypto from "node:crypto";
import { handle, closeSession, closeAllSessions, releaseSessionInput, ALLOWED } from "../src/app-handler.mjs";
import { handle, closeSession, closeAllSessions, releaseSessionInput, reopenSession, ALLOWED } from "../src/app-handler.mjs";
import { APP_ID, APP_NAME, APP_VERSION, socketPath, runInfoPath, writeRegistration, defaultLaunch, hello } from "../src/app-socket.mjs";
import { stateDir } from "../src/registry.mjs";
@@ -80,6 +80,7 @@ async function serve(conn) {
ownedSession = req.sessionId;
const leaseToken = crypto.randomUUID();
leases.set(ownedSession, { socket: conn, token: leaseToken });
reopenSession(ownedSession);
reply = { ok: true, leaseToken };
}
} else if (!leases.has(req.sessionId) || leases.get(req.sessionId).token !== req.leaseToken) {
@@ -61,6 +61,10 @@ Observe once, act once, then verify.
file path or reuse coordinates from an image the model never received.
6. Verify with a fresh observation or a task oracle before claiming success.
`action_sent: true` means it may already have happened — never replay.
On macOS `type` also reports `verified`: `false` (with
`verification_required: "screenshot"`) means the focused control's value
did not reflect the text, so confirm with a screenshot before relying on
the input.
## Choosing targets
@@ -69,6 +69,17 @@ export function closeSession(sessionId, computerId = "local") {
return releaseSessionInput(sessionId, computerId, { close: true });
}
/**
* A freshly granted session owner supersedes a closed-session tombstone:
* without this, a daemon that re-leases a session id (the old owner socket
* died with the previous daemon) would keep aborting every request on it.
* In-flight requests from the dead owner stay aborted; the tombstone is the
* only thing removed.
*/
export function reopenSession(sessionId, computerId = "local") {
sessions.delete(`${computerId}:${sessionId}`);
}
export function closeAllSessions() {
return Promise.allSettled([...sessions.keys()].map((key) => {
const colon = key.indexOf(":");
@@ -82,6 +82,9 @@ export function appRequest(request, options) { return requestConnection(request,
// A live socket is the session owner, independent of short-lived cancellable
// request sockets. The OS closes it even if the MCP process is killed; no PID
// lookup or reuse-prone process identity is needed to release held input.
// When the socket dies without close_session (an app update replaces the
// daemon and every socket it owned), the dead lease is dropped so the next
// request re-opens one instead of failing forever.
const sessionLeases = new Map();
export function openAppSession(sessionId) {
if (!sessionLeases.has(sessionId)) {
@@ -90,12 +93,17 @@ export function openAppSession(sessionId) {
socket.destroy();
throw Object.assign(new ExecError(reply?.error?.message ?? "Computer session lease was refused"), { code: reply?.error?.code ?? "app_session_closed" });
}
const lease = { token: reply.leaseToken, socket, closed: socket.destroyed };
socket.once("close", () => { lease.closed = true; });
const lease = { token: reply.leaseToken, socket, closed: socket.destroyed, deliberate: false };
socket.once("close", () => {
lease.closed = true;
if (sessionLeases.get(sessionId) === pending && !lease.deliberate) sessionLeases.delete(sessionId);
});
// Library clients need not keep Node alive solely for an idle lease.
socket.unref();
return lease;
});
// A refused or unreachable open is retried on the next request, not cached.
pending.catch(() => { if (sessionLeases.get(sessionId) === pending) sessionLeases.delete(sessionId); });
sessionLeases.set(sessionId, pending);
}
return sessionLeases.get(sessionId);
@@ -103,10 +111,16 @@ export function openAppSession(sessionId) {
export async function appSessionRequest(request, options = {}) {
throwIfAborted(options.signal === undefined ? currentSignal() : options.signal);
const lease = await openAppSession(request.sessionId);
if (lease.closed) throw Object.assign(new ExecError("Computer session owner disconnected; restart this MCP session"), { code: "app_session_closed" });
let lease = await openAppSession(request.sessionId);
if (lease.closed) {
if (lease.deliberate) throw Object.assign(new ExecError("Computer session was closed; start a new session to continue"), { code: "app_session_closed" });
// The fresh lease is on a daemon that holds no input for this session,
// so nothing the old lease held can replay across the reconnect.
lease = await openAppSession(request.sessionId);
if (lease.closed) throw Object.assign(new ExecError("Computer session lease could not be re-established with the helper; retry the request"), { code: "app_session_closed" });
}
try { return await appRequest({ ...request, leaseToken: lease.token }, options); }
finally { if (request.tool === "close_session") lease.socket.destroy(); }
finally { if (request.tool === "close_session") { lease.deliberate = true; lease.socket.destroy(); } }
}
/** App identity if it is running, else null. Cheap: one connect. */
@@ -266,6 +266,73 @@ static CGEventRef textEvent(NSString *text, BOOL down) {
CGEventRef event=CGEventCreateKeyboardEvent(NULL,0,down);
CGEventKeyboardSetUnicodeString(event,text.length,chars); free(chars); return event;
}
static BOOL cuTextRole(NSString *role) {
return [@[@"AXTextField",@"AXTextArea",@"AXComboBox",@"AXSearchField",@"AXSecureTextField",@"AXWebArea"] containsObject:role];
}
// NSString lengths are UTF-16 unit counts on both sides, so emoji compare
// consistently. The length check survives autocorrect/IME transforms that
// defeat the suffix check, as long as the character count is preserved.
static BOOL cuTypeVerified(NSString *before, NSString *after, NSString *text) {
return after && ([after hasSuffix:text] || (before && after.length==before.length+text.length));
}
static id cuFocusedElement(pid_t pid) {
AXUIElementRef appEl=AXUIElementCreateApplication(pid);
AXUIElementSetMessagingTimeout(appEl,2.0);
id focused=attr(appEl,@"AXFocusedUIElement");
CFRelease(appEl);
return focused;
}
/**
* Type into whatever holds focus in the bound app, then prove it landed.
* Dispatch succeeding is not delivery (a process with no text receiver drops
* the events silently), so the receipt reports `verified` from the focused
* control's own value. Failure to verify is reported, not thrown — the events
* already went out. The one throw is before any event is posted: a focused
* element that is clearly not a text control.
*/
static NSDictionary *cuType(NSDictionary *args, NSRunningApplication *inputApp, id focused, BOOL simulated) {
NSString *text=args[@"text"];
if(![text isKindOfClass:NSString.class]) @throw [NSException exceptionWithName:@"text" reason:@"text must be a string" userInfo:nil];
NSString *role=focused?attr((__bridge AXUIElementRef)focused,@"AXRole"):nil;
BOOL secure=[role isEqual:@"AXSecureTextField"];
NSString *before=nil;
// A secure field's value is never read; it verifies as unverifiable.
if(focused && !secure) { id v=attr((__bridge AXUIElementRef)focused,@"AXValue"); if([v isKindOfClass:NSString.class]) before=v; }
// Fail closed only on strong evidence: something holds focus and it is
// clearly not text. No focused element at all still receives the events —
// some apps take process-directed keys without reporting AX focus.
if(focused && !cuTextRole(role) && !before)
@throw [NSException exceptionWithName:@"focus" reason:[NSString stringWithFormat:@"focused element is a %@, not a text control — click or focus a text field first",role?:@"unknown element"] userInfo:nil];
// One grapheme per event, the way a keyboard delivers them. Batching
// several into one CGEventKeyboardSetUnicodeString is faster but Electron
// apps coalesce the pending payload and keep only the final batch, so a
// typed string silently arrives truncated to its tail.
for(NSUInteger i=0;i<text.length && !cuCancelled;) {
if([args[@"foreground_input"] boolValue]) cuRequireForeground(inputApp);
cuCheckCancelled();
NSRange range=[text rangeOfComposedCharacterSequencesForRange:NSMakeRange(i,1)];
NSString *chunk=[text substringWithRange:range];
if(!simulated) for(int down=1;down>=0;down--){ CGEventRef event=textEvent(chunk,down); if([args[@"foreground_input"] boolValue]) CGEventPost(kCGHIDEventTap,event); else CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); }
i=NSMaxRange(range); usleep(10000);
}
if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
NSString *after=nil;
if(focused && !secure) {
#ifdef CU_TEST
if(simulated) { NSString *s=((NSMutableDictionary *)focused)[@"after"]; if(s) ((NSMutableDictionary *)focused)[@"AXValue"]=s; }
else
#endif
usleep(80000);
id v=attr((__bridge AXUIElementRef)focused,@"AXValue");
if([v isKindOfClass:NSString.class]) after=v;
}
BOOL verified=cuTypeVerified(before,after,text);
NSMutableDictionary *receipt=[@{@"action_sent":@YES,@"chars":@(text.length),@"strategy":@"unicode-events",
@"keyboard_delivery":[args[@"foreground_input"] boolValue]?@"foreground-guarded":@"process",
@"verified":@(verified),@"focused_role":role?:[NSNull null]} mutableCopy];
if(!verified) receipt[@"verification_required"]=@"screenshot";
return receipt;
}
static id execute(NSDictionary *p) {
NSString *tool=p[@"tool"]; NSDictionary *args=p[@"args"]?:@{};
cuOwnerPipe=[args[@"owner_pipe"] boolValue];
@@ -318,6 +385,13 @@ static id execute(NSDictionary *p) {
CGEventKeyboardGetUnicodeString(event,4096,&length,chars); CGEventFlags flags=CGEventGetFlags(event); CFRelease(event);
return @{@"text":[NSString stringWithCharacters:chars length:length],@"flags":@(flags)};
}
// Drives the real typing logic against a fixture focused element instead of
// a live app: `after` is the value the element reports once the text lands,
// which a fixture omits to model an app that swallows the events.
if([tool isEqual:@"inspect_type"]) {
id fixture=args[@"focused"];
return cuType(args, nil, [fixture isKindOfClass:NSDictionary.class]?[fixture mutableCopy]:nil, YES);
}
#endif
if([tool isEqual:@"permissions"]) return @{@"trusted":@(AXIsProcessTrusted())};
if([tool isEqual:@"list_apps"]) {
@@ -396,24 +470,7 @@ static id execute(NSDictionary *p) {
}
if(mutates) { cuCheckCancelled(); cuLockInput(); }
if(!AXIsProcessTrusted()) @throw [NSException exceptionWithName:@"permission" reason:@"Accessibility permission is missing for Codewhale Computer Use (or the direct host)." userInfo:nil];
if([tool isEqual:@"type"]) {
NSString *text=args[@"text"];
if(![text isKindOfClass:NSString.class]) @throw [NSException exceptionWithName:@"text" reason:@"text must be a string" userInfo:nil];
// One grapheme per event, the way a keyboard delivers them. Batching
// several into one CGEventKeyboardSetUnicodeString is faster but Electron
// apps coalesce the pending payload and keep only the final batch, so a
// typed string silently arrives truncated to its tail.
for(NSUInteger i=0;i<text.length && !cuCancelled;) {
if([args[@"foreground_input"] boolValue]) cuRequireForeground(inputApp);
cuCheckCancelled();
NSRange range=[text rangeOfComposedCharacterSequencesForRange:NSMakeRange(i,1)];
NSString *chunk=[text substringWithRange:range];
for(int down=1;down>=0;down--){ CGEventRef event=textEvent(chunk,down); if([args[@"foreground_input"] boolValue]) CGEventPost(kCGHIDEventTap,event); else CGEventPostToPid(inputApp.processIdentifier,event); CFRelease(event); }
i=NSMaxRange(range); usleep(10000);
}
if(cuCancelled) @throw [NSException exceptionWithName:@"cancelled" reason:@"computer request cancelled" userInfo:nil];
return @{@"action_sent":@YES,@"chars":@(text.length),@"strategy":@"unicode-events",@"keyboard_delivery":[args[@"foreground_input"] boolValue]?@"foreground-guarded":@"process"};
}
if([tool isEqual:@"type"]) return cuType(args, inputApp, cuFocusedElement(inputApp.processIdentifier), NO);
if([tool isEqual:@"key_event"]) {
if([args[@"foreground_input"] boolValue] && [args[@"down"] boolValue]) cuRequireForeground(inputApp);
cuCheckCancelled();
@@ -220,7 +220,7 @@ export const TOOLS = [
},
// ---- text & keyboard ----
{
name: "type", description: "Type text into the focused control (unicode). Focus the field first (click/element action).",
name: "type", description: "Type text into the focused control (unicode). Focus the field first (click/element action). On macOS the receipt carries `verified:true` only when the focused control's value actually reflects the typed text; on `verified:false` the text may have gone nowhere — confirm with a screenshot before relying on it.",
inputSchema: { type: "object", required: ["text"], properties: { text: { type: "string" }, computer: computerParam }, additionalProperties: false },
},
{
@@ -391,3 +391,46 @@ test('native hit_test fails closed without a bound application', { skip: process
assert.equal(r.status, 1);
assert.match(r.stderr, /open_application first/);
});
test('native type verifies delivery against the focused control and fails closed on a non-text focus', { skip: process.platform !== 'darwin' }, (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-type-native-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const binary = path.join(dir, 'native');
const build = spawnSync('clang', ['-DCU_TEST=1', '-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'ApplicationServices', '-framework', 'ScreenCaptureKit', '-framework', 'AVFoundation', '-framework', 'CoreMedia', '-framework', 'Vision', 'src/backends/darwin-accessibility.m', '-o', binary], { encoding: 'utf8' });
assert.equal(build.status, 0, build.stderr);
const type = (args) => spawnSync(binary, [JSON.stringify({ tool: 'inspect_type', args })], { encoding: 'utf8' });
let r = type({ text: 'world', focused: { AXRole: 'AXTextField', AXValue: 'Hello ', after: 'Hello world' } });
assert.equal(r.status, 0, r.stderr);
let receipt = JSON.parse(r.stdout);
assert.equal(receipt.action_sent, true);
assert.equal(receipt.chars, 5);
assert.equal(receipt.strategy, 'unicode-events');
assert.equal(receipt.verified, true, 'suffix match confirms delivery');
assert.equal(receipt.focused_role, 'AXTextField');
assert.ok(!('verification_required' in receipt));
r = type({ text: 'their', focused: { AXRole: 'AXTextArea', AXValue: 'I love ', after: 'I love thier' } });
assert.equal(JSON.parse(r.stdout).verified, true, 'the length check survives an autocorrect transform');
r = type({ text: 'x', focused: { AXRole: 'AXButton', AXTitle: 'Save' } });
assert.equal(r.status, 1, 'a clearly non-text focus refuses before any event is posted');
assert.match(r.stderr, /focused element is a AXButton, not a text control/);
for (const focused of [null, { AXRole: 'AXWebArea' }, { AXRole: 'AXSecureTextField' }, { AXRole: 'AXTextField', AXValue: 'abc' }]) {
r = type({ text: 'hi', focused });
assert.equal(r.status, 0, r.stderr);
receipt = JSON.parse(r.stdout);
assert.equal(receipt.action_sent, true, 'unverifiable readbacks still report dispatch');
assert.equal(receipt.verified, false);
assert.equal(receipt.verification_required, 'screenshot');
}
assert.equal(JSON.parse(type({ text: 'hi', focused: null }).stdout).focused_role, null);
});
test('macOS type passes the native verification receipt through untouched', async (t) => {
const nativeReceipt = { action_sent: true, chars: 5, strategy: 'unicode-events', keyboard_delivery: 'process', verified: false, focused_role: null, verification_required: 'screenshot' };
const { backend } = stubBackend(t, (r) => (r.tool === 'type' ? nativeReceipt : null));
await backend.open_application({ name: 'TextEdit' });
assert.deepEqual(await backend.type({ text: 'hello' }), nativeReceipt);
});
@@ -108,7 +108,8 @@ test("helper accepts actions only while their original socket owner is alive", a
lease.socket.destroy();
await until(() => calls().some((item) => item.method === "release_input" && item.appName === "Lease owner"));
assert.equal((await appRequest({ tool: "type", sessionId, leaseToken: lease.token, args: { text: "stale lease" } })).error.code, "session_owner_required");
await assert.rejects(appSessionRequest({ tool: "probe", sessionId }), (err) => err.code === "app_session_closed");
assert.equal((await appSessionRequest({ tool: "probe", sessionId })).ok, true, "a dropped owner socket re-leases transparently instead of bricking the session");
assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Re-leased owner" } } })).data.name, "Re-leased owner");
assert.ok(!calls().some((item) => item.appName === "Spoofed owner" || item.text === "stale lease"));
});
@@ -229,6 +230,23 @@ test("MCP forced exit cancels its active child before delayed input can post", a
assert.ok(!calls().some((item) => item.method === "late_input"));
});
test("an app update re-leases live sessions transparently; an absent app still fails without bricking", async () => {
const sessionId = "upgrade-survivor";
assert.equal((await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor" } } })).ok, true);
const exit = new Promise((resolve) => daemon.once("exit", resolve));
daemon.kill("SIGTERM");
await exit;
// Mid-update the app is genuinely absent: the request fails, and that
// failure is not cached against the session.
await assert.rejects(appSessionRequest({ tool: "probe", sessionId }), (err) => err.code === "app_unavailable");
daemon = spawn(process.execPath, [path.join(ROOT, "app/daemon.mjs")], { env, stdio: ["ignore", "ignore", "pipe"] });
daemon.stderr.on("data", (data) => { daemonErrors += data; });
await until(async () => !!(await hello({ timeoutMs: 100 })), 5000).catch((err) => { throw new Error(`${err.message}\n${daemonErrors}`); });
const reply = await appSessionRequest({ tool: "get_app_state", sessionId, args: { app_ref: { name: "Survivor again" } } });
assert.equal(reply.ok, true, JSON.stringify(reply));
assert.equal(reply.data.name, "Survivor again", "the same session id works on the replacement daemon without a host reload");
});
test("MCP EOF releases a completed mouse-down and helper shutdown aborts active children", async () => {
const host = mcp();
await host.tool("get_app_state", { app_ref: { name: "Disconnected host" } });