Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/setup-project-bun/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ description: >-
# (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to
# 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after
# finalizers ran inside an earlier call and spin past its own timeout (#4956).
# Rejoin only after the upstream runner crash is fixed and a green lane=all
# dispatch proves the unified version; then delete testRunnerBun and this role input.
# Both roles now default to the package's 1.4.4 security transition, retaining role validation.
# Promotion requires a green full cross-platform lane=all run on the exact candidate head.
inputs:
role:
description: Bun role to install (runtime or test-runner).
Expand Down
10 changes: 4 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -621,12 +621,10 @@ jobs:
printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY"
printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA"

# Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming;
# the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821
# reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs
# 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956).
# Rejoin after the upstream runner crash is fixed and a green lane=all dispatch
# proves the unified version, then delete testRunnerBun and the action's role input.
# Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json.
# Keep role validation after the historical isolate preload and spawnSync failures
# (#6713, #4956). Promotion requires a green full cross-platform lane=all run on
# the exact candidate head; focused local results do not replace that proof.
# The suite, split by file across four Linux runners.
#
# `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst

## Local validation and hooks

The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses
The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses
`OCX_TEST_RUNNER_BUN`; install that version locally if it is missing.
The roles remain independently checked even when their versions match. Promotion requires
a successful full cross-platform `lane=all` run on the candidate head.

Run `bun run test` before review readiness. If the full local suite is too costly
for the task or available resources, run at least focused regression tests for
Expand Down
12 changes: 6 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

# Keep the runtime aligned with package.json and pin the multi-platform image index.
# Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS
# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3
# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821;
# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync
# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the
# upstream crash fix passes a unified-version lane=all dispatch, then delete
# package.json's testRunnerBun pin and setup-project-bun's role input.
# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner
# defaults are both 1.4.4; keep package.json's testRunnerBun pin and
# setup-project-bun's role input independently validated. Promotion requires
# an exact-head cross-platform lane=all dispatch, including multi-file --isolate:
# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0
# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks.
ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d

FROM ${BUN_IMAGE} AS manifest
Expand Down
6 changes: 4 additions & 2 deletions docs-site/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI
bun run prepare:package # refresh package launchers/assets
```

The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
Install that version locally if it is missing; the resolver never downloads it.
The roles remain independently checked even when their versions match. Promotion requires
a successful full cross-platform `lane=all` run on the candidate head.

Run `bun run test` by default. If a full run is disproportionately expensive for the task size,
available machine resources, or concurrent worktrees, you must at least run focused regression tests
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"trustedDependencies": [
"bun"
],
"testRunnerBun": "1.4.3",
"testRunnerBun": "1.4.4",
"scripts": {
"dev": "bun run src/cli/index.ts start",
"dev:proxy": "bun run src/cli/index.ts start",
Expand Down
4 changes: 2 additions & 2 deletions structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ The minute sweep checks persisted activation deadlines locally; only missing dea

## Bun runtime and test runner

`package.json` pins the shipped npm runtime (`dependencies.bun`) to 1.4.4 and the test runner (`testRunnerBun`) to 1.4.3; `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs.
`package.json` pins both the shipped npm runtime (`dependencies.bun`) and the test runner (`testRunnerBun`) to 1.4.4; the roles remain independently validated and `@types/bun` remains unchanged. Runtime 1.4.2 fixed #6684's Windows Bun.fetch truncation after the first cleartext upstream body chunk; 1.4.4 keeps that fix and ships NSS 3.131.1 roots, which drop the compromised eMudhra CAs.
The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload segfault (`load_preloads -> JSModuleLoader::loadModule -> JSPromise::status`, address 0x10): #4064 introduced 1.4.2 on 2026-09-09 and #4821 reverted it on 2026-09-17. It left 1.4.0 for 1.4.3 because of oven-sh/bun#44581 (#4956). Bun 1.4.0's `Bun.spawnSync` pointed the VM at its private wait loop, so a finalizer that ran during one call (an earlier file's stdout/stderr sink, a stream reader) released its poll on that loop; the cached loop's poll count drifted to zero and a later call stopped polling for its child, spinning past its own `timeout` until the 120-second batch deadline. In a multi-file `--isolate` process that depends on what earlier files left to finalize, so single-file attribution stayed clean. `tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts` reproduces the mechanism in a child under a bounded parent and fails on 1.4.0.
#6713 run 37631558263 reproduces that 1.4.2 preload segfault on Linux 1/4, macOS 2/2 and Windows 9/9; single-file success does not prove the multi-file runner works.

`.github/actions/setup-project-bun/action.yml` defaults to role `runtime` (`dependencies.bun`); `test-runner` resolves `testRunnerBun`. Unknown roles and missing or invalid pins fail closed. `.github/workflows/ci.yml` verifies both resolved and installed versions in `setup-action`; `tests/ci-workflows/ci-scope-gaps.test.ts` guards job roles.
Test jobs, including single-file development-version validation, select `test-runner`. They also build dashboard fixtures; `gates` uploads a dashboard preview and checks release-helper syntax on that pin.
Release packaging, Docker, installed-artifact and service smoke jobs, desktop shell and widget preparation use `runtime`. Shipped binaries compile through `process.execPath` in `scripts/build-standalone.ts` and `desktop/scripts/prepare-sidecar.ts` on the runtime pin.

`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading; package-script PATH shadowing cannot select the shipped runtime for tests. Rejoin only after an upstream fix and green unified `lane=all` proof; then remove the runner pin, resolver and CI role split.
`scripts/lib/test-runner-bun.ts` selects the test pin for `scripts/test.ts`, `scripts/release.ts` preflight, `scripts/test-layout/verify.ts`, provider-option gates and GUI package tests via `scripts/test-with-pinned-bun.ts`: matching current Bun first, then a version-checked `OCX_TEST_RUNNER_BUN`, then PATH outside node_modules and `~/.bun/bin`. Missing or mismatched binaries fail with install guidance, without downloading. The caller need not already run the test pin; only a version match permits reusing its executable. `tests/test-layout-tooling.test.ts` verifies matching/different caller fixtures, the selected isolate command, and refusal before spawning tests for a wrong-version override. The 1.4.4 security transition aligns the default versions while retaining the role fields, resolver and CI role checks. Promotion requires successful full cross-platform `lane=all` proof on the exact candidate head; focused local tests do not establish that proof.

## Resolved static model policy

Expand Down
2 changes: 1 addition & 1 deletion tests/ci-workflows/ci-scope-gaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("an edit to the setup action alone", () => {
});
});

// #6713: user runtimes and compiled artifacts need 1.4.2; isolate runners need the testRunnerBun pin (1.4.3, #4956).
// Keep runtime and test-runner role selection distinct even when package.json pins both to 1.4.4.
// Check the active setup at each test command, so a later role switch cannot evade the guard.
const runsBunTests = (step: Step): boolean =>
/\bbun\s+test\b|\bbun\s+run\s+test(?::changed)?(?:\s|$)|\bbun\s+(?:run\s+)?scripts\/test\.ts\b|scripts\/ci\/run-bun-test-batches\.sh/.test(
Expand Down
6 changes: 3 additions & 3 deletions tests/ci-workflows/install-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,10 @@ describe("install scripts", () => {
// and spun past its own timeout. Scratch run 38213764818 stalled 8 of 252 Linux shard 1/4
// batch-5 runs that way (CLI child a zombie, its pidfd armed but never polled).
// tests/ci-workflows/test-runner-spawnsync-finalizer.test.ts keeps the runner above it.
// Rejoin after the upstream runner crash is fixed and a green `lane=all` dispatch
// proves the unified version; then delete testRunnerBun and the setup action's role.
// The security transition now defaults both roles to 1.4.4. Keep their independent
// validation; promotion still requires a green full cross-platform lane=all candidate run.
expect(pkg.dependencies?.bun).toBe("1.4.4");
expect(pkg.testRunnerBun).toBe("1.4.3");
expect(pkg.testRunnerBun).toBe("1.4.4");
expect(pkg.dependencies?.zod).toBe("4.6.6");
expect(pkg.devDependencies?.typescript).toBe("7.0.2");
expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0");
Expand Down
65 changes: 58 additions & 7 deletions tests/claude-integration/claude-picker-upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,42 @@ import { Agent, createServer, request } from "node:https";
import { connect, constants, type ClientHttp2Session, type ClientHttp2Stream } from "node:http2";
import type { ClientRequest, IncomingMessage, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import { createServer as createTlsServer } from "node:tls";
import { createLocalInterceptCa, issueLocalInterceptLeaf } from "../../src/claude/intercept/local-ca";
import { startPickerListener, type PickerListenerOptions } from "../../src/claude/intercept/picker-listener";

async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => void,
async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => void) | "early-fin",
options: Partial<PickerListenerOptions> = {}) {
const ca = createLocalInterceptCa();
const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]);
const upstream = createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler);
const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean; receivedBodyBytes: number }> = [];
const earlyHeaders = Promise.withResolvers<void>();
// A wire-level origin sends the reply and FIN while continuing to drain input. Bun's HTTP
// server also tears down unfinished requests; overriding destroySoon alone does not prevent
// that reset racing ahead of the reply on Windows with a buffered upload.
const upstream = handler === "early-fin" ? createTlsServer({ cert: leaf.certPem, key: leaf.keyPem, allowHalfOpen: true }, socket => {
socket.on("error", () => socket.destroy());
let headers = "";
let replied = false;
socket.on("data", chunk => {
if (replied) return;
headers += chunk.toString("latin1");
const end = headers.indexOf("\r\n\r\n");
if (end < 0) return;
const early = headers.startsWith("POST /early ");
const body = early ? "early" : "ordinary";
const length = /\r\ncontent-length: (\d+)\r\n/i.exec(headers.slice(0, end + 2));
const requestBody = headers.slice(end + 4);
if (early) earlyHeaders.resolve();
if (early && requestBody.length === 0) return;
replied = true;
const observation = { requestComplete: length ? requestBody.length >= Number(length[1])
: requestBody.endsWith("0\r\n\r\n"), flushed: false, receivedBodyBytes: requestBody.length };
if (early) earlyReplies.push(observation);
socket.once("finish", () => { observation.flushed = true; });
socket.end(`HTTP/1.1 ${early ? "413 Payload Too Large" : "200 OK"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`);
});
}) : createServer({ cert: leaf.certPem, key: leaf.keyPem }, handler);
const sockets = new Set<Duplex>();
for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => {
sockets.add(socket); socket.once("close", () => sockets.delete(socket));
Expand All @@ -29,12 +57,12 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v
const session = connect(`https://127.0.0.1:${relay.port}`, { ca: ca.certPem, servername: "claude.ai" });
session.on("error", () => session.destroy()); clients.push(session); return session;
};
const h1 = (path: string, method = "GET", headers: Record<string, string> = {}) => {
const req = request({ host: "127.0.0.1", port: relay.port, servername: "claude.ai", ca: ca.certPem,
const h1 = (path: string, method = "GET", headers: Record<string, string> = {}, port = relay.port) => {
const req = request({ host: "127.0.0.1", port, servername: "claude.ai", ca: ca.certPem,
rejectUnauthorized: true, agent: h1Agent, path, method, headers: { Host: "claude.ai", ...headers } });
clients.push(req); return req;
};
return { logs, h2, h1, relay,
return { logs, h2, h1, relay, earlyReplies, earlyHeaders: earlyHeaders.promise, originPort: address.port,
async close() {
const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client));
await relay.close();
Expand Down Expand Up @@ -349,11 +377,29 @@ bounded("h2 cancellation releases aggregate admission once without weakening the
} finally { await wait(f.close()); }
});

bounded("early-fin origin waits for the first body byte after separate headers", async wait => {
const f = await fixture("early-fin");
let upload: ClientRequest | undefined;
try {
// Address the actual TLS origin directly so flushHeaders guarantees a header-only event.
upload = f.h1("/early", "POST", { "Content-Length": String(32 * 1024 * 1024) }, f.originPort);
const result = h1Result(upload);
const closed = observeSocketClose(upload);
upload.flushHeaders();
await wait(f.earlyHeaders);
expect(f.earlyReplies).toEqual([]);
upload.write("x");
expect(await wait(result)).toEqual({ status: 413, text: "early" });
await wait(closed);
expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true, receivedBodyBytes: 1 }]);
} finally { upload?.destroy(); await wait(f.close()); }
});

for (const protocol of ["h1", "h2"] as const) {
// "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued.
// "buffered" writes 4 MiB of an advertised 32 MiB body; the origin replies after input starts.
for (const input of protocol === "h1" ? ["small", "buffered"] as const : ["small"] as const)
bounded(`${protocol} early upstream response closes ${input === "buffered" ? "a buffered" : "an"} unfinished upload without losing the reply`, async wait => {
const f = await fixture((req, res) => {
const f = await fixture(input === "buffered" ? "early-fin" : (req, res) => {
// Answer like a real origin that replies early: keep reading the body and close with a FIN.
// Bun's own server would close this unfinished request with destroySoon(), a reset that
// Windows delivers before the reply bytes, so the relay could never read what it should relay.
Expand All @@ -373,6 +419,11 @@ for (const protocol of ["h1", "h2"] as const) {
upload.write(input === "buffered" ? Buffer.alloc(4 * 1024 * 1024, 0x61) : "x");
expect(await wait(result)).toEqual({ status: 413, text: "early" });
await wait(closed);
if (input === "buffered") {
expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true, receivedBodyBytes: expect.any(Number) }]);
expect(f.earlyReplies[0]!.receivedBodyBytes).toBeGreaterThan(0);
expect(f.earlyReplies[0]!.receivedBodyBytes).toBeLessThan(32 * 1024 * 1024);
}
if (protocol === "h2") expect((upload as ClientHttp2Stream).rstCode).toBe(constants.NGHTTP2_NO_ERROR);
expect(await wait(normalH2(session))).toEqual({ status: 200, text: "ordinary" });
} finally { await wait(f.close()); }
Expand Down
Loading
Loading