From 4a42f8fafaff65126b6c21632be03b605aec9ad5 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Wed, 30 Sep 2026 13:46:18 +0200 Subject: [PATCH] fix(coding-agent): load codemode worker in Windows binary closes #10204 --- .github/workflows/build-binaries.yml | 8 + packages/codemode/CHANGELOG.md | 4 + packages/codemode/README.md | 4 + packages/codemode/src/runtime/host.ts | 4 +- packages/codemode/src/runtime/worker.ts | 2 +- packages/codemode/src/types.ts | 5 +- packages/codemode/test/sandbox.test.ts | 8 + packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/config.ts | 31 +-- .../src/extensions/codemode/execute.ts | 4 +- .../src/extensions/codemode/worker.ts | 2 +- .../test/codemode-worker-config.test.ts | 21 +++ scripts/build-binaries.sh | 2 +- scripts/smoke-test-codemode-binary.mjs | 178 ++++++++++++++++++ 14 files changed, 253 insertions(+), 21 deletions(-) create mode 100644 packages/coding-agent/test/codemode-worker-config.test.ts create mode 100644 scripts/smoke-test-codemode-binary.mjs diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 03f06609f..90db9aed7 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -142,7 +142,14 @@ jobs: actions: read env: RELEASE_TAG: ${{ github.event.inputs.tag || github.ref_name }} + SOURCE_REF: ${{ github.event.inputs.source_ref || github.event.inputs.tag || github.ref_name }} steps: + - name: Checkout smoke tests + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_REF }} + persist-credentials: false + - name: Download binary archives uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: @@ -198,6 +205,7 @@ jobs: run: | "${{ steps.binary.outputs.binary }}" --help "${{ steps.binary.outputs.binary }}" --version + node scripts/smoke-test-codemode-binary.mjs "${{ steps.binary.outputs.binary }}" stage-github-release: runs-on: ubuntu-latest diff --git a/packages/codemode/CHANGELOG.md b/packages/codemode/CHANGELOG.md index 95a5c2562..18f2d671b 100644 --- a/packages/codemode/CHANGELOG.md +++ b/packages/codemode/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Allowed `CodemodeSandbox.workerUrl` to be a string, as required for embedded worker entrypoints in Bun compiled executables ([#10204](https://github.com/earendil-works/pi/issues/10204)). + ### Fixed - Fixed `image()` accepting malformed base64 data and unsupported image types. It now throws a `TypeError` unless the data is valid base64 of a PNG, JPEG, GIF, or WebP image, derives the MIME type from the image signature instead of the declared type, and strips line breaks from wrapped base64 ([#10215](https://github.com/earendil-works/pi/issues/10215)). diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 8bfda676f..2fc4db361 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -92,6 +92,10 @@ const sandbox = new CodemodeSandbox({ }); ``` +`workerUrl` accepts a URL or string. For a Bun compiled executable, include the worker as an +additional build entrypoint and pass its relative source path as a string, for example +`"./src/codemode-worker.ts"`; Bun resolves that form from its embedded module graph. + ## Declarations for the model Tools and globals can carry `description`, `inputSchema`, and `outputSchema` (JSON Schema). `renderDeclarations()` turns them into TypeScript declarations for a model-facing tool description: diff --git a/packages/codemode/src/runtime/host.ts b/packages/codemode/src/runtime/host.ts index 7a7308bf5..940359b5f 100644 --- a/packages/codemode/src/runtime/host.ts +++ b/packages/codemode/src/runtime/host.ts @@ -75,7 +75,7 @@ interface ExecutionOptions { memoryLimitBytes: number | undefined; store: Record; wasm: Promise; - workerUrl: URL; + workerUrl: string | URL; } /** @@ -288,7 +288,7 @@ export class CodemodeSandbox { private readonly timeoutMs: number; private readonly memoryLimitBytes: number | undefined; private readonly wasm: CodemodeWasmModule | Promise | undefined; - private readonly workerUrl: URL; + private readonly workerUrl: string | URL; private readonly running = new Set(); private closed = false; diff --git a/packages/codemode/src/runtime/worker.ts b/packages/codemode/src/runtime/worker.ts index eebb674a0..5450e0f59 100644 --- a/packages/codemode/src/runtime/worker.ts +++ b/packages/codemode/src/runtime/worker.ts @@ -8,7 +8,7 @@ * Importing this module starts the worker. Hosts that bundle their code (for * example a Bun compiled executable) add a file that imports * `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL - * as `workerUrl`. + * or embedded-module string specifier as `workerUrl`. */ import { parentPort, workerData } from "node:worker_threads"; import { JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi"; diff --git a/packages/codemode/src/types.ts b/packages/codemode/src/types.ts index b3b90ca8f..4bc4f3222 100644 --- a/packages/codemode/src/types.ts +++ b/packages/codemode/src/types.ts @@ -118,9 +118,10 @@ export interface CodemodeSandboxOptions { /** * Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: this package's own * worker file. Pass it when this package is bundled, since the default is resolved relative to - * the module that creates the sandbox. + * the module that creates the sandbox. Bun compiled executables require the relative string + * specifier of an embedded build entrypoint; other hosts usually use a URL. */ - workerUrl?: URL; + workerUrl?: string | URL; } export interface CodemodeExecuteOptions { diff --git a/packages/codemode/test/sandbox.test.ts b/packages/codemode/test/sandbox.test.ts index 20c965f04..10c0302b0 100644 --- a/packages/codemode/test/sandbox.test.ts +++ b/packages/codemode/test/sandbox.test.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import vm from "node:vm"; import { afterEach, describe, expect, it } from "vitest"; import { CodemodeSandbox, type CodemodeTool } from "../src/index.ts"; @@ -561,6 +562,13 @@ describe("limits and lifetime", () => { expect(result).toMatchObject({ ok: true, value: ["RangeError", true] }); }); + it("accepts a worker path string", async () => { + const workerPath = fileURLToPath(new URL("../src/runtime/worker.ts", import.meta.url)); + const sandbox = new CodemodeSandbox({ workerUrl: workerPath }); + sandboxes.push(sandbox); + expect(await sandbox.execute("return 1")).toMatchObject({ ok: true, value: 1 }); + }); + it("reports a missing worker file as a sandbox error", async () => { const sandbox = new CodemodeSandbox({ workerUrl: new URL("./does-not-exist.js", import.meta.url) }); sandboxes.push(sandbox); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 0e0c94f5d..3c8850622 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -7,6 +7,7 @@ - Fixed new sessions intermittently ignoring the saved default model, or warning that no models are available, when it belongs to an extension-registered native provider with a stored credential ([#9962](https://github.com/earendil-works/pi/issues/9962)). - Fixed the `/mcp` sign-in URL not being clickable when it wraps across lines, by emitting it as a terminal hyperlink with a `Cmd/Ctrl+click to open` line like `/login` ([#10186](https://github.com/earendil-works/pi/issues/10186)). - Fixed codemode `image()` accepting malformed base64 data or unsupported image types, which persisted an invalid image block that made every later provider request fail with HTTP 400 ([#10215](https://github.com/earendil-works/pi/issues/10215)). +- Fixed codemode failing to start its script worker from the standalone Windows executable ([#10204](https://github.com/earendil-works/pi/issues/10204)). ## [0.99.1] - 2026-09-29 diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index 360c609e8..b9ef5dbfc 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -489,21 +489,28 @@ export function getQuickJSWasmPath(): string { return embeddedQuickJSWasmPath ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm"); } -/** - * Get the URL of the codemode worker entry (`src/extensions/codemode/worker.ts`), or undefined to use - * the worker file that ships next to pi-codemode's own module. - * - For Bun binary: the build passes the worker as an extra entrypoint. Bun embeds it at its path - * relative to the common directory of all entrypoints (the package root, since the main entry is - * dist/bun/cli.js) with a .js extension, and all bundled code sees the executable as import.meta.url. - * - For the Node bundle: the build emits codemode-worker.js next to the chunk that contains this module. - * - For Node.js (dist/) and tsx (src/): pi-codemode's own worker. - */ -export function getCodemodeWorkerUrl(): URL | undefined { - if (isBunBinary) return new URL("./src/extensions/codemode/worker.js", import.meta.url); - if (isBundledNode) return new URL("./codemode-worker.js", import.meta.url); +/** Resolve the codemode worker entry for a release runtime. */ +export function resolveCodemodeWorkerSpecifier( + runtime: "bun-binary" | "bundled-node" | "unbundled", + moduleUrl: string, +): string | URL | undefined { + // Bun embeds explicit source entrypoints, but on Windows Bun 1.3 cannot map an absolute + // B:\~BUN URL back to one. A relative string with the original source extension works on + // every Bun platform. + if (runtime === "bun-binary") return "./src/extensions/codemode/worker.ts"; + if (runtime === "bundled-node") return new URL("./codemode-worker.js", moduleUrl); return undefined; } +/** + * Get the codemode worker entry, or undefined to use the worker that ships next to pi-codemode. + * The Bun and Node release builds both pass the worker as an extra entrypoint. + */ +export function getCodemodeWorkerSpecifier(): string | URL | undefined { + const runtime = isBunBinary ? "bun-binary" : isBundledNode ? "bundled-node" : "unbundled"; + return resolveCodemodeWorkerSpecifier(runtime, import.meta.url); +} + // ============================================================================= // App Config (from package.json piConfig) // ============================================================================= diff --git a/packages/coding-agent/src/extensions/codemode/execute.ts b/packages/coding-agent/src/extensions/codemode/execute.ts index 0b41d84c9..d8d3dff89 100644 --- a/packages/coding-agent/src/extensions/codemode/execute.ts +++ b/packages/coding-agent/src/extensions/codemode/execute.ts @@ -18,7 +18,7 @@ import { renderToolSample, toCodemodeIdentifier, } from "@earendil-works/pi-codemode"; -import { getCodemodeWorkerUrl, getQuickJSWasmPath } from "../../config.ts"; +import { getCodemodeWorkerSpecifier, getQuickJSWasmPath } from "../../config.ts"; import type { ExtensionToolContext } from "../../core/extensions/types.ts"; import type { SessionEntry } from "../../core/session-manager.ts"; import { combineUsage } from "../../core/usage-totals.ts"; @@ -277,7 +277,7 @@ export async function executeCodemode( timeoutMs: sourceOptions.timeoutMs ?? Number.POSITIVE_INFINITY, memoryLimitBytes: CODEMODE_MEMORY_LIMIT_BYTES, wasm: loadQuickJSWasm(getQuickJSWasmPath()), - workerUrl: getCodemodeWorkerUrl(), + workerUrl: getCodemodeWorkerSpecifier(), }); let result: CodemodeResult; diff --git a/packages/coding-agent/src/extensions/codemode/worker.ts b/packages/coding-agent/src/extensions/codemode/worker.ts index 27f4d172d..3f752c027 100644 --- a/packages/coding-agent/src/extensions/codemode/worker.ts +++ b/packages/coding-agent/src/extensions/codemode/worker.ts @@ -1,6 +1,6 @@ /** * Worker entry for the codemode sandbox in bundled builds. The Node bundle and the Bun binary * build this file as a separate entrypoint because pi-codemode's own worker file is not on disk - * there; `getCodemodeWorkerUrl()` in config.ts resolves it. + * there; `getCodemodeWorkerSpecifier()` in config.ts resolves it. */ import "@earendil-works/pi-codemode/worker"; diff --git a/packages/coding-agent/test/codemode-worker-config.test.ts b/packages/coding-agent/test/codemode-worker-config.test.ts new file mode 100644 index 000000000..a98b9a43e --- /dev/null +++ b/packages/coding-agent/test/codemode-worker-config.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; +import { resolveCodemodeWorkerSpecifier } from "../src/config.ts"; + +describe("resolveCodemodeWorkerSpecifier", () => { + // Regression test for #10204. + test("uses a relative source entrypoint in Bun binaries", () => { + expect(resolveCodemodeWorkerSpecifier("bun-binary", "file:///B:/~BUN/root/config.js")).toBe( + "./src/extensions/codemode/worker.ts", + ); + }); + + test("uses the emitted worker beside the bundled Node module", () => { + expect(resolveCodemodeWorkerSpecifier("bundled-node", "file:///app/chunks/config.js")).toEqual( + new URL("file:///app/chunks/codemode-worker.js"), + ); + }); + + test("uses the pi-codemode worker when unbundled", () => { + expect(resolveCodemodeWorkerSpecifier("unbundled", import.meta.url)).toBeUndefined(); + }); +}); diff --git a/scripts/build-binaries.sh b/scripts/build-binaries.sh index c3364ed5b..acbf2a367 100755 --- a/scripts/build-binaries.sh +++ b/scripts/build-binaries.sh @@ -124,7 +124,7 @@ for platform in "${PLATFORMS[@]}"; do # Bun compiled executables only embed worker scripts when they are passed as # explicit build entrypoints. Bun places them at their path relative to the # common directory of all entrypoints, so the main entry must stay in dist/ - # for the worker URLs in src/config.ts to resolve. + # for the worker specifiers in the runtime to resolve. # # Disable cwd bunfig.toml autoload so project preload scripts cannot crash the # standalone binary before pi starts (see #7684). diff --git a/scripts/smoke-test-codemode-binary.mjs b/scripts/smoke-test-codemode-binary.mjs new file mode 100644 index 000000000..dbbef61a5 --- /dev/null +++ b/scripts/smoke-test-codemode-binary.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { extname, join, resolve } from "node:path"; + +const WORKER_MARKER = "PI_CODEMODE_WORKER_OK"; +const SUCCESS_MARKER = "PI_CODEMODE_BINARY_SMOKE_OK"; +const FAILURE_MARKER = "PI_CODEMODE_BINARY_SMOKE_FAILED"; +const TIMEOUT_MS = 30_000; + +function completionChunk(id, delta, finishReason = null, usage) { + return { + id, + object: "chat.completion.chunk", + created: 0, + model: "codemode-smoke", + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + }; +} + +function sendCompletion(response, chunks) { + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "close", + }); + for (const chunk of chunks) response.write(`data: ${JSON.stringify(chunk)}\n\n`); + response.end("data: [DONE]\n\n"); +} + +async function readRequest(request) { + let body = ""; + for await (const chunk of request) body += chunk.toString(); + return body; +} + +function toolResultContainsMarker(body) { + const payload = JSON.parse(body); + return ( + Array.isArray(payload.messages) && + payload.messages.some( + (message) => message?.role === "tool" && JSON.stringify(message.content).includes(WORKER_MARKER), + ) + ); +} + +async function main() { + const binaryArg = process.argv[2]; + if (!binaryArg || process.argv.length !== 3) { + throw new Error("Usage: node scripts/smoke-test-codemode-binary.mjs "); + } + const binary = resolve(binaryArg); + const tempDir = await mkdtemp(join(tmpdir(), "pi-codemode-binary-smoke-")); + let requestCount = 0; + const server = createServer(async (request, response) => { + if (request.method !== "POST" || !request.url?.endsWith("/chat/completions")) { + response.writeHead(404).end(); + return; + } + const body = await readRequest(request); + requestCount++; + const id = `chatcmpl-codemode-smoke-${requestCount}`; + if (requestCount === 1) { + sendCompletion(response, [ + completionChunk(id, { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_codemode_smoke", + type: "function", + function: { + name: "codemode", + arguments: JSON.stringify({ code: `text("${WORKER_MARKER}")` }), + }, + }, + ], + }), + completionChunk(id, {}, "tool_calls", { prompt_tokens: 1, completion_tokens: 1 }), + ]); + return; + } + const marker = toolResultContainsMarker(body) ? SUCCESS_MARKER : FAILURE_MARKER; + sendCompletion(response, [ + completionChunk(id, { role: "assistant", content: marker }), + completionChunk(id, {}, "stop", { prompt_tokens: 1, completion_tokens: 1 }), + ]); + }); + + try { + await new Promise((resolveListen, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolveListen); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Smoke-test server did not bind to a TCP port"); + await writeFile( + join(tempDir, "models.json"), + JSON.stringify({ + providers: { + "codemode-smoke": { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + api: "openai-completions", + apiKey: "smoke-test", + models: [{ id: "codemode-smoke" }], + }, + }, + }), + ); + + const piArgs = [ + "--provider", + "codemode-smoke", + "--model", + "codemode-smoke", + "--tools", + "codemode", + "--print", + "--no-session", + "--offline", + "--no-context-files", + "--no-skills", + "--no-prompt-templates", + "--no-themes", + "Run the codemode binary smoke test.", + ]; + const javaScript = extname(binary) === ".js"; + const child = spawn(javaScript ? process.execPath : binary, javaScript ? [binary, ...piArgs] : piArgs, { + cwd: tempDir, + env: { + ...process.env, + PI_CODING_AGENT_DIR: tempDir, + PI_OFFLINE: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + const exitCode = await new Promise((resolveExit, reject) => { + const timer = setTimeout(() => { + child.kill(); + reject(new Error(`Smoke test timed out after ${TIMEOUT_MS} ms`)); + }, TIMEOUT_MS); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code) => { + clearTimeout(timer); + resolveExit(code); + }); + }); + if (exitCode !== 0 || !stdout.includes(SUCCESS_MARKER) || requestCount < 2) { + throw new Error( + `Codemode binary smoke test failed (exit ${exitCode}, requests ${requestCount})\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ); + } + process.stdout.write(stdout); + } finally { + await new Promise((resolveClose) => server.close(resolveClose)); + await rm(tempDir, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +});