From 9ceebb2a4d45647dc6fd2760fc26b91f008fbda5 Mon Sep 17 00:00:00 2001 From: usr-bin-roygbiv Date: Sat, 25 Jul 2026 08:31:54 +0000 Subject: [PATCH] fix(workers): isolate compiled host selectors --- packages/coding-agent/CHANGELOG.md | 2 +- packages/coding-agent/src/cli.ts | 6 ++-- .../__tests__/process-entry-import.test.ts | 31 ++++++++++++++++ .../src/tools/computer/worker-entry.ts | 6 ++-- .../fixtures/compiled-worker-selector-host.ts | 36 +++++++++++++++++++ packages/utils/src/worker-host.ts | 8 +++++ packages/utils/test/worker-host.test.ts | 17 ++++++++- scripts/ci-release-publish.test.ts | 20 ++++++++++- scripts/ci-release-publish.ts | 2 ++ 9 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/fixtures/compiled-worker-selector-host.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index cb278f797..b31562881 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -33,7 +33,7 @@ ### Fixed - Fixed `todo` calls that omit `op` hard-failing validation ("op must be operation to apply (was missing)"): the tool now validates leniently and infers the op for unambiguous payloads (`list` → `init`, `phase`+`items` → `append`, bare `items` on an empty list → `init`); `op` stays required in the schema, and ambiguous op-less calls surface the schema error as a retryable tool error. -- Fixed Codex subscription and proxy models being sent the unsupported native `{ type: "computer" }` declaration based only on model ID. They now receive the callable function-tool fallback, including after switching from native OpenAI Responses history, while explicit endpoint metadata can still opt into the GA contract. Explicit native Codex replays preserve `computer_call`/`computer_call_output` pairing, and normal CLI startup keeps the native desktop worker graph lazy while packaged workers re-enter the single CLI host entry. +- Fixed Codex subscription and proxy models being sent the unsupported native `{ type: "computer" }` declaration based only on model ID. They now receive the callable function-tool fallback, including after switching from native OpenAI Responses history, while explicit endpoint metadata can still opt into the GA contract. Explicit native Codex replays preserve `computer_call`/`computer_call_output` pairing, normal CLI startup keeps the native desktop worker graph lazy, and packaged workers re-enter the single CLI host without the computer module claiming non-computer selectors. - Fixed isolated JavaScript eval subprocesses letting the global fatal-rejection handler race the cell rejection interceptor. A floated promise rejection is now folded into the owning cell result without killing its reusable worker process. - Fixed credential-free web search engines (SearXNG, DuckDuckGo, Google, Startpage, Ecosia, Mojeek, and the Public Web fan-out) returning zero results for queries with `site:` paths (e.g. `site:github.com/owner/repo`) or `inurl:` operators: scraper engines only match `site:` against a bare domain and DuckDuckGo ignores `inurl:` entirely, so such queries silently emptied the result set and fell through to the next provider in the chain. A shared `formatScraperQuery` formatter now structurally demotes path-carrying `site:` and all `inurl:` values to plain search terms (covering OR-grouped and quoted directives) while preserving bare-domain `site:` filters, negated operators, and each engine's supported syntax; the pipeline post-filter still enforces the demoted constraints on returned sources. - Fixed `ast_edit` previews reading like applied edits to the model: the `⟨proposed⟩` badge was TUI-only, so the model-visible result (hashline header + `-`/`+` rows, identical to applied edit output) carried no staged-proposal signal. The preview result now leads with a "Staged as a proposal — files NOT modified yet" notice naming `xd://resolve`/`xd://reject`, the injected resolve reminder names the source tool, and the `ast_edit` tool prompt documents the two-phase flow. diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index a437dee6b..60fd3a75a 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -25,12 +25,13 @@ import { VERSION, } from "@oh-my-pi/pi-utils/dirs"; import { interceptUnhandledRejections } from "@oh-my-pi/pi-utils/postmortem"; -import { declareWorkerHostEntry, installWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; +import { declareWorkerHostEntry, installWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host"; import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias"; import { extractProfileFlags } from "./cli/profile-bootstrap"; import { startJsEvalProcess } from "./eval/js/process-entry"; import type { WorkerInbound as JsWorkerInbound, WorkerOutbound as JsWorkerOutbound } from "./eval/js/worker-protocol"; import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol"; +import { COMPUTER_WORKER_ARG } from "./tools/computer/protocol"; import { smokeTestComputerWorker } from "./tools/computer/supervisor"; import { startComputerWorker } from "./tools/computer/worker-entry"; @@ -113,7 +114,6 @@ async function runSmokeTest(): Promise { const TINY_WORKER_ARG = "__omp_worker_tiny_inference"; const STATS_SYNC_WORKER_ARG = "__omp_worker_stats_sync"; const TAB_WORKER_ARG = "__omp_worker_tab"; -const COMPUTER_WORKER_ARG = "__omp_worker_computer"; const JS_EVAL_WORKER_ARG = "__omp_worker_js_eval"; const JS_EVAL_PROCESS_ARG = "__omp_worker_js_eval_process"; const STT_WORKER_ARG = "__omp_worker_stt"; @@ -351,7 +351,7 @@ export async function runCli(argv: string[]): Promise { // synchronous prefix of `runWorkerEntrypoint`, and Bun flushes the // worker's parked initial messages as soon as the entry module's // top-level evaluation finishes. - if (resolvedArgv[0]?.startsWith("__omp_worker_")) { + if (isWorkerHostSelector(resolvedArgv[0])) { const dispatched = await runWorkerEntrypoint(resolvedArgv[0]); if (!dispatched) { process.stderr.write(`Error: unknown worker selector: ${resolvedArgv[0]}\n`); diff --git a/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts b/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts index d06899ce4..fd27dfaab 100644 --- a/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts +++ b/packages/coding-agent/src/eval/__tests__/process-entry-import.test.ts @@ -104,3 +104,34 @@ it("dispatches the computer worker from a single npm-style host bundle", async ( fs.rmSync(outDir, { recursive: true, force: true }); } }); + +it("keeps non-computer selectors isolated in a compiled single-entry worker host", async () => { + using tempDir = TempDir.createSync("@omp-compiled-worker-selector-"); + const packageDir = path.resolve(import.meta.dir, "../../.."); + const outfile = path.join(tempDir.path(), process.platform === "win32" ? "worker-host.exe" : "worker-host"); + const build = Bun.spawn( + [ + process.execPath, + "build", + "--compile", + "--target=bun", + `--outfile=${outfile}`, + path.join(packageDir, "test/fixtures/compiled-worker-selector-host.ts"), + ], + { cwd: packageDir, stdout: "pipe", stderr: "pipe" }, + ); + const [buildExitCode, buildStderr] = await Promise.all([build.exited, new Response(build.stderr).text()]); + expect(buildExitCode, buildStderr).toBe(0); + const proc = Bun.spawn([outfile], { + cwd: packageDir, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + expect(exitCode, stderr).toBe(0); + expect(stdout).toBe('{"ok":true,"kind":"pong"}\n'); +}); diff --git a/packages/coding-agent/src/tools/computer/worker-entry.ts b/packages/coding-agent/src/tools/computer/worker-entry.ts index 2b1e15eed..257175eff 100644 --- a/packages/coding-agent/src/tools/computer/worker-entry.ts +++ b/packages/coding-agent/src/tools/computer/worker-entry.ts @@ -1,6 +1,6 @@ import { parentPort } from "node:worker_threads"; -import { consumeWorkerInbox } from "@oh-my-pi/pi-utils/worker-host"; -import { COMPUTER_WORKER_ARG, type ComputerWorkerInbound, type ComputerWorkerTransport } from "./protocol"; +import { consumeWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host"; +import type { ComputerWorkerInbound, ComputerWorkerTransport } from "./protocol"; import { ComputerWorkerCore } from "./worker"; export function startComputerWorker(): void { @@ -29,6 +29,6 @@ export function startComputerWorker(): void { // Bun workers report `import.meta.main === false`. The source fallback still // enters this file directly, while packaged CLI workers carry the selector and // start the named entry only after installing its inbox. -if (!Bun.isMainThread && !process.argv.includes(COMPUTER_WORKER_ARG) && import.meta.path === Bun.main) { +if (!Bun.isMainThread && !process.argv.some(isWorkerHostSelector) && import.meta.path === Bun.main) { startComputerWorker(); } diff --git a/packages/coding-agent/test/fixtures/compiled-worker-selector-host.ts b/packages/coding-agent/test/fixtures/compiled-worker-selector-host.ts new file mode 100644 index 000000000..ca1fb08aa --- /dev/null +++ b/packages/coding-agent/test/fixtures/compiled-worker-selector-host.ts @@ -0,0 +1,36 @@ +import { parentPort } from "node:worker_threads"; +import { installWorkerInbox, WORKER_HOST_SELECTOR_PREFIX } from "@oh-my-pi/pi-utils/worker-host"; +import { COMPUTER_WORKER_ARG } from "../../src/tools/computer/protocol"; +import { startComputerWorker } from "../../src/tools/computer/worker-entry"; + +const STATS_WORKER_ARG = `${WORKER_HOST_SELECTOR_PREFIX}stats_sync`; + +declare const self: Worker & { + onmessage: ((event: MessageEvent<{ kind: "ping" }>) => void) | null; +}; + +if (Bun.isMainThread) { + const worker = new Worker(Bun.main, { type: "module", argv: [STATS_WORKER_ARG] }); + const response = Promise.withResolvers(); + worker.addEventListener("message", event => response.resolve(event.data)); + worker.addEventListener("error", event => response.reject(event.error ?? new Error(event.message))); + worker.postMessage({ kind: "ping" }); + try { + process.stdout.write(`${JSON.stringify(await response.promise)}\n`); + } finally { + worker.terminate(); + } +} else { + const selector = process.argv.find(arg => arg.startsWith(WORKER_HOST_SELECTOR_PREFIX)); + if (selector === COMPUTER_WORKER_ARG) { + if (!parentPort) throw new Error("compiled worker fixture: missing parentPort"); + installWorkerInbox(parentPort); + startComputerWorker(); + } else if (selector === STATS_WORKER_ARG) { + self.onmessage = (_event: MessageEvent<{ kind: "ping" }>) => { + self.postMessage({ ok: true, kind: "pong" }); + }; + } else { + throw new Error(`unknown worker selector: ${selector}`); + } +} diff --git a/packages/utils/src/worker-host.ts b/packages/utils/src/worker-host.ts index d613eb9aa..26b8a8ad1 100644 --- a/packages/utils/src/worker-host.ts +++ b/packages/utils/src/worker-host.ts @@ -1,5 +1,13 @@ import { stripWindowsExtendedLengthPathPrefix } from "./path"; +/** Prefix reserved for argv selectors dispatched by the shared CLI worker host. */ +export const WORKER_HOST_SELECTOR_PREFIX = "__omp_worker_"; + +/** Whether an argv value selects a worker hosted by the shared CLI entrypoint. */ +export function isWorkerHostSelector(value: string | undefined): value is string { + return value?.startsWith(WORKER_HOST_SELECTOR_PREFIX) ?? false; +} + /** * Main-module path declared by self-dispatching CLI entrypoints — entries * whose top-level argv handling routes hidden `__omp_*` worker selectors. diff --git a/packages/utils/test/worker-host.test.ts b/packages/utils/test/worker-host.test.ts index 4a1854dc9..c2fd44994 100644 --- a/packages/utils/test/worker-host.test.ts +++ b/packages/utils/test/worker-host.test.ts @@ -1,6 +1,11 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { EventEmitter } from "node:events"; -import { consumeWorkerInbox, installWorkerInbox } from "../src/worker-host"; +import { + consumeWorkerInbox, + installWorkerInbox, + isWorkerHostSelector, + WORKER_HOST_SELECTOR_PREFIX, +} from "../src/worker-host"; /** * Regression for JS/tab eval workers always stalling until the init timeout. @@ -14,6 +19,16 @@ import { consumeWorkerInbox, installWorkerInbox } from "../src/worker-host"; * buffers until the worker module `bind`s the real handler; these tests pin that * buffer-replay contract. */ +describe("worker-host selectors", () => { + it("recognizes the shared selector namespace without claiming ordinary CLI arguments", () => { + expect(WORKER_HOST_SELECTOR_PREFIX).toBe("__omp_worker_"); + expect(isWorkerHostSelector("__omp_worker_stats_sync")).toBeTrue(); + expect(isWorkerHostSelector("__omp_worker_computer")).toBeTrue(); + expect(isWorkerHostSelector("--version")).toBeFalse(); + expect(isWorkerHostSelector(undefined)).toBeFalse(); + }); +}); + describe("worker-host inbox", () => { // State is a module-global stash (one worker per process); drain it around // each test so nothing leaks into the next. diff --git a/scripts/ci-release-publish.test.ts b/scripts/ci-release-publish.test.ts index 3f161cbbe..70078718f 100644 --- a/scripts/ci-release-publish.test.ts +++ b/scripts/ci-release-publish.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { $ } from "bun"; -import { inspectPackedTarball, isVersionAlreadyPublished } from "./ci-release-publish.ts"; +import { inspectPackedTarball, isVersionAlreadyPublished, prepareNativeCorePackage } from "./ci-release-publish.ts"; const temporaryDirectories: string[] = []; @@ -40,4 +40,22 @@ describe("release publish", () => { expect(isVersionAlreadyPublished("You cannot publish over the previously published versions: 1.2.3.")).toBe(true); expect(isVersionAlreadyPublished("cannot publish over the previously published version")).toBe(false); }); + + it("ships every file required by the lazy desktop export in the native core", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "omp-native-core-publish-test-")); + temporaryDirectories.push(root); + await Bun.write( + path.join(root, "package.json"), + JSON.stringify({ + name: "@oh-my-pi/pi-natives", + version: "1.2.3", + exports: { + "./desktop": { types: "./native/desktop.d.ts", import: "./native/desktop.js" }, + }, + }), + ); + + const manifest = await prepareNativeCorePackage(root, false); + expect(manifest.files).toEqual(expect.arrayContaining(["native/desktop.js", "native/desktop.d.ts"])); + }); }); diff --git a/scripts/ci-release-publish.ts b/scripts/ci-release-publish.ts index d9b58fba3..7cce1bd44 100644 --- a/scripts/ci-release-publish.ts +++ b/scripts/ci-release-publish.ts @@ -201,6 +201,8 @@ export async function prepareNativeCorePackage(pkgDir: string, write: boolean): manifest.files = [ "native/index.js", "native/index.d.ts", + "native/desktop.js", + "native/desktop.d.ts", "native/loader-state.js", "native/loader-state.d.ts", "native/embedded-addon.js",