From 9e5a917eddb8d1f1ab32dc0421e415ddb71ff3e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:25:44 +0900 Subject: [PATCH 1/8] fix(test): promote Bun 1.4.4 with independent runner role verification --- .github/actions/setup-project-bun/action.yml | 4 +-- .github/workflows/ci.yml | 10 +++--- CONTRIBUTING.md | 6 ++-- docs-site/src/content/docs/contributing.md | 6 ++-- package.json | 2 +- structure/runtime.md | 4 +-- tests/ci-workflows/ci-scope-gaps.test.ts | 2 +- tests/ci-workflows/install-scripts.test.ts | 6 ++-- tests/test-layout-tooling.test.ts | 34 ++++++++++++++++---- 9 files changed, 48 insertions(+), 26 deletions(-) diff --git a/.github/actions/setup-project-bun/action.yml b/.github/actions/setup-project-bun/action.yml index 1ab22dd1dc9..cf28f6f9d43 100644 --- a/.github/actions/setup-project-bun/action.yml +++ b/.github/actions/setup-project-bun/action.yml @@ -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). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028c28ca318..7bcd0eaa3f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -578,12 +578,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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7d4aa94f580..bbcd248f8f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index 7fe0b09831b..d0021b7f12a 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -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 diff --git a/package.json b/package.json index 89176da2851..a270427faf2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/structure/runtime.md b/structure/runtime.md index 43e071d457f..763f3e90c3d 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -4,7 +4,7 @@ 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. @@ -12,7 +12,7 @@ The runner pin skipped 1.4.2's multi-file `bun test --isolate` preload reload se 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 diff --git a/tests/ci-workflows/ci-scope-gaps.test.ts b/tests/ci-workflows/ci-scope-gaps.test.ts index f780d71aa90..0729ab6945d 100644 --- a/tests/ci-workflows/ci-scope-gaps.test.ts +++ b/tests/ci-workflows/ci-scope-gaps.test.ts @@ -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( diff --git a/tests/ci-workflows/install-scripts.test.ts b/tests/ci-workflows/install-scripts.test.ts index 51e62b0b6fc..23ed7b7461e 100644 --- a/tests/ci-workflows/install-scripts.test.ts +++ b/tests/ci-workflows/install-scripts.test.ts @@ -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.4.3"); expect(pkg.devDependencies?.typescript).toBe("7.0.2"); expect(pkg.devDependencies?.["@types/bun"]).toBe("1.4.0"); diff --git a/tests/test-layout-tooling.test.ts b/tests/test-layout-tooling.test.ts index 354e3943fe0..cb4a7482819 100644 --- a/tests/test-layout-tooling.test.ts +++ b/tests/test-layout-tooling.test.ts @@ -7,6 +7,7 @@ import { repoPath, repoRoot } from "./helpers/repo-root"; import { listTestFiles, planMoves } from "../scripts/test-layout/plan"; import { runMove } from "../scripts/test-layout/move"; import { runVerify } from "../scripts/test-layout/verify"; +import * as runnerBun from "../scripts/lib/test-runner-bun"; import { testRunnerBun } from "../package.json"; import { anchors, @@ -469,24 +470,43 @@ describe("move end to end", () => { } }); - test("domain verification launches isolate tests through the pinned runner", () => { + const otherBunVersion = testRunnerBun === "1.4.3" ? "1.4.4" : "1.4.3"; + test.each([ + ["matching caller", testRunnerBun, testRunnerBun, false], + ["different caller", otherBunVersion, testRunnerBun, false], + ["wrong override version", otherBunVersion, otherBunVersion, true], + ] as const)("domain verification preserves the configured runner role: %s", (_name, currentVersion, overrideVersion, refused) => { const { root, cleanup } = scratchRepo(); const layoutPath = join(root, "scripts", "test-layout", "layout.json"); try { runMove({ root, domains: ["server", "providers"], dryRun: false, layoutPath, skipVerify: true, log: () => {} }); + const binaryName = process.platform === "win32" ? "bun.exe" : "bun"; + const pinnedRunner = join(root, "test-runner", binaryName); + const caller = currentVersion === testRunnerBun ? pinnedRunner : join(root, "runtime", binaryName); + const probes: string[] = []; + // Keep role resolution real, but version probes independent of the host and the spawn mock. + const select = spyOn(runnerBun, "getTestRunnerBun").mockImplementation(() => runnerBun.resolveTestRunnerBun({ + pin: testRunnerBun, currentVersion, execPath: caller, + env: { OCX_TEST_RUNNER_BUN: pinnedRunner }, pathEntries: [], homeDir: root, platform: process.platform, + probeVersion: binary => { probes.push(binary); return binary === pinnedRunner ? overrideVersion : undefined; }, + })); const spawn = spyOn(Bun, "spawnSync").mockReturnValue({ exitCode: 0, stdout: Buffer.from(""), stderr: Buffer.from(""), } as ReturnType); try { - const report = runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); - expect(report.testExit).toBe(0); - // The test itself runs on the required pin, so its executable is the resolver's first choice. - expect(Bun.version).toBe(testRunnerBun); - expect(spawn.mock.calls.at(-1)?.[0]).toEqual([ - process.execPath, "test", "--isolate", "tests/server", "tests/providers", + const verify = () => runVerify({ root, domains: ["server", "providers"], layoutPath, log: () => {} }); + if (refused) expect(verify).toThrow(`OCX_TEST_RUNNER_BUN reports ${overrideVersion}`); + else expect(verify()).toMatchObject({ ok: true, testExit: 0 }); + expect(select).toHaveBeenCalledTimes(1); + expect(probes).toEqual(currentVersion === testRunnerBun ? [] : [pinnedRunner]); + const testSpawns = spawn.mock.calls.filter(([command]) => Array.isArray(command) && command[1] === "test"); + expect(testSpawns.map(([command]) => command)).toEqual(refused ? [] : [ + [pinnedRunner, "test", "--isolate", "tests/server", "tests/providers"], ]); + if (!refused) expect(testSpawns[0]?.[1]).toMatchObject({ cwd: root, stdout: "inherit", stderr: "inherit" }); } finally { spawn.mockRestore(); + select.mockRestore(); } } finally { cleanup(); From 1deb67d274c8246df9ad539bff93f958782032cb Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:38:09 +0900 Subject: [PATCH 2/8] docs: align Docker Bun comments with unified role defaults --- Dockerfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 82fe86f4cf1..54e0153e0f1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 From 0dd45828bdf6ee3aaade2518cbd8c33577bf5ef5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 12:39:04 +0000 Subject: [PATCH 3/8] fix(link): preserve literal SSH include names on Bun 1.4.4 Quote literal parentheses and braces before passing OpenSSH Include patterns to Bun.Glob, preserving escapes and complete bracket classes. Add filesystem regressions for Bun 1.4.4 syntax and malformed bracket handling. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../src/content/docs/guides/remote-link.md | 2 + src/link/ssh-config.ts | 38 +++++++++++++-- structure/remote-link.md | 2 +- tests/clients/link-ssh-config.test.ts | 46 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/remote-link.md b/docs-site/src/content/docs/guides/remote-link.md index 6f534ae9f18..3b01513de9a 100644 --- a/docs-site/src/content/docs/guides/remote-link.md +++ b/docs-site/src/content/docs/guides/remote-link.md @@ -27,6 +27,8 @@ The dashboard does not ask you to enter a token. It probes the host first, and i The Add Child sheet explains that the Child uses this Home's providers over SSH and lists the prerequisites above. Host aliases come from `~/.ssh/config` on the Home running OpenCodex, not necessarily the computer displaying the browser. If discovery succeeds with no hosts, add a `Host` entry like this, then choose **Rescan hosts**. You can also enter an existing alias manually; selecting or entering an alias enables **Test connection**. The sheet links to this guide. +Host discovery also follows top-level `Include` directives. Parentheses and braces in an included filename stay literal when combined with wildcards: for example, `Include conf.d/*(1).conf` finds `backup(1).conf`, and `Include conf.d/shard-{1..3}-*.conf` looks for filenames containing `{1..3}`. An `Include` inside a `Host` or `Match` block is conditional and is not used for discovery. + ```sshconfig Host devbox HostName devbox.example.com diff --git a/src/link/ssh-config.ts b/src/link/ssh-config.ts index 4e910588bb6..e82ac48926a 100644 --- a/src/link/ssh-config.ts +++ b/src/link/ssh-config.ts @@ -1,6 +1,6 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve, sep } from "node:path"; import { isSshAlias } from "./ssh-argv"; /** @@ -117,11 +117,43 @@ export interface LoadHostCandidatesOptions { glob?: (pattern: string) => string[]; } +/** OpenSSH Include uses glob(..., GLOB_TILDE): parentheses and braces are literal. */ +function normalizeIncludeGlob(pattern: string): string { + let normalized = ""; + for (let i = 0; i < pattern.length; i += 1) { + const char = pattern[i]!; + if (char === "\\" && i + 1 < pattern.length) { + normalized += char + pattern[++i]; + continue; + } + if (char === "[") { + let end = i + 1; + if (pattern[end] === "!" || pattern[end] === "^") end += 1; + if (pattern[end] === "]") end += 1; + for (; end < pattern.length; end += 1) { + if (pattern[end] === "\\") end += 1; + else if (pattern[end] === "]") break; + } + if (end < pattern.length) { + normalized += pattern.slice(i, end + 1); + i = end; + continue; + } + // Keep a malformed suffix intact: generated quoting must not close its class. + return normalized + pattern.slice(i); + } + // Character classes work on both Bun versions, without enabling 1.4.4's extglobs. + normalized += char === "(" ? "[(]" : char === "{" ? "[{]" : char; + } + return normalized; +} + function defaultGlob(pattern: string): string[] { if (!/[*?[]/.test(pattern)) return [pattern]; const base = dirname(pattern.slice(0, pattern.search(/[*?[]/) + 1)); - const relative = pattern.slice(base.length + 1); - return [...new Bun.Glob(relative).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort(); + // Only native Windows separators become slashes; POSIX backslash escapes stay intact. + const relative = pattern.slice(base.length + 1).split(sep).join("/"); + return [...new Bun.Glob(normalizeIncludeGlob(relative)).scanSync({ cwd: base, absolute: true, onlyFiles: true })].sort(); } /** diff --git a/structure/remote-link.md b/structure/remote-link.md index ca7404d3b16..2130303fc44 100644 --- a/structure/remote-link.md +++ b/structure/remote-link.md @@ -8,7 +8,7 @@ Every remote `ocx` call goes through `remoteOcxArgv`, a quoted POSIX `sh -c` scr `src/link/remote-platform.ts` recognises a Windows Child from one remote result: exit 125, a MINGW/MSYS/CYGWIN/Windows `uname -s`, or the cmd.exe or PowerShell "is not recognized" refusal of a POSIX command when the SSH shell has no POSIX sh. Any other non-transport failure (not 0, 125, 127 or 255) gets one locale-independent follow-up, the fixed `cmd /c echo %OS%` (`WINDOWS_OS_PROBE`) against the same strict host key, which prints `Windows_NT` under cmd and PowerShell in any display language. Enrollment maps all three to `remote_platform_unsupported` at probe or host confirmation, before host trust, key issuance or a tunnel, and the refusal names Hub mode (`ocx hub invite`, `ocx connect`). `ocx link cleanup` (`src/cli/link-cleanup.ts`) applies the same classifier to existing Home-initiated records over their pinned SSH trust and only reports: a Windows Child gets the recovery order disconnect on the Child, forced revoke on the Home, then Hub mode, because an ordinary revoke's remote `ocx disconnect` goes through the POSIX prelude that exits 125. Coverage: `tests/cli/cli-link-cleanup.test.ts`, `tests/server/link-enrollment.test.ts`. -`src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. A candidate is an offer, not trust. +`src/link/ssh-config.ts` lists host candidates from `~/.ssh/config`. Arguments are split with the rules of OpenSSH's `argv_split`. Pattern hosts, `Match` blocks and aliases that fail the alias check produce no candidates, and only top-level `Include` directives are followed, because an include inside a `Host` or `Match` block is conditional. Include discovery shields literal parentheses and braces from Bun's extended-glob and brace expansion, preserving existing escapes and character classes; wildcard matching, traversal options and lexical ordering remain with `Bun.Glob`. Coverage: `tests/clients/link-ssh-config.test.ts`. A candidate is an offer, not trust. `src/link/ssh-runner.ts` drains tunnel stdout and stderr with a 64 KiB cap per stream. After the SSH process exits, either reader that has not reached EOF gets 250 ms to consume remaining bytes, then is cancelled and its lock released without waiting for the underlying cancellation promise. Already consumed diagnostics are retained for host-key, auth and network classification, so an inherited pipe cannot indefinitely hold the supervisor's exit classification or pause retries and the outage clock. This grace applies to spawned tunnels; bounded one-shot commands retain their command timeout. diff --git a/tests/clients/link-ssh-config.test.ts b/tests/clients/link-ssh-config.test.ts index 50b4e42e0e3..7a2dfa914f3 100644 --- a/tests/clients/link-ssh-config.test.ts +++ b/tests/clients/link-ssh-config.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { loadHostCandidates, parseHostCandidates, splitSshArgs } from "../../src/link/ssh-config"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -77,6 +77,50 @@ test("top-level Include follows relative globs but ignores conditional includes" ]); }); +for (const fixture of [ + { name: "star before parentheses", pattern: "conf.d/*(1).conf", files: ["conf.d/backup(1).conf", "conf.d/1.conf"], selected: [0] }, + { name: "question mark before parentheses", pattern: "conf.d/?(work).conf", files: ["conf.d/x(work).conf", "conf.d/work.conf"], selected: [0] }, + { name: "plus before parentheses", pattern: "conf.d/*+(work).conf", files: ["conf.d/a+(work).conf", "conf.d/awork.conf"], selected: [0] }, + { name: "at sign before parentheses", pattern: "conf.d/*@(work).conf", files: ["conf.d/a@(work).conf", "conf.d/awork.conf"], selected: [0] }, + { name: "exclamation mark before parentheses", pattern: "conf.d/*!(work).conf", files: ["conf.d/a!(work).conf", "conf.d/aother.conf"], selected: [0] }, + { name: "numeric brace range", pattern: "conf.d/shard-{1..3}-*.conf", files: ["conf.d/shard-{1..3}-a.conf", "conf.d/shard-1-a.conf"], selected: [0] }, + { name: "brace alternatives", pattern: "conf.d/{work,home}-*.conf", files: ["conf.d/{work,home}-a.conf", "conf.d/work-a.conf", "conf.d/home-a.conf"], selected: [0] }, + { name: "literal directory after a wildcard", pattern: "conf.*/@(work)/*.conf", files: ["conf.a/@(work)/a.conf", "conf.a/work/a.conf"], selected: [0] }, + { name: "ordinary question marks and classes", pattern: "conf.d/[ab]?.conf", files: ["conf.d/a1.conf", "conf.d/b2.conf", "conf.d/c3.conf", "conf.d/a12.conf"], selected: [0, 1] }, + { name: "parentheses and braces inside a class", pattern: "conf.d/[({]*.conf", files: ["conf.d/(a.conf", "conf.d/{b.conf", "conf.d/c.conf"], selected: [0, 1] }, + { name: "a leading closing bracket inside a class", pattern: "conf.d/[]({]*.conf", files: ["conf.d/(a.conf", "conf.d/]b.conf", "conf.d/{c.conf", "conf.d/d.conf"], selected: [0, 1, 2] }, + { name: "a negated class", pattern: "conf.d/[!({]*.conf", files: ["conf.d/(a.conf", "conf.d/{b.conf", "conf.d/c.conf"], selected: [2] }, + { name: "already quoted parentheses", pattern: "conf.d/*[(]1[)].conf", files: ["conf.d/backup(1).conf", "conf.d/1.conf"], selected: [0] }, + { name: "already quoted braces", pattern: "conf.d/*[{]1..3[}].conf", files: ["conf.d/shard{1..3}.conf", "conf.d/shard1.conf"], selected: [0] }, + { name: "an unclosed class cannot consume generated quoting", pattern: "conf.d/[[[ab*(c).conf", files: ["conf.d/bc).conf", "conf.d/c.conf"], selected: [] }, +]) { + test(`Include glob compatibility: ${fixture.name}`, () => { + const home = tempHome("glob"); + const sshDir = join(home, ".ssh"); + mkdirSync(sshDir, { recursive: true }); + writeFileSync(join(sshDir, "config"), `Include ${fixture.pattern}\n`); + for (const [index, file] of fixture.files.entries()) { + const path = join(sshDir, file); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `Host host${index}.example.test\n`); + } + expect(loadHostCandidates({ home }).map(candidate => candidate.alias)).toEqual( + fixture.selected.map(index => `host${index}.example.test`), + ); + }); +} + +test.skipIf(process.platform === "win32")("Include preserves an escaped parenthesis in a POSIX glob", () => { + const home = tempHome("escaped-glob"); + const sshDir = join(home, ".ssh"); + const includeDir = join(sshDir, "conf.d"); + mkdirSync(includeDir, { recursive: true }); + writeFileSync(join(sshDir, "config"), String.raw`Include conf.d/*\(1).conf`); + writeFileSync(join(includeDir, "backup(1).conf"), "Host literal.example.test\n"); + writeFileSync(join(includeDir, "1.conf"), "Host expanded.example.test\n"); + expect(loadHostCandidates({ home }).map(candidate => candidate.alias)).toEqual(["literal.example.test"]); +}); + test("Include recursion stops at the OpenSSH nesting limit", () => { const home = tempHome("recursive"); const sshDir = join(home, ".ssh"); From 41f08fffd3e7dea64a6eed79af918aed74a153dd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:07:35 +0900 Subject: [PATCH 4/8] test: use a draining TLS origin for early upload replies --- .../claude-picker-upload.test.ts | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 8d4cd0d1859..95c6351f04d 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -3,14 +3,39 @@ 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 = {}) { 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 }> = []; + // 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; + replied = true; + 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); + const observation = { requestComplete: length ? requestBody.length >= Number(length[1]) + : requestBody.endsWith("0\r\n\r\n"), flushed: false }; + 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(); for (const event of ["connection", "secureConnection"] as const) upstream.on(event, socket => { sockets.add(socket); socket.once("close", () => sockets.delete(socket)); @@ -34,7 +59,7 @@ async function fixture(handler: (req: IncomingMessage, res: ServerResponse) => v 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, async close() { const closed = clients.map(client => client.destroyed ? Promise.resolve() : observeClose(client)); await relay.close(); @@ -353,16 +378,7 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. 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) => { - // 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. - req.on("error", () => res.destroy()); req.resume(); - const socket = req.socket; - socket.destroySoon = () => { socket.end(); return socket; }; - if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } - else res.end("ordinary"); - }); + const f = await fixture("early-fin"); try { const session = f.h2(); const upload = protocol === "h2" @@ -373,6 +389,7 @@ 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); + expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); 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()); } From a9c3d127d3728f50533df32504b787823b5220ee Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:09:36 +0900 Subject: [PATCH 5/8] test: confine draining origin to buffered HTTP/1 upload --- .../claude-integration/claude-picker-upload.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 95c6351f04d..d0bb998b873 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -378,7 +378,16 @@ for (const protocol of ["h1", "h2"] as const) { // "buffered" answers early while 4 MiB of an advertised 32 MiB body is still queued. 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("early-fin"); + 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. + req.on("error", () => res.destroy()); req.resume(); + const socket = req.socket; + socket.destroySoon = () => { socket.end(); return socket; }; + if (req.url === "/early") { res.writeHead(413, { "Content-Length": "5" }); res.end("early"); } + else res.end("ordinary"); + }); try { const session = f.h2(); const upload = protocol === "h2" @@ -389,7 +398,7 @@ 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); - expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); + if (input === "buffered") expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true }]); 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()); } From f091b1c173042ce54df0d3414f7d683b3e285fa4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 07:26:38 +0900 Subject: [PATCH 6/8] test(server): isolate port reclaim fixture identity and clock --- tests/server/port-reclaim.test.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/server/port-reclaim.test.ts b/tests/server/port-reclaim.test.ts index 07eccd6a7a1..a3632cc8baf 100644 --- a/tests/server/port-reclaim.test.ts +++ b/tests/server/port-reclaim.test.ts @@ -29,8 +29,14 @@ async function reclaimWithMockClock(options: ReclaimListenPortOptions): Promise< const clock = spyOn(Date, "now").mockImplementation(() => now); try { return await reclaimListenPort(10100, "127.0.0.1", { - ...options, timeoutMs: 50, intervalMs: 10, scanIntervalMs: 10, - sleepMs: async () => { now += 10; }, + ...options, + timeoutMs: options.timeoutMs ?? 50, + intervalMs: options.intervalMs ?? 10, + scanIntervalMs: options.scanIntervalMs ?? 10, + sleepMs: async ms => { + now += ms; + await options.sleepMs?.(ms); + }, }); } finally { clock.mockRestore(); @@ -880,16 +886,17 @@ describe("reclaimListenPort", () => { test("killAllOcxOnPort kills ocx listeners absent from the allowlist snapshot", async () => { const killed: number[] = []; - let holder = 9001; + const holderPid = process.pid === 9001 ? 9002 : 9001; + let holder = holderPid; let available = false; - await expect(reclaimListenPort(10100, "127.0.0.1", { + await expect(reclaimWithMockClock({ timeoutMs: 200, intervalMs: 20, scanIntervalMs: 20, dropTcpRows: false, killOcxHolders: true, killAllOcxOnPort: true, - onlyKillPids: [100], // pre-update PID — respawned child is 9001 + onlyKillPids: [100], // pre-update PID — the respawned holder is deliberately not self isAvailableFn: async () => available, listListenPidsFn: () => (holder > 0 ? [holder] : []), isAliveFn: pid => pid === holder, @@ -902,7 +909,7 @@ describe("reclaimListenPort", () => { if (holder === 0) available = true; }, })).resolves.toBe(true); - expect(killed).toEqual([9001]); + expect(killed).toEqual([holderPid]); }); test("foreign non-ocx claimant survives reclaim without allowlist or ocx identity", async () => { From 967a8851ff2841686c7716e458c793a23b6cdaf5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 07:44:20 +0900 Subject: [PATCH 7/8] test: observe body before buffered early upload reply --- .../claude-picker-upload.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index d0bb998b873..2416b821673 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -11,7 +11,8 @@ async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => options: Partial = {}) { const ca = createLocalInterceptCa(); const leaf = issueLocalInterceptLeaf(ca, ["claude.ai"]); - const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean }> = []; + const earlyReplies: Array<{ requestComplete: boolean; flushed: boolean; receivedBodyBytes: number }> = []; + const earlyHeaders = Promise.withResolvers(); // 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. @@ -24,13 +25,15 @@ async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => headers += chunk.toString("latin1"); const end = headers.indexOf("\r\n\r\n"); if (end < 0) return; - replied = true; 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 }; + : 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}`); @@ -54,12 +57,12 @@ async function fixture(handler: ((req: IncomingMessage, res: ServerResponse) => 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 = {}) => { - 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 = {}, 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, earlyReplies, + 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(); @@ -374,8 +377,25 @@ 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"); + try { + // Address the actual TLS origin directly so flushHeaders guarantees a header-only event. + const 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 { 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(input === "buffered" ? "early-fin" : (req, res) => { @@ -398,7 +418,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 }]); + 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()); } From ba67ddb501cc9ee2871025d8a6b812074afbdd08 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 07:50:18 +0900 Subject: [PATCH 8/8] test: close direct upload before fixture teardown --- tests/claude-integration/claude-picker-upload.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/claude-integration/claude-picker-upload.test.ts b/tests/claude-integration/claude-picker-upload.test.ts index 2416b821673..9f8b470ca6b 100644 --- a/tests/claude-integration/claude-picker-upload.test.ts +++ b/tests/claude-integration/claude-picker-upload.test.ts @@ -379,9 +379,10 @@ bounded("h2 cancellation releases aggregate admission once without weakening the 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. - const upload = f.h1("/early", "POST", { "Content-Length": String(32 * 1024 * 1024) }, f.originPort); + upload = f.h1("/early", "POST", { "Content-Length": String(32 * 1024 * 1024) }, f.originPort); const result = h1Result(upload); const closed = observeSocketClose(upload); upload.flushHeaders(); @@ -391,7 +392,7 @@ bounded("early-fin origin waits for the first body byte after separate headers", expect(await wait(result)).toEqual({ status: 413, text: "early" }); await wait(closed); expect(f.earlyReplies).toEqual([{ requestComplete: false, flushed: true, receivedBodyBytes: 1 }]); - } finally { await wait(f.close()); } + } finally { upload?.destroy(); await wait(f.close()); } }); for (const protocol of ["h1", "h2"] as const) {