fix(coding-agent): load codemode worker in Windows binary

closes #10204
This commit is contained in:
Armin Ronacher
2026-09-30 13:48:04 +02:00
parent d2931ad3d5
commit 4a42f8fafa
14 changed files with 253 additions and 21 deletions
+8
View File
@@ -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
+4
View File
@@ -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)).
+4
View File
@@ -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:
+2 -2
View File
@@ -75,7 +75,7 @@ interface ExecutionOptions {
memoryLimitBytes: number | undefined;
store: Record<string, string>;
wasm: Promise<CodemodeWasmModule>;
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<CodemodeWasmModule> | undefined;
private readonly workerUrl: URL;
private readonly workerUrl: string | URL;
private readonly running = new Set<Execution>();
private closed = false;
+1 -1
View File
@@ -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";
+3 -2
View File
@@ -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 {
+8
View File
@@ -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);
+1
View File
@@ -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
+19 -12
View File
@@ -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)
// =============================================================================
@@ -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;
@@ -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";
@@ -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();
});
});
+1 -1
View File
@@ -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).
+178
View File
@@ -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 <pi-binary>");
}
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;
});