From 9e5a917eddb8d1f1ab32dc0421e415ddb71ff3e9 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 21:25:44 +0900 Subject: [PATCH 1/7] 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/7] 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 6920a5e3949626a7dfac6e6142c1009fabbb91ca Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 12:39:29 +0000 Subject: [PATCH 3/7] fix(cursor): use Bun glob matching for native grep Replace suffix and substring approximations with one Bun.Glob matcher per request. Apply basename or root-relative matching while retaining native execution opt-in, traversal exclusions, and existing search limits. Cover recursive patterns, literal names, Bun 1.4.4 extglobs and brace ranges, and dispatcher behavior; document the experimental native grep semantics. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- .../ko/reference/configuration/providers.md | 6 + .../docs/reference/configuration/providers.md | 6 + scripts/test-layout/layout-adapters.json | 1 + src/adapters/cursor/native-exec-fs.ts | 20 +-- structure/providers/cursor.md | 12 ++ tests/fixtures/test-layout-expected.json | 1 + .../cursor/cursor-native-grep-globs.test.ts | 150 ++++++++++++++++++ 7 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 tests/providers/cursor/cursor-native-grep-globs.test.ts diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 54696304ef5..3a760586dde 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -393,6 +393,12 @@ Cursor 서버 주도 로컬 도구는 기본값으로 비활성화됩니다. Cod 필드는 최상위가 아니라 `providers.cursor`에 설정하십시오. 대시보드에서는 **Providers → Cursor → Edit JSON**을 사용해 저장한 뒤 다시 시작합니다. 레거시 `unsafeAllowNativeLocalExec: true`는 `nativeLocalExec`이 설정되지 않았을 때만 `nativeLocalExec: "on"`과 같습니다. MCP, 화면 녹화, computer use는 `mcpServers`와 `desktopExecutor`가 따로 제어합니다. +네이티브 `grep`이 허용된 디렉터리 검색에서는 `glob`으로 파일을 고릅니다. `*.ts`는 모든 하위 폴더의 +파일명에 적용되고, `src/**/*.ts`는 선택한 검색 폴더를 기준으로 한 상대 경로에 적용됩니다(`./`로 시작해도 됩니다). 모든 운영체제에서 +패턴의 경로 구분자는 `/`를 사용합니다. 문자 범위와 중괄호 선택 패턴을 지원하며, Bun 1.4.4에서는 +`**/!(*.test).ts` 같은 제외 패턴과 `part-{1..3}.ts` 같은 숫자 범위도 지원합니다. +필터는 검색 폴더를 바꾸거나 네이티브 실행을 활성화하지 않습니다. + 각 `mcpServers.`는 `command`(stdio) 또는 `url`(Streamable HTTP) 중 하나를 받습니다. stdio는 `args`, `env`, `cwd`도 받습니다. HTTP는 `headers`를 받습니다. 둘 다 `enabled`(기본값 true)와 `toolPrefix`를 지원합니다. `desktopExecutor`는 `computerUseCommand`, `recordScreenCommand`, `cwd`, `env`, `timeoutMs`(기본값 `30000`)를 받습니다. 명령은 `sh -c`를 거치며 stdin에서 JSON 요청 하나를 읽고, stdout에 JSON 결과 하나를 써야 합니다. :::caution[보안] diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index b224370732b..7c572b79a08 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1451,6 +1451,12 @@ Set `nativeLocalExec` on `providers.cursor`, not at the top level. In the dashbo `nativeLocalExec: "on"` only when `nativeLocalExec` is unset. MCP, screen recording, and computer use are controlled separately by `mcpServers` and `desktopExecutor`. +For an enabled native `grep` directory search, `glob` filters file names: `*.ts` matches at any +depth, while `src/**/*.ts` matches paths relative to the selected search directory (`./` is optional). Use `/` for +pattern separators on every platform. Character classes and brace alternatives are supported; +Bun 1.4.4 also supports extglobs such as `**/!(*.test).ts` and ranges such as `part-{1..3}.ts`. +These filters do not change the selected directory or enable native execution. + Foreground `shellArgs` and `shellStreamArgs` requests are rejected before process creation on Windows, macOS, and Linux, including with `nativeLocalExec: "on"`. They require a kernel-backed descendant owner that this implementation does not provide; use the client's shell tool instead. diff --git a/scripts/test-layout/layout-adapters.json b/scripts/test-layout/layout-adapters.json index 8f3499417e7..476a7e864f7 100644 --- a/scripts/test-layout/layout-adapters.json +++ b/scripts/test-layout/layout-adapters.json @@ -1,4 +1,5 @@ { + "cursor-native-grep-globs.test.ts": "providers/cursor", "google-vertex-key-project.test.ts": "adapters/google", "provider-quota-abliteration.test.ts": "providers" } diff --git a/src/adapters/cursor/native-exec-fs.ts b/src/adapters/cursor/native-exec-fs.ts index b9bc94a7434..4510ffb3aba 100644 --- a/src/adapters/cursor/native-exec-fs.ts +++ b/src/adapters/cursor/native-exec-fs.ts @@ -1,5 +1,5 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { basename, dirname, extname, join, relative, resolve } from "node:path"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; import { create } from "@bufbuild/protobuf"; import { DeleteErrorSchema, @@ -235,19 +235,21 @@ export function lsExec(execMsg: ExecServerMessage): Uint8Array { } } -function matchesGlob(file: string, glob?: string): boolean { - if (!glob) return true; - if (glob.startsWith("*.") || glob.startsWith("**/*.")) return file.endsWith(glob.slice(glob.indexOf("*") + 1)); - return file.includes(glob.replaceAll("*", "")); +function grepFileMatcher(root: string, glob?: string): (file: string) => boolean { + if (!glob) return () => true; + const matcher = new Bun.Glob(glob.replace(/^(?:\.\/)+/, "")); + // Bare patterns apply to each basename; slash patterns are relative to the selected root. + const pathPattern = glob.includes("/"); + return file => matcher.match(pathPattern ? relative(root, file).split(sep).join("/") : basename(file)); } -function collectFiles(root: string, glob?: string, out: string[] = []): string[] { +function collectFiles(root: string, matchesFile: (file: string) => boolean, out: string[] = []): string[] { if (out.length >= MAX_GREP_FILES) return out; for (const entry of readdirSync(root, { withFileTypes: true })) { if (entry.name === "node_modules" || entry.name === ".git") continue; const path = join(root, entry.name); - if (entry.isDirectory()) collectFiles(path, glob, out); - if (entry.isFile() && matchesGlob(path, glob)) out.push(path); + if (entry.isDirectory()) collectFiles(path, matchesFile, out); + if (entry.isFile() && matchesFile(path)) out.push(path); if (out.length >= MAX_GREP_FILES) break; } return out; @@ -269,7 +271,7 @@ export function grepExec(execMsg: ExecServerMessage): Uint8Array { const root = resolve(args.path || process.cwd()); try { const pattern = new RegExp(args.pattern, args.caseInsensitive ? "i" : ""); - const files = existsSync(root) && statSync(root).isDirectory() ? collectFiles(root, args.glob) : [root]; + const files = existsSync(root) && statSync(root).isDirectory() ? collectFiles(root, grepFileMatcher(root, args.glob)) : [root]; const matches = []; for (const file of files) { if (matches.length >= MAX_GREP_RESULTS) break; diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 76e5b5dce88..eeb5576f462 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -20,6 +20,18 @@ approval and sandbox path. `nativeLocalExec: "on"` is the explicit config-owner local experiments; `off` and the backwards-compatible `codex-sandbox` spelling both fail closed. MCP, screen recording, and computer-use stay on their separate explicit executor/MCP config paths. +For native `grep` directory searches, `src/adapters/cursor/native-exec-fs.ts` compiles the optional +filter once with `Bun.Glob`. A pattern without `/` matches each basename at every depth; a pattern +with `/` matches the path relative to the selected search root, using `/` on every platform; +an optional leading `./` keeps that root-relative scope. +This supports `**/*.ts`, brace alternatives and character classes; Bun 1.4.4 also supplies extglobs +and brace ranges. Empty filters keep the unfiltered search. The existing directory walk skips +`node_modules`, `.git` and symlink entries, collects at most 500 matching files, reads only files +at most 1,000,000 bytes, and returns at most 200 files with text matches. Glob matching never +expands or changes the search root. Explicit single-file searches retain their existing behavior. +`tests/providers/cursor/cursor-native-grep-globs.test.ts` exercises the dispatch gate, file selection, +all three output modes, traversal exclusions and runtime-specific syntax. + Foreground `shellArgs` and `shellStreamArgs` are unavailable on every platform, including with `nativeLocalExec: "on"`. `src/adapters/cursor/native-foreground-shell.ts` rejects them before spawn until a kernel-backed descendant owner exists; process-group disappearance is not cleanup proof. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index fed4bb7ebc7..d561d4b8c12 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -648,6 +648,7 @@ "cursor-message-mapper.test.ts": "providers/cursor", "cursor-native-exec-common.test.ts": "providers/cursor", "cursor-native-exec-policy.test.ts": "providers/cursor", "cursor-native-exec-shell.test.ts": "providers/cursor", "cursor-foreground-shell.test.ts": "providers/cursor", "cursor-native-exec.test.ts": "providers/cursor", + "cursor-native-grep-globs.test.ts": "providers/cursor", "cursor-oauth-shell.test.ts": "providers/cursor", "cursor-oauth.test.ts": "providers/cursor", "cursor-pool.test.ts": "providers/cursor", "cursor-protobuf-events.test.ts": "providers/cursor", "cursor-repetition-breaker.test.ts": "providers/cursor", "cursor-request-builder.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-native-grep-globs.test.ts b/tests/providers/cursor/cursor-native-grep-globs.test.ts new file mode 100644 index 00000000000..46430eda2ba --- /dev/null +++ b/tests/providers/cursor/cursor-native-grep-globs.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, sep } from "node:path"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { handleCursorNativeExec } from "../../../src/adapters/cursor/native-exec"; +import { + AgentClientMessageSchema, + ExecServerMessageSchema, + GrepArgsSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) removeTreeWithRetry(root); +}); + +function fixture(files: Record = {}): string { + const root = mkdtempSync(join(tmpdir(), "ocx-cursor-grep-globs-")); + roots.push(root); + for (const [path, content] of Object.entries(files)) { + const file = join(root, path); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); + } + return root; +} + +async function grep(root: string, glob?: string, outputMode = "files_with_matches", allow = true) { + const frames = await handleCursorNativeExec(create(ExecServerMessageSchema, { + id: 17, + execId: "grep-glob-test", + message: { case: "grepArgs", value: create(GrepArgsSchema, { pattern: "needle", path: root, glob, outputMode }) }, + }), { unsafeAllowNativeLocalExec: allow }); + const response = fromBinary(AgentClientMessageSchema, frames[0]!); + if (response.message.case !== "execClientMessage" || response.message.value.message.case !== "grepResult") { + throw new Error("Expected native grep response"); + } + return response.message.value.message.value.result; +} + +async function matchedFiles(root: string, glob?: string): Promise { + const response = await grep(root, glob); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + if (result.case !== "files") throw new Error("Expected native grep file list"); + return result.value.files.map(file => relative(root, file).split(sep).join("/")).sort(); +} + +describe("Cursor native grep globs", () => { + test("matches recursive and root-relative patterns without matching absolute ancestors", async () => { + const root = fixture({ + "main.ts": "needle", + "src/main.ts": "needle", + "src/deep/worker.ts": "needle", + "other/src/wrong.ts": "needle", + "src/notes.txt": "needle", + }); + expect(await matchedFiles(root, "**/*.ts")).toEqual([ + "main.ts", "other/src/wrong.ts", "src/deep/worker.ts", "src/main.ts", + ]); + expect(await matchedFiles(root, "src/*.ts")).toEqual(["src/main.ts"]); + expect(await matchedFiles(root, "src/**/*.ts")).toEqual(["src/deep/worker.ts", "src/main.ts"]); + expect(await matchedFiles(root, "./src/**/*.ts")).toEqual(["src/deep/worker.ts", "src/main.ts"]); + expect(await matchedFiles(root, "./*.ts")).toEqual(["main.ts"]); + expect(await matchedFiles(root, `${root.replaceAll(sep, "/")}/*.ts`)).toEqual([]); + }); + + test("matches basename patterns at every depth and preserves the unfiltered search", async () => { + const root = fixture({ "main.ts": "needle", "nested/main.ts": "needle", "nested/main.ts.bak": "needle", "note.txt": "needle" }); + expect(await matchedFiles(root, "*.ts")).toEqual(["main.ts", "nested/main.ts"]); + expect(await matchedFiles(root, "main.ts")).toEqual(["main.ts", "nested/main.ts"]); + expect(await matchedFiles(root, "./main.ts")).toEqual(["main.ts"]); + expect(await matchedFiles(root)).toEqual(["main.ts", "nested/main.ts", "nested/main.ts.bak", "note.txt"]); + expect(await matchedFiles(root, "")).toEqual(await matchedFiles(root)); + }); + + test("supports character classes and brace alternatives", async () => { + const root = fixture({ "a.ts": "needle", "b.tsx": "needle", "c.js": "needle", "nested/d.ts": "needle" }); + expect(await matchedFiles(root, "?.{ts,tsx}")).toEqual(["a.ts", "b.tsx", "nested/d.ts"]); + expect(await matchedFiles(root, "[ab].*")).toEqual(["a.ts", "b.tsx"]); + }); + + // Bun 1.4.3 is still the repository's test runner; these runtime features arrived in 1.4.4. + test.skipIf(!Bun.semver.satisfies(Bun.version, ">=1.4.4"))("uses Bun 1.4.4 extglobs and brace ranges", async () => { + const root = fixture({ + "src/item-1.ts": "needle", "src/item-2.ts": "needle", "src/item-3.ts": "needle", + "src/item-4.ts": "needle", "src/item-2.test.ts": "needle", "src/deep/item-2.ts": "needle", + }); + expect(await matchedFiles(root, "src/item-{1..3}.ts")).toEqual(["src/item-1.ts", "src/item-2.ts", "src/item-3.ts"]); + expect(await matchedFiles(root, "src/**/!(*.test).ts")).toEqual([ + "src/deep/item-2.ts", "src/item-1.ts", "src/item-2.ts", "src/item-3.ts", "src/item-4.ts", + ]); + expect(await matchedFiles(root, "@(item-1|item-4).ts")).toEqual(["src/item-1.ts", "src/item-4.ts"]); + }); + + test("retains the opt-in gate for native grep", async () => { + const root = fixture({ "main.ts": "needle" }); + expect((await grep(root, "**/*.ts", "files_with_matches", false)).case).toBe("error"); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["main.ts"]); + }); + + test("preserves explicit single-file searches without applying a directory glob", async () => { + const root = fixture({ "main.ts": "needle" }); + const file = join(root, "main.ts"); + const response = await grep(file, "*.js"); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + if (result.case !== "files") throw new Error("Expected native grep file list"); + expect(result.value.files).toEqual([file]); + }); + + test("keeps traversal skips, the file byte limit, and the selected search root", async () => { + const base = fixture({ + "search/src/main.ts": "needle", + "search/node_modules/dep.ts": "needle", + "search/.git/private.ts": "needle", + "search/large.ts": `needle${"x".repeat(1_000_000)}`, + "outside/other.ts": "needle", + }); + const root = join(base, "search"); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["src/main.ts"]); + expect(await matchedFiles(root, "../outside/*.ts")).toEqual([]); + }); + + test.skipIf(process.platform === "win32")("does not follow directory or file symlinks while filtering", async () => { + const base = fixture({ "search/main.ts": "needle", "outside/other.ts": "needle" }); + const root = join(base, "search"); + symlinkSync(join(base, "outside"), join(root, "linked")); + symlinkSync(join(base, "outside", "other.ts"), join(root, "linked.ts")); + expect(await matchedFiles(root, "**/*.ts")).toEqual(["main.ts"]); + }); + + test.each(["content", "count"])("applies globs to %s output", async outputMode => { + const root = fixture({ "src/main.ts": "needle\nneedle", "src/no.txt": "needle" }); + const response = await grep(root, "src/**/*.ts", outputMode); + if (response.case !== "success") throw new Error("Expected successful native grep"); + const result = Object.values(response.value.workspaceResults)[0]!.result; + expect(result.case).toBe(outputMode); + if (result.case === "content") { + expect(result.value.matches.map(match => match.file)).toEqual([join(root, "src", "main.ts")]); + expect(result.value.totalMatchedLines).toBe(2); + } else if (result.case === "count") { + expect(result.value.counts.map(match => ({ file: match.file, count: match.count }))).toEqual([{ file: join(root, "src", "main.ts"), count: 2 }]); + expect(result.value.totalFiles).toBe(1); + expect(result.value.totalMatches).toBe(2); + } + }); +}); From 41f08fffd3e7dea64a6eed79af918aed74a153dd Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Fri, 23 Oct 2026 22:07:35 +0900 Subject: [PATCH 4/7] 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/7] 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 967a8851ff2841686c7716e458c793a23b6cdaf5 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 25 Oct 2026 07:44:20 +0900 Subject: [PATCH 6/7] 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 7/7] 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) {