From 66f2a7770ebcc2f2761679bef12e4814ccb03537 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 02:53:53 +0000 Subject: [PATCH 01/18] ci(host-filesystem): run the host-install filesystem slice on three OSes Keep Verify, examples, release-gates, and pinned-CLI host-install proofs on ubuntu-latest. Add a three-cell matrix (ubuntu/macOS/Windows, Node 22.19) that builds once and runs only the host-install/filesystem tests that can diverge by OS. Co-authored-by: Zack Jackson --- .github/workflows/ci.yml | 38 ++++++++++++++++++++++++++++ docs/local-ci.md | 53 +++++++++++++++++++++++++++++++++++++--- package.json | 2 ++ rstest.config.ts | 5 ++-- scripts/local-ci.mjs | 4 ++- 5 files changed, 95 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8663aed8..2d09b5622 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -378,6 +378,44 @@ jobs: AGENT_BUNDLE_PACKAGE_PREBUILT: '1' run: pnpm test:packed:native + # Narrow OS matrix for the host-install / filesystem slice. The rest of CI + # stays on ubuntu-latest (Verify, examples, release-gates, the pinned-CLI + # host-install-proofs job). Cloning those onto three OSes would multiply the + # PR critical path; this job is the smallest proof that the platform-generic + # npm package's win32/darwin branches are not Ubuntu-only qualifications: + # `dev --install-host` rollback and stale ownership, install/uninstall + # receipts, durable-fs publish/fsync, packaged installer-bin resolution, + # and the #769 internal-child cwd policy. It does not install `claude` or + # `codex` — those pins stay in host-install-proofs — so Claude/Codex legs + # in `dev-host-install.test.ts` skip here the same way they skip locally + # when the binaries are absent. One Node line (22.19, engines floor) keeps + # the matrix at three cells, not nine. + host-filesystem: + needs: changes + if: >- + ${{ !cancelled() && + (github.event_name != 'pull_request' || needs.changes.outputs.docs_only != 'true') }} + name: Host filesystem (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: ./.github/actions/setup-workspace + with: + node-version: 22.19.0 + - run: pnpm build + # Dist is already built; packed-install-bin's ad-hoc pack must not + # rebuild it. Workbench is unused by this slice. + - name: Host-install filesystem slice + env: + AGENT_BUNDLE_PACKAGE_PREBUILT: '1' + AGENT_BUNDLE_WORKBENCH_PREBUILT: '1' + run: pnpm test:host-filesystem + rsc-runtime-micro-eval: # Deterministic end-to-end spot-check of the built RSC runtime artifacts # (hook -> RSC worker -> shared kernel state -> MCP tool lowering) without diff --git a/docs/local-ci.md b/docs/local-ci.md index 48fea412c..b209beecf 100644 --- a/docs/local-ci.md +++ b/docs/local-ci.md @@ -23,8 +23,8 @@ Verify-equivalent leg on whatever Node is currently active, with the repo's normal local worker derivation. It skips the Node matrix and the examples/release/micro-eval gates, so it is a fast signal, not a merge gate. -Docs-only PRs skip the hosted Verify, examples, release-gates, micro-eval, and -host-install-proofs jobs. Docs-only means changes under `docs/` or `agent-patterns/`, changeset +Docs-only PRs skip the hosted Verify, examples, release-gates, micro-eval, +host-install-proofs, and host-filesystem jobs. Docs-only means changes under `docs/` or `agent-patterns/`, changeset markdown (`.changeset/*.md`), or top-level markdown. Nested markdown elsewhere is treated as code. Pushes to `main` never use this skip. The allowlist and fail-open listing checks are implemented by `scripts/classify-docs-only.mjs` @@ -148,6 +148,44 @@ the `claude -p` session proofs (`AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION`, (`AGENT_BUNDLE_PACKED_NATIVE_{CLAUDE,CODEX}_SMOKE`). Those still run only in the opt-in `native-host-smoke` workflow on a signed-in runner. +## Host-install filesystem OS matrix + +The published `agent-bundle` package has no `os` field: it is a +platform-generic npm package, and `dev --install-host` already has Windows +junction publish, atomic rename/rollback, ownership manifests, path-casing +comparisons, and per-OS host config locations. Primary CI still runs Verify, +examples, release-gates, and the pinned-CLI host-install proofs on +`ubuntu-latest` only. Cloning that suite onto three OSes would multiply the +PR critical path. + +Hosted CI therefore adds one extra job, `host-filesystem`, on +`ubuntu-latest`, `macos-latest`, and `windows-latest` (Node 22.19, engines +floor — three cells, not a Node × OS product). It builds once and runs +`pnpm test:host-filesystem`: + +- `dev-host-install.test.ts` (Claude/Codex legs skip when those CLIs are + absent; this job does not install them) +- `dev-host-install-manager.test.ts` (rollback and stale ownership) +- `install.test.ts` / `uninstall.test.ts` (receipt ownership) +- `durable-fs.test.ts` (atomic publish, Windows directory fsync) +- `internal-child-resolution-policy.test.ts` (packaged child resolution, #769) +- `packed-install-bin.test.ts` (packaged installer bin from a consumer cwd) + +That is the slice that can actually diverge by OS. macOS matters because +Claude, Codex, and Cursor authors commonly develop there; Windows is in the +matrix because the code already has `win32` branches, not because the rest +of the suite is claimed green there. A green `Host filesystem (windows-latest)` +job is evidence for this slice only. + +To run the same slice locally after `pnpm build`: + +```sh +pnpm test:host-filesystem +``` + +The local gate does not fan this across OSes — one machine can prove one OS. +Hosted macOS and Windows results are the qualification for those runners. + ## Node provisioning The runner introduces no new tooling. For each hosted runtime line @@ -233,10 +271,17 @@ then treat a repeat as a real signal. does not install host CLIs into its legs, so run those proofs by hand with the commands above when a change touches adapter emission, the installers, or the proof suites. +- **host-filesystem** is the three-OS host-install/filesystem slice (see + [Host-install filesystem OS matrix](#host-install-filesystem-os-matrix)). + The local gate can run `pnpm test:host-filesystem` on the current OS after + `pnpm build`; it cannot prove macOS or Windows from a Linux machine. - **native-host-smoke** needs signed-in Claude/Codex CLIs and is opt-in even on hosted CI. -- **Environment skew**: hosted runners are `ubuntu-latest`. Hosted Workbench - browser suites launch Playwright's bundled Chromium — pinned by the +- **Environment skew**: Verify, examples, release-gates, micro-eval, and the + pinned-CLI host-install proofs stay on `ubuntu-latest`. The + `host-filesystem` job is the exception: `ubuntu-latest`, `macos-latest`, + and `windows-latest`. Hosted Workbench browser suites launch Playwright's + bundled Chromium — pinned by the Playwright version in the lockfile and selected with `AGENT_BUNDLE_PLAYWRIGHT_CHANNEL=chromium` (read by `packages/workbench/tests/support/workbench-e2e.ts`) — so the browser under diff --git a/package.json b/package.json index 21211b6d8..215dd6905 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "test:packed:native:codex": "node scripts/run-packed-native-smoke.mjs codex", "test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts packages/agent-bundle/tests/dev-host-install.test.ts packages/agent-bundle/tests/dev-live-host.test.ts packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts", "test:host-install:build": "pnpm build && pnpm test:host-install", + "test:host-filesystem": "rstest --config rstest.config.ts packages/agent-bundle/tests/dev-host-install.test.ts packages/agent-bundle/tests/dev-host-install-manager.test.ts packages/agent-bundle/tests/internal-child-resolution-policy.test.ts packages/agent-bundle/tests/install.test.ts packages/agent-bundle/tests/uninstall.test.ts packages/agent-bundle/tests/durable-fs.test.ts packages/agent-bundle/tests/packed-install-bin.test.ts", + "test:host-filesystem:build": "pnpm build && pnpm test:host-filesystem", "test:host-install:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-host-install-proof.test.ts", "test:host-install:session": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-session.test.ts", "test:host-install:session:claude": "pnpm build && AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION=1 pnpm test:host-install:session", diff --git a/rstest.config.ts b/rstest.config.ts index 48aec29a7..7b60773d1 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -20,8 +20,9 @@ import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** * The default config: what `rstest ` runs when a script names files - * without choosing a pool (`test:host-install`, `test:packed:native`, - * `test:session`, `test:examples:browser`) and what `test:watch` discovers. + * without choosing a pool (`test:host-install`, `test:host-filesystem`, + * `test:packed:native`, `test:session`, `test:examples:browser`) and what + * `test:watch` discovers. * Its scope is every test the adapter-less pools do not own, so integration, * packed and evidence files land here alongside the unit files. It therefore * carries the process pool's shape — the worker cap, the polling scale and diff --git a/scripts/local-ci.mjs b/scripts/local-ci.mjs index 185e3ec9c..2473e2310 100644 --- a/scripts/local-ci.mjs +++ b/scripts/local-ci.mjs @@ -22,7 +22,9 @@ * - dependency-review (GitHub-side action), package-preview and the release * publish (publish-side), host-install-proofs (needs the pinned claude and * codex CLIs on PATH; `pnpm check:host-cli` + the test:host-install - * scripts run it by hand), and native-host-smoke (opt-in, needs signed-in + * scripts run it by hand), host-filesystem (ubuntu/macOS/Windows matrix; + * `pnpm test:host-filesystem` after `pnpm build` on this machine covers + * only the current OS), and native-host-smoke (opt-in, needs signed-in * host CLIs) are intentionally not mirrored — see docs/local-ci.md. * * Isolation model: every leg gets its own git worktree pinned to HEAD with From 54207826cb2fb2342cdf176f3e76e6d6b04c92b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 03:12:18 +0000 Subject: [PATCH 02/18] fix(ci): canonicalize worker TMPDIR and drop example payload from OS slice macOS compared lexical /tmp fixtures to realpath /private/tmp results. Windows died in rstest.config.ts while building the Workbench e2e example this slice never uses. Give the host-filesystem job its own config and publish a realpath worker temp root. Co-authored-by: Zack Jackson --- .github/workflows/ci.yml | 6 ++- docs/local-ci.md | 4 +- package.json | 2 +- .../tests/dev-host-install.test.ts | 4 ++ .../tests/rstest-pool-configs.test.ts | 4 +- .../tests/rstest-worker-isolation.test.ts | 12 +++++- rstest.config.ts | 7 ++-- rstest.host-filesystem.config.ts | 41 +++++++++++++++++++ rstest.worker-isolation.ts | 9 +++- 9 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 rstest.host-filesystem.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d09b5622..652053cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -388,8 +388,10 @@ jobs: # and the #769 internal-child cwd policy. It does not install `claude` or # `codex` — those pins stay in host-install-proofs — so Claude/Codex legs # in `dev-host-install.test.ts` skip here the same way they skip locally - # when the binaries are absent. One Node line (22.19, engines floor) keeps - # the matrix at three cells, not nine. + # when the binaries are absent. The slice uses + # `rstest.host-filesystem.config.ts` (not `rstest.config.ts`) so it never + # builds the Workbench e2e example payload. One Node line (22.19, engines + # floor) keeps the matrix at three cells, not nine. host-filesystem: needs: changes if: >- diff --git a/docs/local-ci.md b/docs/local-ci.md index b209beecf..c994afe92 100644 --- a/docs/local-ci.md +++ b/docs/local-ci.md @@ -161,7 +161,8 @@ PR critical path. Hosted CI therefore adds one extra job, `host-filesystem`, on `ubuntu-latest`, `macos-latest`, and `windows-latest` (Node 22.19, engines floor — three cells, not a Node × OS product). It builds once and runs -`pnpm test:host-filesystem`: +`pnpm test:host-filesystem` (`rstest.host-filesystem.config.ts`, which does +not build the Workbench e2e example payload): - `dev-host-install.test.ts` (Claude/Codex legs skip when those CLIs are absent; this job does not install them) @@ -170,6 +171,7 @@ floor — three cells, not a Node × OS product). It builds once and runs - `durable-fs.test.ts` (atomic publish, Windows directory fsync) - `internal-child-resolution-policy.test.ts` (packaged child resolution, #769) - `packed-install-bin.test.ts` (packaged installer bin from a consumer cwd) +- `rstest-worker-isolation.test.ts` (canonical TMPDIR; macOS `/tmp` → `/private/tmp`) That is the slice that can actually diverge by OS. macOS matters because Claude, Codex, and Cursor authors commonly develop there; Windows is in the diff --git a/package.json b/package.json index 215dd6905..d2df30cd8 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "test:packed:native:codex": "node scripts/run-packed-native-smoke.mjs codex", "test:host-install": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-proof.test.ts packages/agent-bundle/tests/dev-host-install.test.ts packages/agent-bundle/tests/dev-live-host.test.ts packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts", "test:host-install:build": "pnpm build && pnpm test:host-install", - "test:host-filesystem": "rstest --config rstest.config.ts packages/agent-bundle/tests/dev-host-install.test.ts packages/agent-bundle/tests/dev-host-install-manager.test.ts packages/agent-bundle/tests/internal-child-resolution-policy.test.ts packages/agent-bundle/tests/install.test.ts packages/agent-bundle/tests/uninstall.test.ts packages/agent-bundle/tests/durable-fs.test.ts packages/agent-bundle/tests/packed-install-bin.test.ts", + "test:host-filesystem": "rstest --config rstest.host-filesystem.config.ts", "test:host-filesystem:build": "pnpm build && pnpm test:host-filesystem", "test:host-install:packed": "rstest --config rstest.config.ts packages/agent-bundle/tests/packed-host-install-proof.test.ts", "test:host-install:session": "rstest --config rstest.config.ts packages/agent-bundle/tests/host-install-session.test.ts", diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 5cf928cc3..6e00514c7 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -370,6 +370,10 @@ it('installs a marked public-host dev variant from a stable source and removes i }); it('refreshes a persistent Codex component snapshot before attaching each epoch', async () => { + // The fake Codex app-server listens on a Unix socket. Windows has no + // equivalent in this fixture; the rest of the slice still covers + // install/rollback/junctions. + if (process.platform === 'win32') return; const root = await createRoot(); const codexRoot = await mkdtemp(join(tmpdir(), 'codex-')); roots.push(codexRoot); diff --git a/packages/agent-bundle/tests/rstest-pool-configs.test.ts b/packages/agent-bundle/tests/rstest-pool-configs.test.ts index 6602b1230..9b6cf1fd8 100644 --- a/packages/agent-bundle/tests/rstest-pool-configs.test.ts +++ b/packages/agent-bundle/tests/rstest-pool-configs.test.ts @@ -36,6 +36,7 @@ const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../.. const poolConfigs = [ 'rstest.config.ts', + 'rstest.host-filesystem.config.ts', 'rstest.unit.config.ts', 'rstest.route-unit.config.ts', 'rstest.projection.config.ts', @@ -86,6 +87,7 @@ interface PoolExpectation { const expectations: Readonly> = { 'rstest.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, + 'rstest.host-filesystem.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 30_000 }, 'rstest.integration.config.ts': { extraSetupFiles: 0, globalSetup: examplePayloadGlobalSetup, testTimeout: 30_000 }, 'rstest.mcp-conformance.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 180_000 }, 'rstest.native-host.config.ts': { extraSetupFiles: 0, globalSetup: workspaceGlobalSetup, testTimeout: 60_000 }, @@ -161,7 +163,7 @@ describe('helper-built pools', () => { }); describe('process pools', () => { - it.each(['rstest.config.ts', 'rstest.integration.config.ts'] as const)('%s shares the worker cap and the polling scale', (name) => { + it.each(['rstest.config.ts', 'rstest.host-filesystem.config.ts', 'rstest.integration.config.ts'] as const)('%s shares the worker cap and the polling scale', (name) => { const workers = processPoolMaxWorkers(); expect(resolved[name].maxWorkers).toBe(workers); expect(resolved[name].env?.['AGENT_BUNDLE_TEST_TIME_SCALE']).toBe(String(processPoolTimeScale(workers))); diff --git a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts index dff4e589c..286ffcbdd 100644 --- a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts +++ b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts @@ -1,3 +1,4 @@ +import { realpathSync } from 'node:fs'; import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join } from 'node:path'; @@ -41,9 +42,18 @@ it('isolates concurrent Rstest invocations that share a host temporary root', () expect(firstRoot).not.toBe(secondRoot); }); +it('publishes a realpath worker root so TMPDIR matches production path canonicalization', () => { + const root = rstestWorkerRoot(); + expect(root).toBe(realpathSync(root)); +}); + it('stamps every worker root with the owner marker the local-CI runner cleans up by', () => { const root = rstestWorkerRoot(); - expect(root.startsWith(join(rstestWorkerRootsParent, rstestWorkerRootPrefix)) || process.platform === 'win32').toBe(true); + const parent = process.platform === 'win32' ? undefined : realpathSync(rstestWorkerRootsParent); + expect( + process.platform === 'win32' || + (parent !== undefined && root.startsWith(join(parent, rstestWorkerRootPrefix))), + ).toBe(true); // The setup file already isolated this worker, so TMPDIR points at the // root itself; the marker records the HOST temp root it was derived from // and the process that owns it. diff --git a/rstest.config.ts b/rstest.config.ts index 7b60773d1..2e10dc19f 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -20,9 +20,10 @@ import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; /** * The default config: what `rstest ` runs when a script names files - * without choosing a pool (`test:host-install`, `test:host-filesystem`, - * `test:packed:native`, `test:session`, `test:examples:browser`) and what - * `test:watch` discovers. + * without choosing a pool (`test:host-install`, `test:packed:native`, + * `test:session`, `test:examples:browser`) and what `test:watch` discovers. + * `test:host-filesystem` owns `rstest.host-filesystem.config.ts` so that + * slice never builds the rsc-agent-runtime example payload. * Its scope is every test the adapter-less pools do not own, so integration, * packed and evidence files land here alongside the unit files. It therefore * carries the process pool's shape — the worker cap, the polling scale and diff --git a/rstest.host-filesystem.config.ts b/rstest.host-filesystem.config.ts new file mode 100644 index 000000000..6c42b3946 --- /dev/null +++ b/rstest.host-filesystem.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from '@rstest/core'; + +import { + poolTimeouts, + processPoolMaxWorkers, + processPoolTimeScale, + workspaceGlobalSetup, + workspaceSetupFiles, +} from './rstest.pools.ts'; +import { withAgentBundleRslibConfig } from './rstest.rslib.ts'; + +/** + * Hosted three-OS slice (`pnpm test:host-filesystem`): host-install + * rollback/ownership, durable-fs, packaged installer-bin, and the #769 + * internal-child policy. It is not `rstest.config.ts` — that config's + * orchestrator builds `examples/rsc-agent-runtime` for Workbench e2e + * fixtures, which this slice never copies and which fails on Windows + * (`hook/index.js` vs `hook\\index.js` in runtime-assets.json). + */ +const maxWorkers = processPoolMaxWorkers(); + +export const hostFilesystemTestFiles = [ + 'packages/agent-bundle/tests/dev-host-install.test.ts', + 'packages/agent-bundle/tests/dev-host-install-manager.test.ts', + 'packages/agent-bundle/tests/internal-child-resolution-policy.test.ts', + 'packages/agent-bundle/tests/install.test.ts', + 'packages/agent-bundle/tests/uninstall.test.ts', + 'packages/agent-bundle/tests/durable-fs.test.ts', + 'packages/agent-bundle/tests/packed-install-bin.test.ts', + 'packages/agent-bundle/tests/rstest-worker-isolation.test.ts', +] as const; + +export default defineConfig({ + extends: withAgentBundleRslibConfig(), + include: [...hostFilesystemTestFiles], + globalSetup: [...workspaceGlobalSetup], + pool: { maxWorkers }, + setupFiles: [...workspaceSetupFiles], + env: { AGENT_BUNDLE_TEST_TIME_SCALE: String(processPoolTimeScale(maxWorkers)) }, + ...poolTimeouts(30_000), +}); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts index f8bc32d6a..cfc9b6d87 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -76,7 +76,12 @@ export const rstestWorkerRoot = (): string => { const root = rstestWorkerRootPath(hostTemporaryRoot, workerId); mkdirSync(root, { recursive: true }); writeOwnerMarker(root, workerId); - return root; + // macOS `/tmp` is a symlink to `/private/tmp`. Install, receipt, and + // durable-fs code realpath destinations; tests that compare `os.tmpdir()` + // strings to those results must see the same spelling. The short `/tmp` + // construction above stays for AF_UNIX headroom; TMPDIR gets the + // canonical path after the directory exists. + return realpathSync(root); }; export const rstestWorkerCacheDirectory = (name: string): string => { From 789a5313a5952bb0c8fa57aaf341db158e25b630 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 03:30:53 +0000 Subject: [PATCH 03/18] fix(windows): host-install path identity, durable fsync, packed bin launch Judge project paths by on-disk identity so Windows 8.3 aliases and native config paths no longer raise AB7001. Tolerate directory FlushFileBuffers EPERM, replace development-host junctions by moving the previous pointer aside, and launch packed npm/framework bins through Node entrypoints with HOME plus USERPROFILE isolation. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 5 + packages/agent-bundle/src/core/durable-fs.ts | 7 +- packages/agent-bundle/src/core/errors.ts | 10 +- .../agent-bundle/src/core/project-context.ts | 11 ++- packages/agent-bundle/src/dev/epoch-store.ts | 4 +- .../src/dev/host-install-manager.ts | 6 +- .../playground/native-playground-service.ts | 4 +- .../src/dev/playground/playground-store.ts | 11 ++- .../tests/dev-host-install.test.ts | 4 +- .../agent-bundle/tests/durable-fs.test.ts | 8 +- packages/agent-bundle/tests/install.test.ts | 11 ++- .../tests/native-playground-service.test.ts | 8 +- .../tests/packed-install-bin.test.ts | 92 ++++++++++++++++--- .../tests/playground-service.test.ts | 2 +- .../tests/support/host-install.ts | 29 ++++-- packages/agent-bundle/tests/uninstall.test.ts | 12 ++- rstest.worker-isolation.ts | 17 ++-- 17 files changed, 179 insertions(+), 62 deletions(-) create mode 100644 .changeset/host-install-windows-path-identity.md diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md new file mode 100644 index 000000000..d5f513d82 --- /dev/null +++ b/.changeset/host-install-windows-path-identity.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +On Windows, `createProjectContext` now judges project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`, development host install replaces directory junctions by moving the previous pointer aside, and directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap. (#787) diff --git a/packages/agent-bundle/src/core/durable-fs.ts b/packages/agent-bundle/src/core/durable-fs.ts index cb5f88322..589b32ff8 100644 --- a/packages/agent-bundle/src/core/durable-fs.ts +++ b/packages/agent-bundle/src/core/durable-fs.ts @@ -34,9 +34,10 @@ export const syncPath = async (path: string, options: SyncPathOptions = {}): Pro try { await handle.sync(); } catch (error) { - // Windows has no public directory-fsync primitive. Only documented - // directory FlushFileBuffers capability failures are tolerated here; - // opening a directory and every retained regular-file sync still fail. + // Windows has no public directory-fsync primitive. Documented directory + // FlushFileBuffers capability failures (EACCES, EINVAL, EPERM) are + // tolerated here; opening a directory and every retained regular-file + // sync still fail. if (options.directory === true && isTolerableWin32SyncError(options.platform ?? process.platform, error)) return; throw error; } finally { diff --git a/packages/agent-bundle/src/core/errors.ts b/packages/agent-bundle/src/core/errors.ts index 7290b5ffa..c41eb8c25 100644 --- a/packages/agent-bundle/src/core/errors.ts +++ b/packages/agent-bundle/src/core/errors.ts @@ -20,6 +20,12 @@ export class CodedError extends Error { } } -/** Windows denies fsync on directories and AV-locked files; durability there is best-effort. */ +/** + * Windows has no public directory-fsync primitive. FlushFileBuffers on a + * directory handle fails with EACCES, EINVAL, or EPERM depending on the + * volume and Node/libuv mapping; durability there is best-effort. Regular + * files never use this helper. + */ export const isTolerableWin32SyncError = (platform: string, error: unknown): boolean => - platform === 'win32' && (isErrno(error, 'EACCES') || isErrno(error, 'EINVAL')); + platform === 'win32' + && (isErrno(error, 'EACCES') || isErrno(error, 'EINVAL') || isErrno(error, 'EPERM')); diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 7d2d80d0e..921b7da9f 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -261,8 +261,11 @@ const projectRelativePath = (root: string, value: string, label: string): string const resolvedProjectPath = (root: string, value: string, label: string): string => { const canonicalRoot = realpathSync(resolve(root)); const lexicalRoot = resolve(root); - projectRelativePath(lexicalRoot, value, label); - const referencedPath = realpathSync(resolve(lexicalRoot, value)); + // Relative authored paths stay POSIX-canonical. Absolute on-disk paths + // (Windows `C:\…`, 8.3 aliases) are judged by realpath identity so a + // short-name root and a long-name config file still name one project. + if (!isAbsolute(value)) projectRelativePath(lexicalRoot, value, label); + const referencedPath = realpathSync(isAbsolute(value) ? value : resolve(lexicalRoot, value)); if (escapesRoot(canonicalRoot, referencedPath)) { throw new RangeError(`${label} ${JSON.stringify(referencedPath)} is outside project root ${JSON.stringify(canonicalRoot)}.`); } @@ -272,7 +275,7 @@ const resolvedProjectPath = (root: string, value: string, label: string): string }; const canonicalCompilerPath = (root: string, value: string, label: string): string => - isAbsolute(value) ? projectRelativePath(root, value, label) : value; + isAbsolute(value) ? resolvedProjectPath(root, value, label) : value; const canonicalProvenance = (root: string, provenance: SourceProvenance): SourceProvenance => ({ ...provenance, @@ -641,7 +644,7 @@ const canonicalSourceInputs = ( export const createProjectContext = (options: CreateProjectContextOptions): ProjectContext => { const canonicalRoot = realpathSync(resolve(options.root)); const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path'); - const sourceInputs = canonicalSourceInputs(options.root, options.sourceInputs); + const sourceInputs = canonicalSourceInputs(canonicalRoot, options.sourceInputs); const configInput = sourceInputs.find((input) => input.path === configPath); if (configInput === undefined) { throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`); diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 0e6e8ce02..6377fa38e 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -5,7 +5,7 @@ import { lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, w import { basename, dirname, join, relative, resolve } from 'node:path'; import { stableJson } from '../core/digest.ts'; -import { isErrno } from '../core/errors.ts'; +import { isErrno, isTolerableWin32SyncError } from '../core/errors.ts'; import { exists, isInside } from '../core/paths.ts'; import { hasExactOwnKeys, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { runPromise, runSync } from '../effect/boundary.ts'; @@ -704,7 +704,7 @@ export class EpochStore { try { await handle.sync(); } catch (error) { - if (directory && process.platform === 'win32' && (isErrno(error, 'EACCES') || isErrno(error, 'EINVAL'))) return; + if (directory && isTolerableWin32SyncError(process.platform, error)) return; throw error; } finally { await handle.close(); } diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index e257073c7..b9d90ab1b 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -334,7 +334,11 @@ const publishDirectoryPointer = async ( let moved = false; try { const metadata = await lstat(path).catch(() => undefined); - if (metadata !== undefined && !metadata.isSymbolicLink()) { + // Unix `rename` replaces a symlink in place. Windows treats a junction as + // a directory, so rename onto an existing pointer fails; move it aside + // first, including when lstat reports a symbolic link. + const replaceInPlace = process.platform !== 'win32' && metadata?.isSymbolicLink() === true; + if (metadata !== undefined && !replaceInPlace) { await rename(path, movedAside); moved = true; } diff --git a/packages/agent-bundle/src/dev/playground/native-playground-service.ts b/packages/agent-bundle/src/dev/playground/native-playground-service.ts index 4b00847f0..cc52b8a57 100644 --- a/packages/agent-bundle/src/dev/playground/native-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/native-playground-service.ts @@ -28,7 +28,7 @@ import type { NativePlaygroundHost } from './native-playground-types.ts'; import { safeDevWireText } from '../logs/dev-log-service.ts'; import type { ArtifactEpoch } from '../types.ts'; import { workspaceDiff, type WorkspaceDiff } from '../../eval/workspace-diff.ts'; -import { isErrno } from '../../core/errors.ts'; +import { isErrno, isTolerableWin32SyncError } from '../../core/errors.ts'; import { isInsideOrEqual, sameFile } from '../../core/paths.ts'; export type { NativePlaygroundHost } from './native-playground-types.ts'; @@ -1459,7 +1459,7 @@ export class NativePlaygroundService { try { await handle.sync(); } catch (error) { - if (catalogDurabilityPlatform() === 'win32' && (isErrno(error, 'EACCES') || isErrno(error, 'EINVAL'))) return; + if (isTolerableWin32SyncError(catalogDurabilityPlatform(), error)) return; throw error; } finally { await handle.close(); diff --git a/packages/agent-bundle/src/dev/playground/playground-store.ts b/packages/agent-bundle/src/dev/playground/playground-store.ts index 5883663be..f981c1ce9 100644 --- a/packages/agent-bundle/src/dev/playground/playground-store.ts +++ b/packages/agent-bundle/src/dev/playground/playground-store.ts @@ -5,7 +5,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { serialQueue, type SerialQueue } from '../../core/async.ts'; import { containsProviderCredential, isCredentialKey } from '../../core/credentials.ts'; -import { isErrno } from '../../core/errors.ts'; +import { isErrno, isTolerableWin32SyncError } from '../../core/errors.ts'; import { isInsideOrEqual } from '../../core/paths.ts'; import { hasExactOwnKeys, isRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import type { DevLogSink } from '../logs/dev-log-service.ts'; @@ -1390,10 +1390,11 @@ export class PlaygroundService { runDurabilityTestHook(`before-directory-fsync:${reason}`, path); fsyncSync(descriptor); } catch (error) { - // Windows has no public directory-fsync primitive. Only documented - // directory FlushFileBuffers capability failures are tolerated here; - // opening a directory and every retained regular-file sync still fail. - if (durabilityPlatform() === 'win32' && (isErrno(error, 'EACCES') || isErrno(error, 'EINVAL'))) return; + // Windows has no public directory-fsync primitive. Documented directory + // FlushFileBuffers capability failures (EACCES, EINVAL, EPERM) are + // tolerated here; opening a directory and every retained regular-file + // sync still fail. + if (isTolerableWin32SyncError(durabilityPlatform(), error)) return; throw error; } finally { closeSync(descriptor); diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 6e00514c7..dc0137cbf 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -34,6 +34,7 @@ const claudeAvailable = spawnSync('claude', ['--version'], { stdio: 'ignore', ti const codexAvailable = spawnSync('codex', ['--version'], { stdio: 'ignore', timeout: 5_000 }).status === 0; const claudeIt = claudeAvailable ? it : it.skip; const codexIt = codexAvailable ? it : it.skip; +const unixSocketIt = process.platform === 'win32' ? it.skip : it; beforeAll(async () => { fixture = await buildHostInstallFixture({ environment: process.env }); @@ -369,11 +370,10 @@ it('installs a marked public-host dev variant from a stable source and removes i expect(uninstalls).toHaveLength(2); }); -it('refreshes a persistent Codex component snapshot before attaching each epoch', async () => { +unixSocketIt('refreshes a persistent Codex component snapshot before attaching each epoch', async () => { // The fake Codex app-server listens on a Unix socket. Windows has no // equivalent in this fixture; the rest of the slice still covers // install/rollback/junctions. - if (process.platform === 'win32') return; const root = await createRoot(); const codexRoot = await mkdtemp(join(tmpdir(), 'codex-')); roots.push(codexRoot); diff --git a/packages/agent-bundle/tests/durable-fs.test.ts b/packages/agent-bundle/tests/durable-fs.test.ts index 0ca214c7e..56aaeba23 100644 --- a/packages/agent-bundle/tests/durable-fs.test.ts +++ b/packages/agent-bundle/tests/durable-fs.test.ts @@ -25,18 +25,18 @@ it('tolerates only documented Windows directory fsync capability failures', asyn sync: async () => { throw errnoFailure(code, `${code} sync failed`); }, }); - for (const code of ['EACCES', 'EINVAL'] as const) { + for (const code of ['EACCES', 'EINVAL', 'EPERM'] as const) { await expect(syncPath('/ignored', { directory: true, open: failingOpen(code), platform: 'win32' })) .resolves.toBeUndefined(); } - await expect(syncPath('/ignored', { directory: true, open: failingOpen('EPERM'), platform: 'win32' })) - .rejects.toMatchObject({ code: 'EPERM' }); + await expect(syncPath('/ignored', { directory: true, open: failingOpen('EIO'), platform: 'win32' })) + .rejects.toMatchObject({ code: 'EIO' }); await expect(syncPath('/ignored', { directory: true, open: failingOpen('EACCES'), platform: 'linux' })) .rejects.toMatchObject({ code: 'EACCES' }); // Regular files never tolerate the gap, even on Windows. await expect(syncPath('/ignored', { open: failingOpen('EACCES'), platform: 'win32' })) .rejects.toMatchObject({ code: 'EACCES' }); - expect(closed).toHaveLength(5); + expect(closed).toHaveLength(6); }); it('publishes files by hard link, adopts raced winners, and never leaves staging behind', async () => { diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 188ac5d64..219c6d4c4 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,6 +1,6 @@ import { execFile as executeFile } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { access, chmod, cp, link, lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { access, chmod, cp, link, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -162,7 +162,14 @@ const createHostBundle = async ( ...(host === 'cursor' ? {} : { marketplace: 'install-fixture-marketplace' }), }], ); - return { bundleRoot, cleanupRoot, from }; + // Production `readArtifactManifest` realpaths the bundle (Windows 8.3 → long + // path). Tests compare CLI cwd/args and receipt hashes to this identity. + const canonicalBundle = await realpath(bundleRoot); + return { + bundleRoot: canonicalBundle, + cleanupRoot, + from: options.nestedUnder === undefined ? canonicalBundle : from, + }; }; const refreshCursorBundle = async (fixture: { readonly bundleRoot: string }): Promise => diff --git a/packages/agent-bundle/tests/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index 26ceb063f..233e9f54b 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -600,18 +600,12 @@ it('tolerates only Windows directory fsync capability failures during catalog pu }); }; try { - for (const code of ['EACCES', 'EINVAL'] as const) { + for (const code of ['EACCES', 'EINVAL', 'EPERM'] as const) { await rm(catalogDirectory, { force: true, recursive: true }); const service = serviceFor(code); await expect(service.catalog(epoch(`epoch-${code.toLowerCase()}`, join(root, code)))).resolves.toMatchObject({ epochId: `epoch-${code.toLowerCase()}` }); await service.close(); } - await rm(catalogDirectory, { force: true, recursive: true }); - const service = serviceFor('EPERM'); - await expect(service.catalog(epoch('epoch-eperm', join(root, 'EPERM')))).rejects.toMatchObject({ - errors: [expect.objectContaining({ code: 'EPERM' }), expect.objectContaining({ code: 'EPERM' })], - }); - await service.close(); } finally { if (previousPlatform === undefined) delete runtime[nativeCatalogDurabilityPlatformKey]; else runtime[nativeCatalogDurabilityPlatformKey] = previousPlatform; diff --git a/packages/agent-bundle/tests/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts index d788eb8fa..da73aee3f 100644 --- a/packages/agent-bundle/tests/packed-install-bin.test.ts +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -1,14 +1,18 @@ import { execFile as executeFile } from 'node:child_process'; +import { existsSync } from 'node:fs'; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; +import { packageBinEntries } from '../src/core/package-dependencies.ts'; import { removeProjectSource } from '../src/test/packed.ts'; import { runBin } from './support/bin-process.ts'; import { within } from './support/eventually.ts'; +import { packedNativeNodeCommand } from './support/packed-native-smoke.ts'; import { cachedNpmInstallArguments, installedEnvironment, @@ -21,10 +25,12 @@ const execFile = promisify(executeFile); const packageName = 'install-bin-fixture'; const binName = 'demo-install'; const agentBundleImport = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]agent-bundle(?:\/[^'"]*)?['"]/u; +const homeEnvKeys = new Set(['home', 'userprofile']); let consumer = ''; let home = ''; let bin = ''; +let childEnvironment: NodeJS.ProcessEnv = {}; interface Run { readonly code: number | null; @@ -33,9 +39,56 @@ interface Run { readonly stdout: string; } +/** npm's JavaScript CLI, never the extensionless `.bin` shim or `npm.cmd`. */ +const resolveNpmCli = (): string => { + const fromEnv = process.env['npm_execpath']; + if (fromEnv !== undefined && fromEnv.length > 0 && fromEnv.endsWith('npm-cli.js') && existsSync(fromEnv)) { + return fromEnv; + } + const besideNode = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'); + if (existsSync(besideNode)) return besideNode; + return createRequire(import.meta.url).resolve('npm/bin/npm-cli.js'); +}; + +const runNodeEntrypoint = async ( + entrypoint: string, + args: readonly string[], + options: { readonly cwd: string; readonly env: NodeJS.ProcessEnv }, +): Promise<{ readonly stdout: string }> => { + const command = packedNativeNodeCommand(entrypoint, args); + return execFile(command.executable, [...command.args], { + cwd: options.cwd, + env: options.env, + }); +}; + +const packageBinPath = async (packageRoot: string, name: string): Promise => { + const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as Readonly>; + const entry = packageBinEntries(manifest).find(([bin]) => bin === name); + if (entry === undefined) { + throw new Error(`${packageRoot} package.json does not declare the ${name} bin.`); + } + return resolve(packageRoot, entry[1]); +}; + +/** + * Isolates the installer's home. `os.homedir()` follows HOME on POSIX and + * USERPROFILE on Windows; leftover `UserProfile` spellings must not win. + */ +const isolatedHomeEnvironment = (homeDirectory: string): NodeJS.ProcessEnv => { + const isolated: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(installedEnvironment())) { + if (homeEnvKeys.has(key.toLowerCase())) continue; + isolated[key] = value; + } + isolated.HOME = homeDirectory; + isolated.USERPROFILE = homeDirectory; + return isolated; +}; + /** Runs the installed bin from a directory that is neither the package nor the artifact, with an isolated home. */ const run = async (args: readonly string[]): Promise => { - const child = runBin(bin, args, { cwd: consumer, env: { ...installedEnvironment(), HOME: home } }); + const child = runBin(bin, args, { cwd: consumer, env: childEnvironment }); const { code } = await within(child.exit, 60_000 * timeScale); const stdout = child.stdout(); return { code, json: () => JSON.parse(stdout) as T, stderr: child.stderr(), stdout }; @@ -45,7 +98,10 @@ beforeAll(async () => { const { tarball: agentBundle } = await sharedPackedTarball('agent-bundle'); consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-install-bin-')); home = join(consumer, 'home'); + childEnvironment = isolatedHomeEnvironment(home); const project = join(consumer, 'project'); + const npmCli = resolveNpmCli(); + const installEnv = installedEnvironment(); await mkdir(join(project, 'src', 'skills', 'demo'), { recursive: true }); await Promise.all([ // Installers never create a Cursor home (AB7002); the fixture host has one. @@ -80,17 +136,24 @@ beforeAll(async () => { writeFile(join(project, 'src', 'skills', 'demo', 'SKILL.md'), '---\nname: demo\ndescription: A demo skill.\n---\n\nDemo.\n'), writeFile(join(project, 'README.md'), '# install-bin fixture\n'), ]); - await execFile('npm', ['install', '--save-dev', ...cachedNpmInstallArguments, agentBundle], { cwd: project, env: installedEnvironment() }); - const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); - await execFile(cli, ['prepack', '--root', project, '--output', 'artifact'], { cwd: project, env: installedEnvironment() }); + await runNodeEntrypoint(npmCli, ['install', '--save-dev', ...cachedNpmInstallArguments, agentBundle], { + cwd: project, + env: installEnv, + }); + const frameworkRoot = join(project, 'node_modules', 'agent-bundle'); + const cli = await packageBinPath(frameworkRoot, 'agent-bundle'); + await runNodeEntrypoint(cli, ['prepack', '--root', project, '--output', 'artifact'], { + cwd: project, + env: installEnv, + }); const tarballs = join(consumer, 'tarballs'); const installed = join(consumer, 'installed'); await Promise.all([mkdir(tarballs), mkdir(installed)]); - const { stdout: packJson } = await execFile( - 'npm', + const { stdout: packJson } = await runNodeEntrypoint( + npmCli, ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], - { cwd: join(project, 'dist'), env: installedEnvironment() }, + { cwd: join(project, 'dist'), env: installEnv }, ); const packed = packOutputFromJson(packJson, packageName); expect(packed.files.map((file) => file.path)).toEqual(expect.arrayContaining([ @@ -98,11 +161,18 @@ beforeAll(async () => { `bin/${binName}.js`, ])); await writeFile(join(installed, 'package.json'), '{"private":true}\n'); - await execFile('npm', ['install', ...cachedNpmInstallArguments, join(tarballs, packed.filename)], { + await runNodeEntrypoint(npmCli, ['install', ...cachedNpmInstallArguments, join(tarballs, packed.filename)], { cwd: installed, - env: installedEnvironment(), + env: installEnv, }); - bin = resolve(installed, 'node_modules', packageName, `bin/${binName}.js`); + bin = await packageBinPath(join(installed, 'node_modules', packageName), binName); + // `os.homedir()` must resolve to the fixture before any install/doctor/uninstall. + const { stdout: resolvedHome } = await execFile( + process.execPath, + ['--input-type=module', '--eval', "import { homedir } from 'node:os'; process.stdout.write(homedir());"], + { cwd: consumer, env: childEnvironment }, + ); + expect(resolvedHome).toBe(home); // `packed-deleted-source`: the source project, its build, and its node_modules // (the only `agent-bundle` on disk) are gone before the bin runs. await removeProjectSource({ projectRoot: project }); diff --git a/packages/agent-bundle/tests/playground-service.test.ts b/packages/agent-bundle/tests/playground-service.test.ts index 9378be1ec..86971c354 100644 --- a/packages/agent-bundle/tests/playground-service.test.ts +++ b/packages/agent-bundle/tests/playground-service.test.ts @@ -203,7 +203,7 @@ const createFixture = async (input: Readonly<{ }; it('tolerates only unsupported Windows directory fsync errors', async () => { - for (const code of ['EACCES', 'EINVAL'] as const) { + for (const code of ['EACCES', 'EINVAL', 'EPERM'] as const) { const fixture = await createFixture(); let observed = false; try { diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 4933bb350..9d4aa173f 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -511,11 +511,24 @@ const normalizedRelative = (root: string, path: string): string => const isolatedEnvironment = ( environment: Readonly, values: Readonly, -): NodeJS.ProcessEnv => ({ - ...packedNativeEnvironment(environment), - ...values, - ...(values.HOME === undefined ? {} : { USERPROFILE: values.HOME }), -}); +): NodeJS.ProcessEnv => { + const merged: NodeJS.ProcessEnv = { + ...packedNativeEnvironment(environment), + ...values, + ...(values.HOME === undefined ? {} : { USERPROFILE: values.HOME }), + }; + if (values.HOME === undefined) return merged; + // Windows env names are case-insensitive: a leftover `UserProfile` from the + // runner would otherwise win over the fixture HOME we just assigned. + const isolated: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(merged)) { + if (key.toLowerCase() === 'home' || key.toLowerCase() === 'userprofile') continue; + isolated[key] = value; + } + isolated.HOME = values.HOME; + isolated.USERPROFILE = values.HOME; + return isolated; +}; const stringEnvironment = ( environment: Readonly, @@ -603,7 +616,11 @@ const buildFixtureProject = async (options: { const artifactRoot = join(project, 'artifact'); try { await cp(join(fixturesRoot, options.fixture), project, { recursive: true }); - await symlink(join(packageRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); + await symlink( + join(packageRoot, 'node_modules'), + join(project, 'node_modules'), + process.platform === 'win32' ? 'junction' : 'dir', + ); await options.prepareProject?.(project); const result = await run(process.execPath, [ cli, diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 67a5f66a3..df11d3b96 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -90,7 +90,11 @@ const createFixture = async ( ...(mcp === undefined ? {} : { mcp }), }], ); - return { bundleRoot, cleanupRoot, home }; + return { + bundleRoot: await realpath(bundleRoot), + cleanupRoot: await realpath(cleanupRoot), + home: await realpath(home), + }; }; const writeFixtureMcp = async ( @@ -618,7 +622,9 @@ it('rolls back earlier state markers when a later root cannot be recorded', asyn plugin: 'uninstall-fixture', pluginRoot: fixture.bundleRoot, scope: 'user', - })).rejects.toMatchObject({ code: 'ENAMETOOLONG' }); + })).rejects.toMatchObject({ + code: expect.stringMatching(/^(?:ENAMETOOLONG|EINVAL)$/u), + }); await expect(readdir(firstRoot)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts index cfc9b6d87..9451f4eef 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { rstestRunIdVariable, rstestWorkerRootOwnerFile } from './scripts/rstest-worker-roots.mjs'; @@ -76,12 +76,15 @@ export const rstestWorkerRoot = (): string => { const root = rstestWorkerRootPath(hostTemporaryRoot, workerId); mkdirSync(root, { recursive: true }); writeOwnerMarker(root, workerId); - // macOS `/tmp` is a symlink to `/private/tmp`. Install, receipt, and - // durable-fs code realpath destinations; tests that compare `os.tmpdir()` - // strings to those results must see the same spelling. The short `/tmp` - // construction above stays for AF_UNIX headroom; TMPDIR gets the - // canonical path after the directory exists. - return realpathSync(root); + // macOS `/tmp` is a symlink to `/private/tmp`. Windows TEMP is often the + // 8.3 form `C:\Users\RUNNER~1\...` while `realpath` of a file under it + // expands to `C:\Users\runneradmin\...`. Install, receipt, and durable-fs + // code realpath destinations; tests that compare `os.tmpdir()` strings to + // those results must see the same spelling. Prefer the owner marker file: + // GetFinalPathNameByHandle expands 8.3 names more reliably for files than + // for the directory handle used to create this root. + const marker = join(root, rstestWorkerRootOwnerFile); + return existsSync(marker) ? dirname(realpathSync(marker)) : realpathSync(root); }; export const rstestWorkerCacheDirectory = (name: string): string => { From b189762af04e591f28e32595d7bd0385a614a941 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 03:32:30 +0000 Subject: [PATCH 04/18] fix(test): resolve npm-cli.js from nvm and official Node layouts The packed-install-bin harness now checks Node's sibling npm, the official node_modules/npm path, and nvm's lib/node_modules layout before falling back to createRequire. Co-authored-by: Zack Jackson --- .../tests/packed-install-bin.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/tests/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts index da73aee3f..ae96d61c1 100644 --- a/packages/agent-bundle/tests/packed-install-bin.test.ts +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, realpathSync } from 'node:fs'; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; @@ -41,12 +41,24 @@ interface Run { /** npm's JavaScript CLI, never the extensionless `.bin` shim or `npm.cmd`. */ const resolveNpmCli = (): string => { - const fromEnv = process.env['npm_execpath']; - if (fromEnv !== undefined && fromEnv.length > 0 && fromEnv.endsWith('npm-cli.js') && existsSync(fromEnv)) { - return fromEnv; + const sibling = join(dirname(process.execPath), 'npm'); + let siblingTarget: string | undefined; + try { + siblingTarget = existsSync(sibling) ? realpathSync(sibling) : undefined; + } catch { + siblingTarget = undefined; + } + const candidates = [ + process.env['npm_execpath'], + join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), + join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), + siblingTarget, + ]; + for (const candidate of candidates) { + if (candidate !== undefined && candidate.endsWith('npm-cli.js') && existsSync(candidate)) { + return candidate; + } } - const besideNode = join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'); - if (existsSync(besideNode)) return besideNode; return createRequire(import.meta.url).resolve('npm/bin/npm-cli.js'); }; From c84c6a47bca127920e03b03ac9ff9696e036f642 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 03:50:40 +0000 Subject: [PATCH 05/18] fix: lexical project paths, PATH npm-cli, and Windows host-fs skips Preserve ENOENT-tolerant lexical canonicalization for not-yet-built hook/payload paths. Resolve npm-cli.js from official, nvm, and PATH layouts without createRequire. Report POSIX-only install tests as skipped on Windows, and treat file-parent state roots as unproven on Windows EEXIST. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- .../agent-bundle/src/core/project-context.ts | 23 ++++- .../agent-bundle/src/install/state-root.ts | 2 + packages/agent-bundle/tests/install.test.ts | 22 ++--- .../tests/npm-cli-resolution.test.ts | 83 ++++++++++++++++++ .../tests/packed-install-bin.test.ts | 27 +----- .../tests/rstest-worker-isolation.test.ts | 5 +- .../agent-bundle/tests/support/npm-cli.ts | 86 +++++++++++++++++++ rstest.host-filesystem.config.ts | 1 + 9 files changed, 213 insertions(+), 38 deletions(-) create mode 100644 packages/agent-bundle/tests/npm-cli-resolution.test.ts create mode 100644 packages/agent-bundle/tests/support/npm-cli.ts diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index d5f513d82..b3a2d79c4 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`, development host install replaces directory junctions by moving the previous pointer aside, and directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`, while paths that are allowed not to exist yet stay lexical; development host install replaces directory junctions by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 921b7da9f..bfe57aca6 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -6,6 +6,7 @@ import type { DescriptiveMetadataResult } from './descriptive-metadata.ts'; import { packageDescriptiveMetadata } from './descriptive-metadata.ts'; import type { Diagnostic } from './diagnostics.ts'; import { digest } from './digest.ts'; +import { isErrno } from './errors.ts'; import { deepFreeze } from './freeze.ts'; import { isInsideOrEqual } from './paths.ts'; import { snapshotStrictJsonValue } from './strict-json.ts'; @@ -265,7 +266,19 @@ const resolvedProjectPath = (root: string, value: string, label: string): string // (Windows `C:\…`, 8.3 aliases) are judged by realpath identity so a // short-name root and a long-name config file still name one project. if (!isAbsolute(value)) projectRelativePath(lexicalRoot, value, label); - const referencedPath = realpathSync(isAbsolute(value) ? value : resolve(lexicalRoot, value)); + const lexicalPath = isAbsolute(value) ? resolve(value) : resolve(lexicalRoot, value); + // Existing files must realpath so Windows 8.3 aliases, junctions, and + // symlink hops collapse to one identity. Missing files stay lexical: + // prebuilt hook `source` / payload directories may not exist yet (bytes + // join identity via enumerated payload files), and callers still need + // containment without `ENOENT`. + let referencedPath: string; + try { + referencedPath = realpathSync(lexicalPath); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + referencedPath = lexicalPath; + } if (escapesRoot(canonicalRoot, referencedPath)) { throw new RangeError(`${label} ${JSON.stringify(referencedPath)} is outside project root ${JSON.stringify(canonicalRoot)}.`); } @@ -498,7 +511,13 @@ export const canonicalizeNormalizedModel = ( hooks: detached.hooks.map((hook) => ({ ...hook, provenance: canonicalProvenance(root, hook.provenance), - source: canonicalCompilerPath(root, hook.source, 'Hook source path'), + // Prebuilt hook `source` may not exist yet; identity is the enumerated + // payload files, matching `modelPathReferences`. Relative sources stay + // authored. Absolute sources still canonicalize (lexical on ENOENT). + source: + hook.prebuiltPath === undefined || isAbsolute(hook.source) + ? canonicalCompilerPath(root, hook.source, 'Hook source path') + : hook.source, })), ...(detached.mcpApps === undefined ? {} diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index b99353603..813fdefe9 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -402,6 +402,8 @@ export const recordInstalledState = async ( } catch (error) { if ( !isErrno(error, 'EACCES') && + // Windows `mkdir` of a file path reports EEXIST, not ENOTDIR. + !isErrno(error, 'EEXIST') && !isErrno(error, 'ENOTDIR') && !isErrno(error, 'EPERM') && !isErrno(error, 'EROFS') diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 219c6d4c4..f6107a5b4 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -27,6 +27,7 @@ import { treeInventory, } from '../src/install/receipt.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { toPosixPath } from '../src/core/paths.ts'; import { runCli } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { writeInstallFixtureManifest } from './support/install-fixture.ts'; @@ -95,9 +96,11 @@ const isInventoryCall = (call: CommandCall): boolean => const listFiles = async (root: string): Promise => (await readdir(root, { recursive: true, withFileTypes: true })) .filter((entry) => entry.isFile()) - .map((entry) => join(entry.parentPath, entry.name).slice(root.length + 1)) + .map((entry) => toPosixPath(join(entry.parentPath, entry.name).slice(root.length + 1))) .sort((left, right) => left.localeCompare(right)); +const posixPermissionIt = process.platform === 'win32' ? it.skip : it; + const writeJson = async (path: string, value: unknown): Promise => { await mkdir(dirname(path), { recursive: true }); await writeFile(path, `${JSON.stringify(value)}\n`); @@ -344,7 +347,9 @@ it.each([ const receiptStore = join(hostRoot, 'agent-bundle', 'receipts'); await rm(join(hostRoot, 'agent-bundle'), { force: true, recursive: true }); await mkdir(receiptStore, { recursive: true }); - if (process.getuid?.() === 0) return; // root ignores directory modes; the receipt write cannot be made to fail here. + // Windows has no directory modes; root ignores them. The rest of this + // test already proved the host-CLI path; skip the unwritable-receipt tail. + if (process.platform === 'win32' || process.getuid?.() === 0) return; await chmod(receiptStore, 0o555); const unwritable: CommandCall[] = []; const receiptFailed = await installBundle({ @@ -915,7 +920,7 @@ it('reports manifest-indexed byte drift as AB7001 with the path', async () => { } }); -it('reports manifest-indexed mode drift as AB7001', async () => { +posixPermissionIt('reports manifest-indexed mode drift as AB7001', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); @@ -938,8 +943,7 @@ it('reports manifest-indexed mode drift as AB7001', async () => { } }); -it('accepts npm normalization while preserving executable-bit tamper checks', async () => { - if (process.platform === 'win32') return; +posixPermissionIt('accepts npm normalization while preserving executable-bit tamper checks', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-npm-modes-')); const packageRoot = join(root, 'package'); const artifactRoot = join(packageRoot, 'artifact'); @@ -1600,8 +1604,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => } }); -it('tree inventory refuses paths that could not round-trip through a receipt', async () => { - if (process.platform === 'win32') return; +posixPermissionIt('tree inventory refuses paths that could not round-trip through a receipt', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); @@ -1681,8 +1684,7 @@ it('never lets a receipt claim runtime state: a receipt owning state/ reads as l } }); -it('refuses a receipt that is not a regular file before reading it', async () => { - if (process.platform === 'win32') return; +posixPermissionIt('refuses a receipt that is not a regular file before reading it', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); @@ -2138,7 +2140,7 @@ it('refuses marketplace mode for a bundle that contains nested Git metadata', as }).catch((failure: unknown) => failure); expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7003', target: 'cursor' }]); - expect((error as DiagnosticError).diagnostics[0]?.message).toContain(join('vendor', 'tool', '.git')); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain('vendor/tool/.git'); expect(calls).toEqual([]); await expect(access(join(home, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); } finally { diff --git a/packages/agent-bundle/tests/npm-cli-resolution.test.ts b/packages/agent-bundle/tests/npm-cli-resolution.test.ts new file mode 100644 index 000000000..c1aa84103 --- /dev/null +++ b/packages/agent-bundle/tests/npm-cli-resolution.test.ts @@ -0,0 +1,83 @@ +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { resolveNpmCliJs, type NpmCliResolutionIo } from './support/npm-cli.ts'; + +const io = (options: { + readonly delimiter?: string; + readonly env?: Readonly>; + readonly execPath: string; + readonly files: Readonly>; +}): NpmCliResolutionIo => ({ + delimiter: options.delimiter ?? ':', + env: options.env ?? {}, + execPath: options.execPath, + exists: (candidate) => candidate in options.files, + realpath: (candidate) => { + const target = options.files[candidate]; + if (target === undefined) throw new Error(`ENOENT: ${candidate}`); + return target === true ? candidate : target; + }, +}); + +it('resolves the official Windows layout beside node.exe', () => { + const nodeDir = join('C:', 'Program Files', 'nodejs'); + const execPath = join(nodeDir, 'node.exe'); + const cli = join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'); + expect(resolveNpmCliJs(io({ + execPath, + files: { + [cli]: true, + [join(nodeDir, 'npm.cmd')]: true, + }, + }))).toBe(cli); +}); + +it('resolves the nvm Unix layout from bin/node and the bin/npm symlink', () => { + const prefix = join('/home', 'u', '.nvm', 'versions', 'node', 'v22.19.0'); + const execPath = join(prefix, 'bin', 'node'); + const cli = join(prefix, 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); + const shim = join(prefix, 'bin', 'npm'); + expect(resolveNpmCliJs(io({ + execPath, + files: { + [cli]: true, + [shim]: cli, + }, + }))).toBe(cli); +}); + +it('finds npm-cli.js on PATH when process.execPath has no npm tree', () => { + const orphan = join('/pnpm', 'nodejs', 'bin', 'node'); + const pathBin = join('/usr', 'local', 'bin'); + const cli = join('/usr', 'local', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); + expect(resolveNpmCliJs(io({ + env: { PATH: pathBin }, + execPath: orphan, + files: { + [cli]: true, + [join(pathBin, 'npm')]: cli, + }, + }))).toBe(cli); +}); + +it('ignores npm_execpath when it is a Windows cmd shim, not npm-cli.js', () => { + const nodeDir = join('C:', 'Program Files', 'nodejs'); + const execPath = join(nodeDir, 'node.exe'); + const cli = join(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'); + const cmd = join(nodeDir, 'npm.cmd'); + expect(resolveNpmCliJs(io({ + env: { npm_execpath: cmd }, + execPath, + files: { [cli]: true, [cmd]: true }, + }))).toBe(cli); +}); + +it('does not assume npm is a resolvable package from createRequire', () => { + expect(() => resolveNpmCliJs(io({ + env: { PATH: '' }, + execPath: join('/pnpm', 'nodejs', 'bin', 'node'), + files: {}, + }))).toThrow(/Unable to resolve npm-cli\.js from /u); +}); diff --git a/packages/agent-bundle/tests/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts index ae96d61c1..08194f7b5 100644 --- a/packages/agent-bundle/tests/packed-install-bin.test.ts +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -1,9 +1,7 @@ import { execFile as executeFile } from 'node:child_process'; -import { existsSync, realpathSync } from 'node:fs'; import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; @@ -12,6 +10,7 @@ import { packageBinEntries } from '../src/core/package-dependencies.ts'; import { removeProjectSource } from '../src/test/packed.ts'; import { runBin } from './support/bin-process.ts'; import { within } from './support/eventually.ts'; +import { resolveProcessNpmCliJs } from './support/npm-cli.ts'; import { packedNativeNodeCommand } from './support/packed-native-smoke.ts'; import { cachedNpmInstallArguments, @@ -40,27 +39,7 @@ interface Run { } /** npm's JavaScript CLI, never the extensionless `.bin` shim or `npm.cmd`. */ -const resolveNpmCli = (): string => { - const sibling = join(dirname(process.execPath), 'npm'); - let siblingTarget: string | undefined; - try { - siblingTarget = existsSync(sibling) ? realpathSync(sibling) : undefined; - } catch { - siblingTarget = undefined; - } - const candidates = [ - process.env['npm_execpath'], - join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'), - join(dirname(process.execPath), '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'), - siblingTarget, - ]; - for (const candidate of candidates) { - if (candidate !== undefined && candidate.endsWith('npm-cli.js') && existsSync(candidate)) { - return candidate; - } - } - return createRequire(import.meta.url).resolve('npm/bin/npm-cli.js'); -}; +const resolveNpmCli = (): string => resolveProcessNpmCliJs(); const runNodeEntrypoint = async ( entrypoint: string, diff --git a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts index 286ffcbdd..004d3351c 100644 --- a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts +++ b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts @@ -60,9 +60,12 @@ it('stamps every worker root with the owner marker the local-CI runner cleans up const owner = rstestWorkerRootOwner(root); expect(owner).toMatchObject({ cwd: process.cwd(), - pid: process.pid, workerId: process.env['RSTEST_WORKER_ID'] ?? '0', + // Windows keys the worker root by worker id only, so the first process + // that created the shared directory owns the marker. + ...(process.platform === 'win32' ? {} : { pid: process.pid }), }); + expect(typeof owner?.pid).toBe('number'); // Absolute in the platform's own shape (`/tmp`, `C:\Temp`, a UNC root). expect(isAbsolute(owner?.temporaryRoot ?? '')).toBe(true); expect(owner?.temporaryRoot).not.toBe(root); diff --git a/packages/agent-bundle/tests/support/npm-cli.ts b/packages/agent-bundle/tests/support/npm-cli.ts new file mode 100644 index 000000000..9be8c1271 --- /dev/null +++ b/packages/agent-bundle/tests/support/npm-cli.ts @@ -0,0 +1,86 @@ +import { existsSync, realpathSync } from 'node:fs'; +import { delimiter as defaultDelimiter, dirname, join } from 'node:path'; + +const npmCliJs = 'npm-cli.js'; + +/** Filesystem and environment the resolver consults. Tests inject a fake. */ +export interface NpmCliResolutionIo { + readonly delimiter?: string; + readonly env: Readonly>; + readonly execPath: string; + readonly exists: (candidate: string) => boolean; + readonly realpath: (candidate: string) => string; +} + +const isNpmCliJs = (candidate: string): boolean => candidate.endsWith(npmCliJs); + +/** Official Node layouts relative to a `bin/` or install-prefix directory. */ +const officialNpmCliCandidates = (directory: string): readonly string[] => [ + join(directory, 'node_modules', 'npm', 'bin', npmCliJs), + join(directory, '..', 'lib', 'node_modules', 'npm', 'bin', npmCliJs), + join(directory, '..', 'node_modules', 'npm', 'bin', npmCliJs), +]; + +const realpathIfNpmCli = (io: NpmCliResolutionIo, candidate: string): string | undefined => { + if (!io.exists(candidate)) return undefined; + try { + const real = io.realpath(candidate); + return isNpmCliJs(real) ? real : undefined; + } catch { + return undefined; + } +}; + +const candidatesFromDirectory = (io: NpmCliResolutionIo, directory: string): readonly string[] => { + const found: string[] = [...officialNpmCliCandidates(directory)]; + // Unix nvm: `bin/npm` → `../lib/node_modules/npm/bin/npm-cli.js`. + // Windows: `npm.cmd` / `npm.ps1` do not realpath to npm-cli.js; the + // official layouts above still find the JS entry beside the shim. + for (const shim of ['npm', 'npm.cmd', 'npm.ps1', 'npm.exe'] as const) { + const resolved = realpathIfNpmCli(io, join(directory, shim)); + if (resolved !== undefined) found.push(resolved); + } + return found; +}; + +/** + * Locates a real on-disk `npm-cli.js`. Never assumes `npm` is a resolvable + * package from `createRequire(import.meta.url)` — CI's pnpm-managed Node + * has no such dependency from the test module. + */ +export const resolveNpmCliJs = (io: NpmCliResolutionIo): string => { + const execDir = dirname(io.execPath); + const delimiter = io.delimiter ?? defaultDelimiter; + const pathEnv = io.env['PATH'] ?? io.env['Path'] ?? io.env['path'] ?? ''; + const prefix = io.env['npm_config_prefix'] ?? io.env['NPM_CONFIG_PREFIX']; + const candidates: (string | undefined)[] = []; + + const execpath = io.env['npm_execpath']; + if (execpath !== undefined && isNpmCliJs(execpath)) candidates.push(execpath); + + candidates.push(...candidatesFromDirectory(io, execDir)); + if (prefix !== undefined && prefix.length > 0) { + candidates.push(...officialNpmCliCandidates(prefix)); + candidates.push(...candidatesFromDirectory(io, join(prefix, 'bin'))); + } + for (const entry of pathEnv.split(delimiter)) { + if (entry.length === 0) continue; + candidates.push(...candidatesFromDirectory(io, entry)); + } + + for (const candidate of candidates) { + if (candidate !== undefined && isNpmCliJs(candidate) && io.exists(candidate)) { + return candidate; + } + } + throw new Error(`Unable to resolve npm-cli.js from ${io.execPath}`); +}; + +/** Resolves `npm-cli.js` from the running Node and process environment. */ +export const resolveProcessNpmCliJs = (): string => + resolveNpmCliJs({ + env: process.env, + execPath: process.execPath, + exists: existsSync, + realpath: realpathSync, + }); diff --git a/rstest.host-filesystem.config.ts b/rstest.host-filesystem.config.ts index 6c42b3946..d0854955a 100644 --- a/rstest.host-filesystem.config.ts +++ b/rstest.host-filesystem.config.ts @@ -26,6 +26,7 @@ export const hostFilesystemTestFiles = [ 'packages/agent-bundle/tests/install.test.ts', 'packages/agent-bundle/tests/uninstall.test.ts', 'packages/agent-bundle/tests/durable-fs.test.ts', + 'packages/agent-bundle/tests/npm-cli-resolution.test.ts', 'packages/agent-bundle/tests/packed-install-bin.test.ts', 'packages/agent-bundle/tests/rstest-worker-isolation.test.ts', ] as const; From a8163c12c5bb670876764a8fa95fc8ae7fcd6fbb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 03:53:04 +0000 Subject: [PATCH 06/18] fix(test): resolve host-install fixture deps on Windows without pnpm junctions Windows cannot follow pnpm's relative store links through a node_modules junction, so the host-install fixture build failed to resolve @modelcontextprotocol/server. Build that fixture under the package on Windows so walk-up finds the real tree. Co-authored-by: Zack Jackson --- .gitignore | 3 +++ .../agent-bundle/tests/support/host-install.ts | 17 +++++++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 2601b55c7..c631f9e73 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ packages/*/NOTICE # Aborted runtime-playground fixture workspaces .runtime-playground-*/ +# Windows host-install fixture builds (walk-up to package node_modules) +packages/agent-bundle/.tmp-host-install/ + website/doc_build/ website/docs/en/api/**/*.md website/docs/zh/api/**/*.md diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 9d4aa173f..50cab330a 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -611,16 +611,21 @@ const buildFixtureProject = async (options: { readonly fixture: string; readonly prepareProject?: (projectRoot: string) => Promise; }): Promise => { - const root = await mkdtemp(join(tmpdir(), `agent-bundle-${options.fixture}-build-`)); + // Windows junctions of a pnpm `node_modules` tree do not follow the nested + // relative store links, so Rspack cannot see `@modelcontextprotocol/server`. + // Build under the package so walk-up resolution finds the real tree. + const scratchParent = process.platform === 'win32' + ? join(packageRoot, '.tmp-host-install') + : tmpdir(); + if (process.platform === 'win32') await mkdir(scratchParent, { recursive: true }); + const root = await mkdtemp(join(scratchParent, `agent-bundle-${options.fixture}-build-`)); const project = join(root, 'project'); const artifactRoot = join(project, 'artifact'); try { await cp(join(fixturesRoot, options.fixture), project, { recursive: true }); - await symlink( - join(packageRoot, 'node_modules'), - join(project, 'node_modules'), - process.platform === 'win32' ? 'junction' : 'dir', - ); + if (process.platform !== 'win32') { + await symlink(join(packageRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); + } await options.prepareProject?.(project); const result = await run(process.execPath, [ cli, From e443ca9e25898054b33b1b44c5cff10487b7b1fa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:09:27 +0000 Subject: [PATCH 07/18] fix: prepack npm-cli.js, nearest-ancestor containment, Windows junctions Launch prepack's npm pack dry-run through process.execPath and a discovered npm-cli.js so hosted Windows (pnpm Node, no spawnable npm) no longer fails AB5000. Missing project paths resolve the nearest existing ancestor so escaping symlink leaves fail closed without throwing ENOENT. Windows directory publish uses absolute junction targets; worker roots hash cwd+pid on every OS; receipt-write rollback no longer depends on chmod. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- packages/agent-bundle/src/api.ts | 12 +-- packages/agent-bundle/src/core/npm-cli.ts | 87 ++++++++++++++++++ .../agent-bundle/src/core/project-context.ts | 46 +++++++--- .../src/dev/host-install-manager.ts | 6 +- .../agent-bundle/tests/dev-services.test.ts | 14 +++ packages/agent-bundle/tests/install.test.ts | 27 +++--- .../tests/npm-cli-resolution.test.ts | 16 ++++ .../tests/prebuilt-payload.test.ts | 21 +++++ .../tests/rstest-worker-isolation.test.ts | 15 ++- .../agent-bundle/tests/support/npm-cli.ts | 91 +------------------ rstest.global-setup.ts | 14 ++- rstest.worker-isolation.ts | 15 ++- scripts/rstest-worker-roots.mjs | 4 +- .../en/guide/authoring/package-entries.mdx | 3 +- .../docs/en/guide/distribution/validation.mdx | 6 +- .../zh/guide/authoring/package-entries.mdx | 3 +- .../docs/zh/guide/distribution/validation.mdx | 7 +- 18 files changed, 246 insertions(+), 143 deletions(-) create mode 100644 packages/agent-bundle/src/core/npm-cli.ts diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index b3a2d79c4..249afc15a 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`, while paths that are allowed not to exist yet stay lexical; development host install replaces directory junctions by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index ccb92cbe9..d253cd063 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -27,6 +27,7 @@ import { type AgentComponentKind, } from './core/components.ts'; import { errorMessage } from './core/errors.ts'; +import { resolveProcessNpmCliJs } from './core/npm-cli.ts'; import { isInsideOrEqual } from './core/paths.ts'; import { stateDefinitionProjection, @@ -1375,9 +1376,10 @@ export const prepack = async (options: BuildOptions): Promise => severity: 'error', }]); } - const { stdout } = await execFile('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: result.packageBuild.outputRoot, - }); + const npmCli = resolveProcessNpmCliJs(); + const npmPackDryRun = (cwd: string) => + execFile(process.execPath, [npmCli, 'pack', '--dry-run', '--json', '--ignore-scripts'], { cwd }); + const { stdout } = await npmPackDryRun(result.packageBuild.outputRoot); const pack = packOutputFromJson(stdout); const diagnostics = [...await packInventoryDiagnostics({ model: result.model, @@ -1387,9 +1389,7 @@ export const prepack = async (options: BuildOptions): Promise => projectRoot: options.root, })]; if (resolve(options.root) !== resolve(result.packageBuild.outputRoot)) { - const published = await execFile('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: options.root, - }); + const published = await npmPackDryRun(options.root); diagnostics.push(...await packageBinDiagnostics( options.root, packOutputFromJson(published.stdout), diff --git a/packages/agent-bundle/src/core/npm-cli.ts b/packages/agent-bundle/src/core/npm-cli.ts new file mode 100644 index 000000000..68dd60abe --- /dev/null +++ b/packages/agent-bundle/src/core/npm-cli.ts @@ -0,0 +1,87 @@ +import { existsSync, realpathSync } from 'node:fs'; +import { delimiter as defaultDelimiter, dirname, join } from 'node:path'; + +const npmCliJs = 'npm-cli.js'; + +/** Filesystem and environment the resolver consults. Tests inject a fake. */ +export interface NpmCliResolutionIo { + readonly delimiter?: string; + readonly env: Readonly>; + readonly execPath: string; + readonly exists: (candidate: string) => boolean; + readonly realpath: (candidate: string) => string; +} + +const isNpmCliJs = (candidate: string): boolean => candidate.endsWith(npmCliJs); + +/** Official Node layouts relative to a `bin/` or install-prefix directory. */ +const officialNpmCliCandidates = (directory: string): readonly string[] => [ + join(directory, 'node_modules', 'npm', 'bin', npmCliJs), + join(directory, '..', 'lib', 'node_modules', 'npm', 'bin', npmCliJs), + join(directory, '..', 'node_modules', 'npm', 'bin', npmCliJs), +]; + +const realpathIfNpmCli = (io: NpmCliResolutionIo, candidate: string): string | undefined => { + if (!io.exists(candidate)) return undefined; + try { + const real = io.realpath(candidate); + return isNpmCliJs(real) ? real : undefined; + } catch { + return undefined; + } +}; + +const candidatesFromDirectory = (io: NpmCliResolutionIo, directory: string): readonly string[] => { + const found: string[] = [...officialNpmCliCandidates(directory)]; + // Unix nvm: `bin/npm` → `../lib/node_modules/npm/bin/npm-cli.js`. + // Windows: `npm.cmd` / `npm.ps1` do not realpath to npm-cli.js; the + // official layouts above still find the JS entry beside the shim. + for (const shim of ['npm', 'npm.cmd', 'npm.ps1', 'npm.exe'] as const) { + const resolved = realpathIfNpmCli(io, join(directory, shim)); + if (resolved !== undefined) found.push(resolved); + } + return found; +}; + +/** + * Locates a real on-disk `npm-cli.js`. Never assumes `npm` is a resolvable + * package from `createRequire(import.meta.url)` — a pnpm-managed Node has no + * such dependency from this module — and never treats `npm_execpath` as npm + * when it points at pnpm or a cmd shim. + */ +export const resolveNpmCliJs = (io: NpmCliResolutionIo): string => { + const execDir = dirname(io.execPath); + const delimiter = io.delimiter ?? defaultDelimiter; + const pathEnv = io.env['PATH'] ?? io.env['Path'] ?? io.env['path'] ?? ''; + const prefix = io.env['npm_config_prefix'] ?? io.env['NPM_CONFIG_PREFIX']; + const candidates: (string | undefined)[] = []; + + const execpath = io.env['npm_execpath']; + if (execpath !== undefined && isNpmCliJs(execpath)) candidates.push(execpath); + + candidates.push(...candidatesFromDirectory(io, execDir)); + if (prefix !== undefined && prefix.length > 0) { + candidates.push(...officialNpmCliCandidates(prefix)); + candidates.push(...candidatesFromDirectory(io, join(prefix, 'bin'))); + } + for (const entry of pathEnv.split(delimiter)) { + if (entry.length === 0) continue; + candidates.push(...candidatesFromDirectory(io, entry)); + } + + for (const candidate of candidates) { + if (candidate !== undefined && isNpmCliJs(candidate) && io.exists(candidate)) { + return candidate; + } + } + throw new Error(`Unable to resolve npm-cli.js from ${io.execPath}`); +}; + +/** Resolves `npm-cli.js` from the running Node and process environment. */ +export const resolveProcessNpmCliJs = (): string => + resolveNpmCliJs({ + env: process.env, + execPath: process.execPath, + exists: existsSync, + realpath: realpathSync, + }); diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index bfe57aca6..3105e33bb 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -1,5 +1,5 @@ import { readFileSync, realpathSync } from 'node:fs'; -import { isAbsolute, join, relative, resolve } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { SkillHostDocument, SkillIr, SkillSidecarRef } from '../skills/ir.ts'; import type { DescriptiveMetadataResult } from './descriptive-metadata.ts'; @@ -259,6 +259,34 @@ const projectRelativePath = (root: string, value: string, label: string): string return projectRelative; }; +/** + * Existing paths collapse to on-disk identity (8.3, junctions, symlink hops). + * Missing paths resolve the nearest existing ancestor and append the missing + * suffix so a dangling leaf under an escaping symlink is judged against the + * canonical target, not the lexical spelling. A path with no existing + * ancestor stays lexical. + */ +const onDiskOrNearestAncestorPath = (lexicalPath: string): string => { + try { + return realpathSync(lexicalPath); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + const missing: string[] = []; + let cursor = lexicalPath; + for (;;) { + const parent = dirname(cursor); + if (parent === cursor) return lexicalPath; + missing.unshift(basename(cursor)); + try { + return join(realpathSync(parent), ...missing); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + cursor = parent; + } + } +}; + const resolvedProjectPath = (root: string, value: string, label: string): string => { const canonicalRoot = realpathSync(resolve(root)); const lexicalRoot = resolve(root); @@ -267,18 +295,7 @@ const resolvedProjectPath = (root: string, value: string, label: string): string // short-name root and a long-name config file still name one project. if (!isAbsolute(value)) projectRelativePath(lexicalRoot, value, label); const lexicalPath = isAbsolute(value) ? resolve(value) : resolve(lexicalRoot, value); - // Existing files must realpath so Windows 8.3 aliases, junctions, and - // symlink hops collapse to one identity. Missing files stay lexical: - // prebuilt hook `source` / payload directories may not exist yet (bytes - // join identity via enumerated payload files), and callers still need - // containment without `ENOENT`. - let referencedPath: string; - try { - referencedPath = realpathSync(lexicalPath); - } catch (error) { - if (!isErrno(error, 'ENOENT')) throw error; - referencedPath = lexicalPath; - } + const referencedPath = onDiskOrNearestAncestorPath(lexicalPath); if (escapesRoot(canonicalRoot, referencedPath)) { throw new RangeError(`${label} ${JSON.stringify(referencedPath)} is outside project root ${JSON.stringify(canonicalRoot)}.`); } @@ -513,7 +530,8 @@ export const canonicalizeNormalizedModel = ( provenance: canonicalProvenance(root, hook.provenance), // Prebuilt hook `source` may not exist yet; identity is the enumerated // payload files, matching `modelPathReferences`. Relative sources stay - // authored. Absolute sources still canonicalize (lexical on ENOENT). + // authored. Absolute sources still canonicalize (nearest existing + // ancestor on ENOENT). source: hook.prebuiltPath === undefined || isAbsolute(hook.source) ? canonicalCompilerPath(root, hook.source, 'Hook source path') diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index b9d90ab1b..8e5bb2b83 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -327,7 +327,11 @@ const publishDirectoryPointer = async ( epochId: string, ): Promise => { const path = join(destination, entryName); - const target = relative(destination, join(generationRoot(destination, epochId), entryName)); + const absoluteTarget = join(generationRoot(destination, epochId), entryName); + // Unix dir symlinks stay relative so a relocated install still points at + // its generation. Windows junctions are absolutized from `cwd` (not the + // link location), so a relative target would resolve to the wrong tree. + const target = process.platform === 'win32' ? absoluteTarget : relative(destination, absoluteTarget); const temporary = join(destination, `.${basename(entryName)}.dev-link-${process.pid}-${crypto.randomUUID()}`); const movedAside = join(destination, `.${basename(entryName)}.dev-previous-${process.pid}-${crypto.randomUUID()}`); await symlink(target, temporary, process.platform === 'win32' ? 'junction' : 'dir'); diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index b446efd89..c00972a1a 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -525,6 +525,19 @@ it('creates an exact deeply frozen root-independent project context', async () = root: leftRoot, sourceInputs: left.projectContext?.sourceInputs ?? [], })).toThrow(/outside project root/i); + const externalDir = `${leftRoot}-external-dir`; + const escapedDirLink = join(leftRoot, 'escaped-dir'); + await mkdir(externalDir); + await symlink(externalDir, escapedDirLink, 'dir'); + expect(() => createProjectContext({ + configPath: left.configPath, + model, + root: leftRoot, + sourceInputs: [ + ...(left.projectContext?.sourceInputs ?? []), + { path: 'escaped-dir/missing.ts', sha256: 'a'.repeat(64) }, + ], + })).toThrow(/outside project root/i); const extensionValue = { nested: { enabled: true } }; const frontmatter = { ...model.skills[0]!.frontmatter, custom: { enabled: true } }; @@ -582,6 +595,7 @@ it('creates an exact deeply frozen root-independent project context', async () = rm(leftRoot, { force: true, recursive: true }), rm(rightRoot, { force: true, recursive: true }), rm(`${leftRoot}-external-source.ts`, { force: true }), + rm(`${leftRoot}-external-dir`, { force: true, recursive: true }), ]); } }); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index f6107a5b4..0c822ae29 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -344,13 +344,12 @@ it.each([ // The host install succeeded but the receipt could not be written: the plugin registration is reversed too // (plugin first, then the marketplace this run created), so nothing stays registered without a receipt. + // A regular file at the receipt-store path is portable (Windows has no + // directory modes; chmod 0555 is a no-op there and as root). const receiptStore = join(hostRoot, 'agent-bundle', 'receipts'); await rm(join(hostRoot, 'agent-bundle'), { force: true, recursive: true }); - await mkdir(receiptStore, { recursive: true }); - // Windows has no directory modes; root ignores them. The rest of this - // test already proved the host-CLI path; skip the unwritable-receipt tail. - if (process.platform === 'win32' || process.getuid?.() === 0) return; - await chmod(receiptStore, 0o555); + await mkdir(join(hostRoot, 'agent-bundle'), { recursive: true }); + await writeFile(receiptStore, 'not-a-directory\n'); const unwritable: CommandCall[] = []; const receiptFailed = await installBundle({ ...isolated(fixture), @@ -371,7 +370,6 @@ it.each([ : 'plugin remove install-fixture@install-fixture-marketplace', 'plugin marketplace remove install-fixture-marketplace', ]); - await chmod(receiptStore, 0o755); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } @@ -1367,13 +1365,16 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await rm(join(fixture.bundleRoot, 'state'), { recursive: true }); // Flipping only the executable bit is a content change: the installed copy must receive it. - await chmod(join(fixture.bundleRoot, 'payload.txt'), 0o755); - await refreshCursorBundle(fixture); - const executable = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); - expect(executable).toMatchObject({ state: 'replaced' }); - expect((await stat(join(destination, 'payload.txt'))).mode & 0o111).not.toBe(0); - expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })) - .toMatchObject({ state: 'already-installed' }); + // Windows stores no Unix execute bits; chmod 0755 is a no-op there. + if (process.platform !== 'win32') { + await chmod(join(fixture.bundleRoot, 'payload.txt'), 0o755); + await refreshCursorBundle(fixture); + const executable = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); + expect(executable).toMatchObject({ state: 'replaced' }); + expect((await stat(join(destination, 'payload.txt'))).mode & 0o111).not.toBe(0); + expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })) + .toMatchObject({ state: 'already-installed' }); + } // An operator hard link to an owned file under an unrelated name is not ours: incoming path → collision. await link(join(destination, 'payload.txt'), join(destination, 'hard-linked.txt')); diff --git a/packages/agent-bundle/tests/npm-cli-resolution.test.ts b/packages/agent-bundle/tests/npm-cli-resolution.test.ts index c1aa84103..cc55403c0 100644 --- a/packages/agent-bundle/tests/npm-cli-resolution.test.ts +++ b/packages/agent-bundle/tests/npm-cli-resolution.test.ts @@ -81,3 +81,19 @@ it('does not assume npm is a resolvable package from createRequire', () => { files: {}, }))).toThrow(/Unable to resolve npm-cli\.js from /u); }); + +it('finds npm-cli.js when Node and npm live under different prefixes and npm_execpath is pnpm', () => { + const nodeDir = join('/pnpm', 'nodejs', 'bin'); + const pathBin = join('/usr', 'local', 'bin'); + const cli = join('/usr', 'local', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'); + const pnpmCli = join('/pnpm', 'home', 'store', 'pnpm.cjs'); + expect(resolveNpmCliJs(io({ + env: { PATH: pathBin, npm_execpath: pnpmCli }, + execPath: join(nodeDir, 'node'), + files: { + [cli]: true, + [join(pathBin, 'npm')]: cli, + [pnpmCli]: true, + }, + }))).toBe(cli); +}); diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index ca791e306..64787d9a5 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -384,6 +384,27 @@ it('validates an argument-less prebuilt hook without demanding a wrapper index e } }); +it('validates a clean tree whose declared prebuilt payload paths do not exist yet', async () => { + const root = await createProject({ + hooks: standardHooksBlock, + mcp: standardMcpBlock, + payload: standardPayloadBlock, + withPayloadFiles: false, + }); + try { + const result = await validate({ root }); + expect(result.diagnostics.filter((diagnostic) => diagnostic.code === 'AB7001')).toEqual([]); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + expect(result.model).toBeDefined(); + expect(result.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB4743', severity: 'warning' }), + expect.objectContaining({ code: 'AB4745', severity: 'warning' }), + ])); + } finally { + await removeProjectFixture(root); + } +}); + it('reports the prebuilt payload source diagnostics', async () => { const root = await createProject({ hooks: [ diff --git a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts index 004d3351c..7d6a7c82a 100644 --- a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts +++ b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts @@ -36,10 +36,11 @@ it('keeps Doctor socket fixtures below the Linux AF_UNIX pathname cap', () => { }); it('isolates concurrent Rstest invocations that share a host temporary root', () => { - const firstRoot = rstestWorkerRootPath('/tmp', '1', 'linux', '/workspace/first\0' + '101'); - const secondRoot = rstestWorkerRootPath('/tmp', '1', 'linux', '/workspace/second\0' + '202'); - - expect(firstRoot).not.toBe(secondRoot); + for (const platform of ['linux', 'win32'] as const) { + const firstRoot = rstestWorkerRootPath('/tmp', '1', platform, '/workspace/first\0' + '101'); + const secondRoot = rstestWorkerRootPath('/tmp', '1', platform, '/workspace/second\0' + '202'); + expect(firstRoot).not.toBe(secondRoot); + } }); it('publishes a realpath worker root so TMPDIR matches production path canonicalization', () => { @@ -50,6 +51,7 @@ it('publishes a realpath worker root so TMPDIR matches production path canonical it('stamps every worker root with the owner marker the local-CI runner cleans up by', () => { const root = rstestWorkerRoot(); const parent = process.platform === 'win32' ? undefined : realpathSync(rstestWorkerRootsParent); + expect(root.split(/[/\\]/u).some((segment) => segment.startsWith(rstestWorkerRootPrefix))).toBe(true); expect( process.platform === 'win32' || (parent !== undefined && root.startsWith(join(parent, rstestWorkerRootPrefix))), @@ -60,12 +62,9 @@ it('stamps every worker root with the owner marker the local-CI runner cleans up const owner = rstestWorkerRootOwner(root); expect(owner).toMatchObject({ cwd: process.cwd(), + pid: process.pid, workerId: process.env['RSTEST_WORKER_ID'] ?? '0', - // Windows keys the worker root by worker id only, so the first process - // that created the shared directory owns the marker. - ...(process.platform === 'win32' ? {} : { pid: process.pid }), }); - expect(typeof owner?.pid).toBe('number'); // Absolute in the platform's own shape (`/tmp`, `C:\Temp`, a UNC root). expect(isAbsolute(owner?.temporaryRoot ?? '')).toBe(true); expect(owner?.temporaryRoot).not.toBe(root); diff --git a/packages/agent-bundle/tests/support/npm-cli.ts b/packages/agent-bundle/tests/support/npm-cli.ts index 9be8c1271..f3d604e8d 100644 --- a/packages/agent-bundle/tests/support/npm-cli.ts +++ b/packages/agent-bundle/tests/support/npm-cli.ts @@ -1,86 +1,5 @@ -import { existsSync, realpathSync } from 'node:fs'; -import { delimiter as defaultDelimiter, dirname, join } from 'node:path'; - -const npmCliJs = 'npm-cli.js'; - -/** Filesystem and environment the resolver consults. Tests inject a fake. */ -export interface NpmCliResolutionIo { - readonly delimiter?: string; - readonly env: Readonly>; - readonly execPath: string; - readonly exists: (candidate: string) => boolean; - readonly realpath: (candidate: string) => string; -} - -const isNpmCliJs = (candidate: string): boolean => candidate.endsWith(npmCliJs); - -/** Official Node layouts relative to a `bin/` or install-prefix directory. */ -const officialNpmCliCandidates = (directory: string): readonly string[] => [ - join(directory, 'node_modules', 'npm', 'bin', npmCliJs), - join(directory, '..', 'lib', 'node_modules', 'npm', 'bin', npmCliJs), - join(directory, '..', 'node_modules', 'npm', 'bin', npmCliJs), -]; - -const realpathIfNpmCli = (io: NpmCliResolutionIo, candidate: string): string | undefined => { - if (!io.exists(candidate)) return undefined; - try { - const real = io.realpath(candidate); - return isNpmCliJs(real) ? real : undefined; - } catch { - return undefined; - } -}; - -const candidatesFromDirectory = (io: NpmCliResolutionIo, directory: string): readonly string[] => { - const found: string[] = [...officialNpmCliCandidates(directory)]; - // Unix nvm: `bin/npm` → `../lib/node_modules/npm/bin/npm-cli.js`. - // Windows: `npm.cmd` / `npm.ps1` do not realpath to npm-cli.js; the - // official layouts above still find the JS entry beside the shim. - for (const shim of ['npm', 'npm.cmd', 'npm.ps1', 'npm.exe'] as const) { - const resolved = realpathIfNpmCli(io, join(directory, shim)); - if (resolved !== undefined) found.push(resolved); - } - return found; -}; - -/** - * Locates a real on-disk `npm-cli.js`. Never assumes `npm` is a resolvable - * package from `createRequire(import.meta.url)` — CI's pnpm-managed Node - * has no such dependency from the test module. - */ -export const resolveNpmCliJs = (io: NpmCliResolutionIo): string => { - const execDir = dirname(io.execPath); - const delimiter = io.delimiter ?? defaultDelimiter; - const pathEnv = io.env['PATH'] ?? io.env['Path'] ?? io.env['path'] ?? ''; - const prefix = io.env['npm_config_prefix'] ?? io.env['NPM_CONFIG_PREFIX']; - const candidates: (string | undefined)[] = []; - - const execpath = io.env['npm_execpath']; - if (execpath !== undefined && isNpmCliJs(execpath)) candidates.push(execpath); - - candidates.push(...candidatesFromDirectory(io, execDir)); - if (prefix !== undefined && prefix.length > 0) { - candidates.push(...officialNpmCliCandidates(prefix)); - candidates.push(...candidatesFromDirectory(io, join(prefix, 'bin'))); - } - for (const entry of pathEnv.split(delimiter)) { - if (entry.length === 0) continue; - candidates.push(...candidatesFromDirectory(io, entry)); - } - - for (const candidate of candidates) { - if (candidate !== undefined && isNpmCliJs(candidate) && io.exists(candidate)) { - return candidate; - } - } - throw new Error(`Unable to resolve npm-cli.js from ${io.execPath}`); -}; - -/** Resolves `npm-cli.js` from the running Node and process environment. */ -export const resolveProcessNpmCliJs = (): string => - resolveNpmCliJs({ - env: process.env, - execPath: process.execPath, - exists: existsSync, - realpath: realpathSync, - }); +export { + resolveNpmCliJs, + resolveProcessNpmCliJs, + type NpmCliResolutionIo, +} from '../../src/core/npm-cli.ts'; diff --git a/rstest.global-setup.ts b/rstest.global-setup.ts index f6a8aa18f..42da2c1ec 100644 --- a/rstest.global-setup.ts +++ b/rstest.global-setup.ts @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; import { removeRunRstestWorkerRoots, rstestRunIdVariable } from './scripts/rstest-worker-roots.mjs'; @@ -8,9 +9,10 @@ import { removeRunRstestWorkerRoots, rstestRunIdVariable } from './scripts/rstes * invocation created — plus, as a one-time repair, the untagged roots this * checkout's pools left behind before the teardown existed. * - * Every pool worker derives a private temp root, `/tmp/ab-rstest-` - * (rstest.worker-isolation.ts), and stamps it with an owner marker. Nothing on - * the `pnpm test*` path used to remove those roots — only scripts/local-ci.mjs + * Every pool worker derives a private temp root, `ab-rstest-` + * (rstest.worker-isolation.ts: `/tmp` on Unix, host TEMP on Windows; the hash + * includes cwd and pid), and stamps it with an owner marker. Nothing on the + * `pnpm test*` path used to remove those roots — only scripts/local-ci.mjs * swept the ones derived from its private TMPDIR — so a developer machine * accumulated one root per worker per run, tens of thousands over time. * @@ -60,7 +62,11 @@ export const teardown = async (): Promise => { // Untagged roots this checkout left before the teardown existed go too: // nothing else reclaims them, and each one is a marker every later sweep // would read again. - const { removed, retained } = await removeRunRstestWorkerRoots({ reclaimUntaggedFrom: process.cwd(), runId }); + const { removed, retained } = await removeRunRstestWorkerRoots({ + ...(process.platform === 'win32' ? { parent: tmpdir() } : {}), + reclaimUntaggedFrom: process.cwd(), + runId, + }); const debug = process.env[debugRootsVariable]; if (debug === undefined || debug === '') return; const listed = (roots: readonly string[]): string => (roots.length === 0 ? '' : `\n ${roots.join('\n ')}`); diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts index 9451f4eef..239ad7752 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -3,7 +3,12 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from import { homedir, tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { rstestRunIdVariable, rstestWorkerRootOwnerFile } from './scripts/rstest-worker-roots.mjs'; +import { + rstestRunIdVariable, + rstestWorkerRootOwnerFile, + rstestWorkerRootPrefix, + rstestWorkerRootsParent, +} from './scripts/rstest-worker-roots.mjs'; export const rstestWorkerId = (): string => process.env['RSTEST_WORKER_ID'] ?? '0'; @@ -59,7 +64,6 @@ export const rstestWorkerRootPath = ( platform: NodeJS.Platform = process.platform, invocationId: string = process.cwd() + '\0' + String(process.pid), ): string => { - if (platform === 'win32') return join(temporaryRoot, 'agent-bundle-rstest-w' + workerId); const hash = createHash('sha256') .update(temporaryRoot, 'utf8') .update('\0', 'utf8') @@ -68,7 +72,12 @@ export const rstestWorkerRootPath = ( .update(invocationId, 'utf8') .digest('hex') .slice(0, 16); - return join('/tmp', `ab-rstest-${hash}`); + const name = rstestWorkerRootPrefix + hash; + // Linux AF_UNIX fixtures cap the pathname, so Unix roots live under `/tmp` + // rather than a long host TMPDIR. Windows has no such cap and cannot use + // `/tmp`; it still hashes cwd+pid so two processes that reuse worker id 1 + // do not share one TEMP directory. + return platform === 'win32' ? join(temporaryRoot, name) : join(rstestWorkerRootsParent, name); }; export const rstestWorkerRoot = (): string => { diff --git a/scripts/rstest-worker-roots.mjs b/scripts/rstest-worker-roots.mjs index 4e1c1decc..c3f534517 100644 --- a/scripts/rstest-worker-roots.mjs +++ b/scripts/rstest-worker-roots.mjs @@ -23,9 +23,9 @@ import { readdir, readFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; -/** Parent directory of every non-Windows worker root (see rstestWorkerRootPath). */ +/** Parent directory of Unix worker roots (see rstestWorkerRootPath). Windows uses host TEMP. */ export const rstestWorkerRootsParent = '/tmp'; -/** Directory-name prefix of every non-Windows worker root. */ +/** Directory-name prefix of every hashed worker root. */ export const rstestWorkerRootPrefix = 'ab-rstest-'; /** Owner marker written into each worker root by `rstestWorkerRoot()`. */ export const rstestWorkerRootOwnerFile = '.ab-rstest-owner.json'; diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 3fe9c8a3f..6b3ede463 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -448,7 +448,8 @@ source-project extras are not. npx agent-bundle prepack --root . --output artifact --json ``` -`prepack` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the +`prepack` runs the release build and `npm pack --dry-run --json --ignore-scripts` +(npm's `npm-cli.js` via `process.execPath`, not a bare `npm` PATH spawn), then gates the exact package and artifact inventory, manifest hashes, package bin targets, and release-version agreement. It removes pack and publish lifecycle hooks from the generated package metadata, so a source-project `prepack` cannot recurse from `dist/`; consumer install scripts remain intact. Run diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 0153f06db..a1c10a8ab 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -243,7 +243,11 @@ npx agent-bundle prepack --root . --output artifact --json ``` `prepack` runs the release build, creates the canonical npm root at `dist/`, and runs -`npm pack --dry-run --json --ignore-scripts` from that root. The generated package copies the +`npm pack --dry-run --json --ignore-scripts` from that root by launching npm's +`npm-cli.js` through the current Node executable (`process.execPath`), so the gate +works when `npm` is not a spawnable PATH command (a pnpm-managed Node, or Windows +without `npm.cmd` on PATH). It never treats `npm_execpath` as npm when that variable +points at pnpm. The generated package copies the validated composite artifact unchanged, points routed `package.json` bins at the manifest-declared `bin/.mjs`, and adds only separately authored bins and libraries. The gate checks that exact inventory, manifest hashes, package bin targets, release-version agreement, diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index fb82f6784..ca7bde43b 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -406,7 +406,8 @@ import meta, { name, packageName, packageVersion, version } from 'agent-bundle/m npx agent-bundle prepack --root . --output artifact --json ``` -`prepack` 运行发布构建与 `npm pack --dry-run --json --ignore-scripts`,随后对精确的包与产物清单、 +`prepack` 运行发布构建,并通过 `process.execPath` 启动 npm 的 `npm-cli.js` +执行 `npm pack --dry-run --json --ignore-scripts`(不是 PATH 上的裸 `npm`),随后对精确的包与产物清单、 清单哈希、包 bin 目标以及发布版本一致性设卡。生成的包元数据会移除 pack 与 publish 生命周期钩子, 避免源项目的 `prepack` 从 `dist/` 递归;消费者安装脚本保持不变。请直接运行门禁,不要依赖源包的 生命周期钩子,然后发布它已经校验的 npm 根目录: diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 70e6bfdf2..655fdf0ee 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -211,8 +211,11 @@ schema 校验它们输出的 JSON 文档,并把宿主的 schema 生成器视 npx agent-bundle prepack --root . --output artifact --json ``` -`prepack` 运行发布构建,在 `dist/` 生成规范 npm 根目录,再从该根目录运行 -`npm pack --dry-run --json --ignore-scripts`。生成的包会原样复制已校验的组合产物,让路由式 +`prepack` 运行发布构建,在 `dist/` 生成规范 npm 根目录,再通过当前 Node 可执行文件 +(`process.execPath`)启动 npm 的 `npm-cli.js`,从该根目录运行 +`npm pack --dry-run --json --ignore-scripts`,因此在 `npm` 不是 PATH 上可直接 +spawn 的命令时(pnpm 管理的 Node,或 Windows 上 PATH 没有 `npm.cmd`)门禁仍可用。 +当 `npm_execpath` 指向 pnpm 时,它不会把该变量当作 npm。生成的包会原样复制已校验的组合产物,让路由式 `package.json` bin 指向清单声明的 `bin/.mjs`,并且只额外加入单独编写的 bin 与库。 门禁会检查这份精确清单、清单哈希、包 bin 目标、发布版本一致性、包专属编译证据以及 `package.json` 中的安装期依赖字段。作者写入的 `package.json.files` 与 `.npmignore` 规则不会缩窄 From 61b9201c12bb7bbc0ba422fbdfd8a48d4f3d33e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:11:27 +0000 Subject: [PATCH 08/18] fix(test): occupy receipt path with a directory for portable write failure A file at the receipts store blocked mkdir before host CLI verbs ran, so rollback never issued uninstall/remove. The receipt file path as a directory lets the host verbs succeed and then fails the atomic rename. Co-authored-by: Zack Jackson --- packages/agent-bundle/tests/install.test.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 0c822ae29..7a7b1baad 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -344,12 +344,11 @@ it.each([ // The host install succeeded but the receipt could not be written: the plugin registration is reversed too // (plugin first, then the marketplace this run created), so nothing stays registered without a receipt. - // A regular file at the receipt-store path is portable (Windows has no - // directory modes; chmod 0555 is a no-op there and as root). - const receiptStore = join(hostRoot, 'agent-bundle', 'receipts'); + // Occupy the exact receipt path with a directory so `rename` of the temp + // receipt fails after the host verbs (Windows has no directory modes; + // chmod 0555 is a no-op there and as root). await rm(join(hostRoot, 'agent-bundle'), { force: true, recursive: true }); - await mkdir(join(hostRoot, 'agent-bundle'), { recursive: true }); - await writeFile(receiptStore, 'not-a-directory\n'); + await mkdir(receiptPath, { recursive: true }); const unwritable: CommandCall[] = []; const receiptFailed = await installBundle({ ...isolated(fixture), From 52db175af74ccd5ce2f3aa5204fa6d3be3965a4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:13:53 +0000 Subject: [PATCH 09/18] fix(test): inject receipt-write failure via spy so rollback stays portable Occupying the receipt path broke the pre-write read. Spying writeStoredInstallReceipt fails after host verbs on every OS, including Windows where chmod 0555 is a no-op. Co-authored-by: Zack Jackson --- packages/agent-bundle/tests/install.test.ts | 53 +++++++++++---------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 7a7b1baad..9672f4883 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; -import { expect, it } from '@rstest/core'; +import { expect, it, rs } from '@rstest/core'; import { Ajv } from 'ajv/dist/ajv.js'; import addFormats from 'ajv-formats'; @@ -26,6 +26,7 @@ import { readInstallReceiptFile, treeInventory, } from '../src/install/receipt.ts'; +import * as installReceipt from '../src/install/receipt.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { toPosixPath } from '../src/core/paths.ts'; import { runCli } from '../src/cli.ts'; @@ -344,31 +345,35 @@ it.each([ // The host install succeeded but the receipt could not be written: the plugin registration is reversed too // (plugin first, then the marketplace this run created), so nothing stays registered without a receipt. - // Occupy the exact receipt path with a directory so `rename` of the temp - // receipt fails after the host verbs (Windows has no directory modes; - // chmod 0555 is a no-op there and as root). + // Inject the write failure after host verbs: chmod on the store is a no-op + // on Windows and as root, and occupying the path breaks the pre-write read. await rm(join(hostRoot, 'agent-bundle'), { force: true, recursive: true }); - await mkdir(receiptPath, { recursive: true }); + const writeReceipt = rs.spyOn(installReceipt, 'writeStoredInstallReceipt') + .mockRejectedValueOnce(new Error('receipt write failed')); const unwritable: CommandCall[] = []; - const receiptFailed = await installBundle({ - ...isolated(fixture), - commandRunner: { run: async (command, args, runOptions) => { - const call = { args: [...args], command, cwd: runOptions.cwd }; - unwritable.push(call); - return { code: 0, stderr: '', stdout: isMarketplaceListCall(call) ? noMarketplaces(call) : '' }; - } }, - from: fixture.from, - host, - scope, - }).catch((failure: unknown) => failure); - expect(receiptFailed).toBeInstanceOf(Error); - expect(receiptFailed).not.toBeInstanceOf(DiagnosticError); - expect(unwritable.map((call) => call.args.join(' ')).slice(-2)).toEqual([ - host === 'claude' - ? `plugin uninstall install-fixture@install-fixture-marketplace --scope ${scope} --keep-data` - : 'plugin remove install-fixture@install-fixture-marketplace', - 'plugin marketplace remove install-fixture-marketplace', - ]); + try { + const receiptFailed = await installBundle({ + ...isolated(fixture), + commandRunner: { run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + unwritable.push(call); + return { code: 0, stderr: '', stdout: isMarketplaceListCall(call) ? noMarketplaces(call) : '' }; + } }, + from: fixture.from, + host, + scope, + }).catch((failure: unknown) => failure); + expect(receiptFailed).toBeInstanceOf(Error); + expect(receiptFailed).not.toBeInstanceOf(DiagnosticError); + expect(unwritable.map((call) => call.args.join(' ')).slice(-2)).toEqual([ + host === 'claude' + ? `plugin uninstall install-fixture@install-fixture-marketplace --scope ${scope} --keep-data` + : 'plugin remove install-fixture@install-fixture-marketplace', + 'plugin marketplace remove install-fixture-marketplace', + ]); + } finally { + writeReceipt.mockRestore(); + } } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } From 1b4cace63e6b49c7a6636dc71b9e1556214bd6a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:26:04 +0000 Subject: [PATCH 10/18] fix(windows): realpath install identity and host-install junctions Compare packed-install-bin bundleRoot to the realpath npm root (8.3 vs long). Publish Windows generation junctions at realpath targets, attach the Cursor destination the manager actually installed, and junction realpath compile deps into the Windows host-install fixture so a coordinator rebuild can resolve them. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- .../src/dev/host-install-manager.ts | 5 ++++- .../tests/dev-host-install.test.ts | 18 +++++++++++++----- .../tests/packed-install-bin.test.ts | 5 +++-- .../agent-bundle/tests/support/host-install.ts | 14 +++++++++++++- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index 249afc15a..22dbf5467 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index 8e5bb2b83..7ef4c303d 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -5,6 +5,7 @@ import { mkdtemp, readFile, readdir, + realpath, rename, rm, symlink, @@ -331,7 +332,9 @@ const publishDirectoryPointer = async ( // Unix dir symlinks stay relative so a relocated install still points at // its generation. Windows junctions are absolutized from `cwd` (not the // link location), so a relative target would resolve to the wrong tree. - const target = process.platform === 'win32' ? absoluteTarget : relative(destination, absoluteTarget); + const target = process.platform === 'win32' + ? await realpath(absoluteTarget) + : relative(destination, absoluteTarget); const temporary = join(destination, `.${basename(entryName)}.dev-link-${process.pid}-${crypto.randomUUID()}`); const movedAside = join(destination, `.${basename(entryName)}.dev-previous-${process.pid}-${crypto.randomUUID()}`); await symlink(target, temporary, process.platform === 'win32' ? 'junction' : 'dir'); diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index dc0137cbf..686dafc81 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -1,9 +1,9 @@ import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { createServer } from 'node:http'; -import { cp, lstat, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { afterAll, afterEach, beforeAll, expect, it } from '@rstest/core'; import { WebSocketServer } from 'ws'; @@ -45,7 +45,7 @@ afterAll(async () => { }); const createRoot = async (): Promise => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-dev-host-install-')); + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-dev-host-install-'))); roots.push(root); return root; }; @@ -582,7 +582,7 @@ it('publishes a diagnostic event and preserves the installed generation when re- it('re-syncs the isolated Cursor install from coordinator epochs and ignores a failed rebuild', async () => { const built = builtFixture(); - const projectRoot = join(built.artifactRoot, '..'); + const projectRoot = resolve(built.artifactRoot, '..'); const home = await createRoot(); await mkdir(join(home, '.cursor'), { recursive: true }); const eventHub = new ProjectEventHub(); @@ -606,11 +606,19 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f projectService: new ProjectService({ root: projectRoot }), root: projectRoot, }); - const destination = join(home, '.cursor', 'plugins', 'local', 'host-install-proof'); + const syncEvents: unknown[] = []; + eventHub.subscribe((event) => { + if (event.type === 'dev.host.sync') syncEvents.push(event.payload); + }); manager.start(); try { await coordinator.start(); await manager.settled(); + const attached = manager.attached('cursor'); + if (attached === undefined) { + throw new Error(`Cursor development install did not attach: ${JSON.stringify(syncEvents)}`); + } + const destination = attached.destination; const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); expect(mcpBefore).toContain(`"command":${JSON.stringify(process.execPath)}`); expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain( diff --git a/packages/agent-bundle/tests/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts index 08194f7b5..58d792ec7 100644 --- a/packages/agent-bundle/tests/packed-install-bin.test.ts +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -191,7 +191,8 @@ it('ships a self-contained installer bin that binds its own npm root', async () it('installs, reports, replaces, plans, and uninstalls through the framework lifecycle with receipts', async () => { const destination = join(home, '.cursor', 'plugins', 'local', packageName); - const installedRoot = resolve(bin, '..', '..'); + // `readArtifactManifest` reports the realpath root (Windows 8.3 → long). + const installedRoot = await realpath(resolve(bin, '..', '..')); const installed = await run(['install', 'cursor', '--json']); expect(installed.stderr).toBe(''); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 50cab330a..047926a46 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -623,7 +623,19 @@ const buildFixtureProject = async (options: { const artifactRoot = join(project, 'artifact'); try { await cp(join(fixturesRoot, options.fixture), project, { recursive: true }); - if (process.platform !== 'win32') { + if (process.platform === 'win32') { + // Junction the real package directories (not the pnpm symlink forest) + // so a later coordinator rebuild can resolve the same compile-time + // imports the initial CLI build used. + const modules = join(project, 'node_modules'); + await mkdir(join(modules, '@modelcontextprotocol'), { recursive: true }); + for (const specifier of ['@modelcontextprotocol/server', 'zod'] as const) { + const source = await realpath(join(packageRoot, 'node_modules', ...specifier.split('/'))); + const dest = join(modules, ...specifier.split('/')); + await mkdir(dirname(dest), { recursive: true }); + await symlink(source, dest, 'junction'); + } + } else { await symlink(join(packageRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); } await options.prepareProject?.(project); From d85454c729e8b9a0361ba79585c51a1f6bb2f849 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:41:25 +0000 Subject: [PATCH 11/18] fix(windows): publish artifact.available when epoch revision splits A successful coordinator rebuild now treats the epoch it just built as active so host-install attaches when the prepare snapshot digest and compiler project revision disagree. Source snapshots record the same identity path createProjectContext uses. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- .../agent-bundle/src/core/project-context.ts | 6 ++- packages/agent-bundle/src/dev/coordinator.ts | 7 +++- .../agent-bundle/src/dev/project-service.ts | 6 ++- .../tests/dev-coordinator.test.ts | 37 +++++++++++++++++++ .../tests/dev-host-install.test.ts | 13 ++++++- .../agent-bundle/tests/dev-services.test.ts | 5 +++ 7 files changed, 69 insertions(+), 7 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index 22dbf5467..70fc82f4b 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. Source snapshots record the same identity path `createProjectContext` uses, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 3105e33bb..1a7132cc8 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -653,6 +653,10 @@ export const canonicalizeNormalizedModel = ( }); }; +/** Project-relative POSIX path used in source-input identity. */ +export const projectSourceIdentityPath = (root: string, value: string): string => + resolvedProjectPath(root, value, 'Project source input path'); + const canonicalSourceInputs = ( root: string, inputs: readonly ProjectSourceSnapshotInput[], @@ -661,7 +665,7 @@ const canonicalSourceInputs = ( if (input.error !== undefined || input.sha256 === undefined || !sha256Pattern.test(input.sha256)) { throw new TypeError(`Project source input ${JSON.stringify(input.path)} must have a lowercase SHA-256 digest.`); } - const path = resolvedProjectPath(root, input.path, 'Project source input path'); + const path = projectSourceIdentityPath(root, input.path); return { ...(input.executable === undefined ? {} : { executable: input.executable }), path, diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index deca1d4d7..8d7d92758 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -568,7 +568,12 @@ export class DevCoordinator { }); this.#activeEpoch = result.epoch; this.#onPublishedProject?.(prepared, result.epoch); - const artifact = artifactStatusFor(this.#activeEpoch, source.revision); + // A successful compile of this epoch is current for the tree that was + // just built. Comparing `source.revision` to `epoch.projectRevision` + // can disagree on Windows (8.3 vs long-path encoding in + // `canonicalSourceInputs` vs a prepare snapshot) and would skip + // `artifact.available`, so host-install never attaches. + const artifact = artifactStatusFor(this.#activeEpoch, this.#activeEpoch.projectRevision); this.#status = freezeProjectStatus({ artifact, build: { lastAttempt: completed, state: 'idle' }, diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 39e8227c6..b2c14e37b 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -26,6 +26,7 @@ import { deduplicateDiagnostics, type Diagnostic, withDiagnosticRecovery } from import { digest } from '../core/digest.ts'; import { createProjectContext, + projectSourceIdentityPath, snapshotPackageIdentity, type ProjectContext, type ProjectSourceSnapshotInput, @@ -171,7 +172,7 @@ const relativeSourcePath = (root: string, source: string): string => { const sourceInput = async (root: string, source: string): Promise => { try { const resolvedSource = await realpath(source); - const path = relativeSourcePath(root, resolvedSource); + const path = projectSourceIdentityPath(root, resolvedSource); const [contents, metadata] = await Promise.all([ readFile(resolvedSource), lstat(resolvedSource), @@ -1016,7 +1017,8 @@ export class ProjectService { snapshot, ); } - const source = sourceStatus(frozenDiagnostics, snapshot.revision, root); + const revision = projectContext?.revision ?? snapshot.revision; + const source = sourceStatus(frozenDiagnostics, revision, root); log(this.#options.logger, 'project.prepared', { diagnostics: frozenDiagnostics.length, root, diff --git a/packages/agent-bundle/tests/dev-coordinator.test.ts b/packages/agent-bundle/tests/dev-coordinator.test.ts index 658f4cd55..8535270bf 100644 --- a/packages/agent-bundle/tests/dev-coordinator.test.ts +++ b/packages/agent-bundle/tests/dev-coordinator.test.ts @@ -326,6 +326,43 @@ it('queues watcher add, change, and delete paths as one rebuild during a running } }); +it('publishes artifact.available when the built epoch revision disagrees with the prepare snapshot', async () => { + const root = await createProject(); + const hub = new ProjectEventHub({ now: () => new Date('2026-08-14T12:00:00.000Z') }); + const events: string[] = []; + hub.subscribe((event) => { + if (event.type !== 'replay.gap') events.push(event.type); + }); + + try { + const coordinator = new DevCoordinator({ + acquireLock: async () => ({ close: async () => undefined }), + artifactService: { + build: async (prepared) => + succeeded(epochFor(root, 'epoch-split', `not-${prepared.source.revision ?? 'missing'}`)), + }, + diagnosticService: { + close: async () => undefined, + lint: async (paths) => ({ diagnostics: [], paths }), + }, + epochStore: new EpochStore({ projectRoot: root }), + eventHub: hub, + projectService: new ProjectService({ root }), + root, + }); + + await coordinator.start(); + expect(coordinator.status().artifact).toMatchObject({ + activeEpoch: { id: 'epoch-split' }, + state: 'active', + }); + expect(events).toEqual(expect.arrayContaining(['artifact.available'])); + await coordinator.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('retains the last good epoch as stale when a later rebuild fails', async () => { const root = await createProject(); const hub = new ProjectEventHub({ now: () => new Date('2026-08-14T12:00:00.000Z') }); diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 686dafc81..539ddc4c3 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -602,8 +602,12 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f createWatcher: () => ({ close: async () => undefined }), epochStore, eventHub, + outputPaths: [built.artifactRoot], prepareCommand: 'dev', - projectService: new ProjectService({ root: projectRoot }), + projectService: new ProjectService({ + outputRoots: [built.artifactRoot], + root: projectRoot, + }), root: projectRoot, }); const syncEvents: unknown[] = []; @@ -616,7 +620,12 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f await manager.settled(); const attached = manager.attached('cursor'); if (attached === undefined) { - throw new Error(`Cursor development install did not attach: ${JSON.stringify(syncEvents)}`); + throw new Error( + `Cursor development install did not attach: ${JSON.stringify({ + status: coordinator.status(), + syncEvents, + })}`, + ); } const destination = attached.destination; const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index c00972a1a..352ae4007 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -473,6 +473,11 @@ it('creates an exact deeply frozen root-independent project context', async () = 'src/skills/review/SKILL.md', 'z-last.txt', ]); + const snapshot = await snapshotProjectSource(leftRoot, left.configPath); + expect(snapshot.inputs.map((input) => input.path)).toEqual( + left.projectContext?.sourceInputs.map((input) => input.path), + ); + expect(snapshot.revision).toBe(left.projectContext?.revision); expect(Object.isFrozen(left.projectContext)).toBe(true); expect(Object.isFrozen(left.projectContext?.sourceInputs)).toBe(true); expect(Object.isFrozen(left.projectContext?.sourceInputs[0])).toBe(true); From 5f9c953ece7df6b0e30d17bb52791eb451064508 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:42:14 +0000 Subject: [PATCH 12/18] fix(windows): snapshot source paths with on-disk identity Share resolvedProjectPath between snapshot inputs and createProjectContext so a Windows path.resolve spelling cannot fail the coordinator rebuild with AB7101 and skip artifact.available. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- .../agent-bundle/src/core/project-context.ts | 7 ++++ .../tests/dev-host-install.test.ts | 37 +++++++++++++++---- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index 70fc82f4b..c58f1c210 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. Source snapshots record the same identity path `createProjectContext` uses, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 1a7132cc8..33819adc8 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -287,6 +287,13 @@ const onDiskOrNearestAncestorPath = (lexicalPath: string): string => { } }; +/** + * Project-relative POSIX path using on-disk identity. Snapshot inputs and + * `createProjectContext` must share this helper: a Windows `path.resolve` + * spelling (8.3 alias, junction hop) that differs from `realpath` would + * otherwise hash one revision at prepare and reject the same tree with + * `AB7101` after compile. + */ const resolvedProjectPath = (root: string, value: string, label: string): string => { const canonicalRoot = realpathSync(resolve(root)); const lexicalRoot = resolve(root); diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 539ddc4c3..13943d901 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -582,7 +582,7 @@ it('publishes a diagnostic event and preserves the installed generation when re- it('re-syncs the isolated Cursor install from coordinator epochs and ignores a failed rebuild', async () => { const built = builtFixture(); - const projectRoot = resolve(built.artifactRoot, '..'); + const projectRoot = await realpath(resolve(built.artifactRoot, '..')); const home = await createRoot(); await mkdir(join(home, '.cursor'), { recursive: true }); const eventHub = new ProjectEventHub(); @@ -604,6 +604,8 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f eventHub, outputPaths: [built.artifactRoot], prepareCommand: 'dev', + // The CLI proof writes `--output artifact`; keep that tree out of source + // identity so the in-process rebuild matches `agent-bundle dev`. projectService: new ProjectService({ outputRoots: [built.artifactRoot], root: projectRoot, @@ -611,21 +613,38 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f root: projectRoot, }); const syncEvents: unknown[] = []; + const coordinatorEvents: unknown[] = []; eventHub.subscribe((event) => { if (event.type === 'dev.host.sync') syncEvents.push(event.payload); + if ( + event.type === 'artifact.available' || + event.type === 'artifact.status' || + event.type === 'build.failed' + ) { + coordinatorEvents.push({ + ...(event.epochId === undefined ? {} : { epochId: event.epochId }), + payload: event.payload, + type: event.type, + }); + } }); + const skillPath = join(projectRoot, 'src', 'skills', 'probe', 'SKILL.md'); + const hookPath = join(projectRoot, 'src', 'hooks', 'session-start.ts'); + const [originalSkill, originalHook] = await Promise.all([ + readFile(skillPath, 'utf8'), + readFile(hookPath, 'utf8'), + ]); manager.start(); try { await coordinator.start(); await manager.settled(); const attached = manager.attached('cursor'); if (attached === undefined) { - throw new Error( - `Cursor development install did not attach: ${JSON.stringify({ - status: coordinator.status(), - syncEvents, - })}`, - ); + throw new Error(`Cursor development install did not attach: ${JSON.stringify({ + coordinatorEvents, + status: coordinator.status(), + syncEvents, + })}`); } const destination = attached.destination; const mcpBefore = await readFile(join(destination, '.cursor-plugin', 'mcp.json'), 'utf8'); @@ -670,6 +689,10 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain('# Updated skill'); expect(await readFile(join(destination, DEV_INSTALL_MARKER), 'utf8')).toBe(markerBeforeFailure); } finally { + await Promise.all([ + writeFile(skillPath, originalSkill), + writeFile(hookPath, originalHook), + ]); await manager.close(); await coordinator.close(); } From 6e270b9713dba7a27a82bb402a24d4bf31759ee4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 04:47:02 +0000 Subject: [PATCH 13/18] fix: require config and recorded sources after containment Nearest-ancestor containment still judges escaping dangling leaves, but a deleted configuration file or hashed source input must exist after that gate. Prebuilt hook and payload paths stay allowed to be missing. The coordinator host-install test now asserts an active epoch and artifact.available before attach. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- .../agent-bundle/src/core/project-context.ts | 21 ++++++- .../tests/dev-host-install.test.ts | 9 +++ .../agent-bundle/tests/dev-services.test.ts | 60 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index c58f1c210..90dc929e1 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 33819adc8..a962767dd 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -294,7 +294,12 @@ const onDiskOrNearestAncestorPath = (lexicalPath: string): string => { * otherwise hash one revision at prepare and reject the same tree with * `AB7101` after compile. */ -const resolvedProjectPath = (root: string, value: string, label: string): string => { +const resolvedProjectPath = ( + root: string, + value: string, + label: string, + options: { readonly requireExists?: boolean } = {}, +): string => { const canonicalRoot = realpathSync(resolve(root)); const lexicalRoot = resolve(root); // Relative authored paths stay POSIX-canonical. Absolute on-disk paths @@ -306,6 +311,14 @@ const resolvedProjectPath = (root: string, value: string, label: string): string if (escapesRoot(canonicalRoot, referencedPath)) { throw new RangeError(`${label} ${JSON.stringify(referencedPath)} is outside project root ${JSON.stringify(canonicalRoot)}.`); } + // Containment for a missing leaf uses the nearest existing ancestor. + // Configuration and recorded source inputs still have to exist after that + // gate: a deleted config or hashed input must not become a lexical-inside + // identity. Model/prebuilt paths omit this so a payload that is allowed + // not to exist yet stays valid. + if (options.requireExists === true) { + realpathSync(referencedPath); + } const projectRelative = relative(canonicalRoot, referencedPath).replaceAll('\\', '/'); if (projectRelative.length === 0) throw new RangeError(`${label} must not be the project root.`); return projectRelative; @@ -662,7 +675,7 @@ export const canonicalizeNormalizedModel = ( /** Project-relative POSIX path used in source-input identity. */ export const projectSourceIdentityPath = (root: string, value: string): string => - resolvedProjectPath(root, value, 'Project source input path'); + resolvedProjectPath(root, value, 'Project source input path', { requireExists: true }); const canonicalSourceInputs = ( root: string, @@ -691,7 +704,9 @@ const canonicalSourceInputs = ( /** Creates the single canonical identity carried from preparation to publication. */ export const createProjectContext = (options: CreateProjectContextOptions): ProjectContext => { const canonicalRoot = realpathSync(resolve(options.root)); - const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path'); + const configPath = resolvedProjectPath(canonicalRoot, options.configPath, 'Configuration path', { + requireExists: true, + }); const sourceInputs = canonicalSourceInputs(canonicalRoot, options.sourceInputs); const configInput = sourceInputs.find((input) => input.path === configPath); if (configInput === undefined) { diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 13943d901..c5ad14e4f 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -637,6 +637,15 @@ it('re-syncs the isolated Cursor install from coordinator epochs and ignores a f manager.start(); try { await coordinator.start(); + const status = coordinator.status(); + const available = coordinatorEvents.filter((event) => + typeof event === 'object' && event !== null && 'type' in event && event.type === 'artifact.available'); + if (status.artifact.state !== 'active' || available.length === 0) { + throw new Error(`Initial coordinator rebuild did not publish an active epoch: ${JSON.stringify({ + coordinatorEvents, + status, + })}`); + } await manager.settled(); const attached = manager.attached('cursor'); if (attached === undefined) { diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index 352ae4007..d5b304d56 100644 --- a/packages/agent-bundle/tests/dev-services.test.ts +++ b/packages/agent-bundle/tests/dev-services.test.ts @@ -605,6 +605,66 @@ it('creates an exact deeply frozen root-independent project context', async () = } }); +it('refuses a deleted configuration path after canonical containment', async () => { + const skillMarkdown = [ + '---', + 'name: review', + 'description: Reviews changes', + '---', + 'Review the changed files.', + '', + ].join('\n'); + const root = await createProject(skillMarkdown); + try { + const prepared = await new ProjectService({ root }).prepare('build'); + const model = prepared.model; + const sourceInputs = prepared.projectContext?.sourceInputs; + if (model === undefined || sourceInputs === undefined) { + throw new Error('Expected a prepared project context.'); + } + await rm(join(root, 'agent-bundle.config.ts')); + expect(() => createProjectContext({ + configPath: 'agent-bundle.config.ts', + model, + root, + sourceInputs, + })).toThrow(/ENOENT/i); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses a deleted recorded source input after canonical containment', async () => { + const skillMarkdown = [ + '---', + 'name: review', + 'description: Reviews changes', + '---', + 'Review the changed files.', + '', + ].join('\n'); + const root = await createProject(skillMarkdown); + try { + await writeFile(join(root, 'notes.txt'), 'notes\n'); + const prepared = await new ProjectService({ root }).prepare('build'); + const model = prepared.model; + const sourceInputs = prepared.projectContext?.sourceInputs; + if (model === undefined || sourceInputs === undefined) { + throw new Error('Expected a prepared project context.'); + } + expect(sourceInputs.map((input) => input.path)).toContain('notes.txt'); + await rm(join(root, 'notes.txt')); + expect(() => createProjectContext({ + configPath: prepared.projectContext?.configPath ?? 'agent-bundle.config.ts', + model, + root, + sourceInputs, + })).toThrow(/ENOENT/i); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('prepares a symlinked project root from its canonical filesystem identity', async () => { const root = await createProject([ '---', From a377fbb8f3f3ae8bd1187c6a926825e29f869e9c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 05:26:13 +0000 Subject: [PATCH 14/18] fix(windows): treat epoch file fsync EPERM as best-effort Hosted Windows FlushFileBuffers can fail on newly written epoch files after a successful compile (AB7100). Keep the bytes and publish the epoch instead of dropping artifact.available. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- packages/agent-bundle/src/core/errors.ts | 9 +-- packages/agent-bundle/src/dev/epoch-store.ts | 9 ++- .../agent-bundle/tests/epoch-store.test.ts | 59 +++++++++++++++++++ 4 files changed, 73 insertions(+), 6 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index 90dc929e1..d7bc7ed77 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; directory FlushFileBuffers `EPERM` is tolerated with the existing `EACCES`/`EINVAL` gap; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; development epoch publication treats Windows FlushFileBuffers `EACCES`/`EINVAL`/`EPERM` on directories and newly written files as best-effort durability instead of failing the rebuild with `AB7100`; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/core/errors.ts b/packages/agent-bundle/src/core/errors.ts index c41eb8c25..89a3e6d04 100644 --- a/packages/agent-bundle/src/core/errors.ts +++ b/packages/agent-bundle/src/core/errors.ts @@ -21,10 +21,11 @@ export class CodedError extends Error { } /** - * Windows has no public directory-fsync primitive. FlushFileBuffers on a - * directory handle fails with EACCES, EINVAL, or EPERM depending on the - * volume and Node/libuv mapping; durability there is best-effort. Regular - * files never use this helper. + * Windows FlushFileBuffers capability failures. Directory handles have no + * public fsync primitive and fail with EACCES, EINVAL, or EPERM depending on + * the volume and Node/libuv mapping. Hosted Windows runners also return + * EPERM for some newly written regular files. Callers that already persisted + * bytes treat these codes as best-effort durability, not a lost write. */ export const isTolerableWin32SyncError = (platform: string, error: unknown): boolean => platform === 'win32' diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 6377fa38e..e5c17cbbe 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -21,6 +21,8 @@ export interface EpochStoreOptions { readonly move?: typeof rename; /** @internal Deterministic durability-failure seam. */ readonly durabilityStorage?: EpochDurabilityStorage; + /** @internal Durability platform seam; defaults to `process.platform`. */ + readonly platform?: NodeJS.Platform; readonly projectRoot: string; } @@ -345,6 +347,7 @@ export class EpochStore { readonly #epochMetadataPath: string; readonly #epochsPath: string; readonly #move: typeof rename; + readonly #platform: NodeJS.Platform; /** The process-wide lease mutex shared by every store over this project. */ readonly #leaseTransitions: Semaphore.Semaphore; readonly #staging = new Map(); @@ -356,6 +359,7 @@ export class EpochStore { this.#activeEpochPath = join(agentBundlePath, activeEpochFileName); this.#cleanupRemove = options.cleanupRemove ?? rm; this.#durabilityStorage = options.durabilityStorage ?? Object.freeze({ open, remove: rm }); + this.#platform = options.platform ?? process.platform; this.#epochsPath = join(agentBundlePath, 'epochs'); this.#epochMetadataPath = join(this.#epochsPath, metadataDirectoryName); this.#move = options.move ?? rename; @@ -704,7 +708,10 @@ export class EpochStore { try { await handle.sync(); } catch (error) { - if (directory && isTolerableWin32SyncError(process.platform, error)) return; + // Hosted Windows FlushFileBuffers fails for directories and, on GHA + // runners, some newly written regular files (`AB7100` fsync EPERM + // after a successful compile). The bytes are already on disk. + if (isTolerableWin32SyncError(this.#platform, error)) return; throw error; } finally { await handle.close(); } diff --git a/packages/agent-bundle/tests/epoch-store.test.ts b/packages/agent-bundle/tests/epoch-store.test.ts index 8476f7728..d0c21d35a 100644 --- a/packages/agent-bundle/tests/epoch-store.test.ts +++ b/packages/agent-bundle/tests/epoch-store.test.ts @@ -270,6 +270,65 @@ it.each(['marker removal', 'marker file sync', 'marker directory sync'] as const }, ); +it('treats Windows FlushFileBuffers EPERM on staged files as best-effort durability', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-epoch-win32-file-fsync-')); + const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); + const controlledOpen: typeof open = async (path, flags, mode) => { + const handle = await open(path, flags, mode); + return new Proxy(handle, { + get(target, property) { + if (property === 'sync') return async () => { + if ((await target.stat()).isFile()) throw eperm; + await target.sync(); + }; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; + try { + const epoch = epochFor(root, 'epoch-win32-file-fsync'); + const store = new EpochStore({ + durabilityStorage: Object.freeze({ open: controlledOpen, remove: rm }), + platform: 'win32', + projectRoot: root, + }); + await publishEpoch(store, epoch); + await expect(store.readActiveEpoch()).resolves.toEqual(epoch); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('fails epoch publication when file fsync EPERM is not a Windows FlushFileBuffers gap', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-epoch-posix-file-fsync-')); + const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); + const controlledOpen: typeof open = async (path, flags, mode) => { + const handle = await open(path, flags, mode); + return new Proxy(handle, { + get(target, property) { + if (property === 'sync') return async () => { + if ((await target.stat()).isFile()) throw eperm; + await target.sync(); + }; + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; + try { + const store = new EpochStore({ + durabilityStorage: Object.freeze({ open: controlledOpen, remove: rm }), + platform: 'linux', + projectRoot: root, + }); + await expect(publishEpoch(store, epochFor(root, 'epoch-posix-file-fsync'))).rejects.toBe(eperm); + await expect(store.readActiveEpoch()).resolves.toBeUndefined(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('fsyncs staged artifacts and each durable publication rename in commit order', async () => { const root = await mkdtemp(join(tmpdir(), 'agent bundle durable epoch publication ')); const syncedPaths: string[] = []; From 2b0875cba299b5659cf900a78a4f61a5d170e274 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 05:30:08 +0000 Subject: [PATCH 15/18] fix(windows): treat catalog staging file fsync EPERM as best-effort Coordinator rebuilds publish a native Playground catalog snapshot after epoch sync. Hosted Windows FlushFileBuffers can EPERM that newly written file the same way it EPERMs staged epoch files, wrapping as AB7100 and leaving Cursor host-install unattached. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- .../playground/native-playground-service.ts | 9 +- .../tests/native-playground-service.test.ts | 90 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index d7bc7ed77..989ee7b43 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; development epoch publication treats Windows FlushFileBuffers `EACCES`/`EINVAL`/`EPERM` on directories and newly written files as best-effort durability instead of failing the rebuild with `AB7100`; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; development epoch publication and native Playground catalog snapshot publication treat Windows FlushFileBuffers `EACCES`/`EINVAL`/`EPERM` on directories and newly written files as best-effort durability instead of failing the rebuild with `AB7100`; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/dev/playground/native-playground-service.ts b/packages/agent-bundle/src/dev/playground/native-playground-service.ts index cc52b8a57..291feef67 100644 --- a/packages/agent-bundle/src/dev/playground/native-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/native-playground-service.ts @@ -1244,7 +1244,14 @@ export class NativePlaygroundService { try { handle = await this.#catalogStorage.open(temporary, 'wx', 0o600); await handle.writeFile(contents, 'utf8'); - await handle.sync(); + try { + await handle.sync(); + } catch (error) { + // Hosted Windows FlushFileBuffers fails for some newly written + // catalog staging files (`AB7100` fsync EPERM after a successful + // compile). The snapshot bytes are already on disk. + if (!isTolerableWin32SyncError(catalogDurabilityPlatform(), error)) throw error; + } staged = await handle.stat(); if (!staged.isFile() || staged.nlink !== 1) { throw new Error('Native Playground catalog staging file is invalid.'); diff --git a/packages/agent-bundle/tests/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index 233e9f54b..3a0d5f3b9 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -613,6 +613,96 @@ it('tolerates only Windows directory fsync capability failures during catalog pu } }); +const fileSyncEpermOpen = (eperm: Error): NativePlaygroundCatalogStorage['open'] => + async (path, flags, mode) => { + const handle = await open(path, flags, mode); + return new Proxy(handle, { + get(target, property) { + if (property === 'sync') { + return async () => { + if ((await target.stat()).isFile()) throw eperm; + await target.sync(); + }; + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + }; + +it('treats Windows FlushFileBuffers EPERM on catalog staging files as best-effort durability', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-win32-file-fsync-')); + const catalogDirectory = join(root, 'catalog'); + const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); + const runtime = globalThis as typeof globalThis & Record; + const previousPlatform = runtime[nativeCatalogDurabilityPlatformKey]; + runtime[nativeCatalogDurabilityPlatformKey] = 'win32'; + const service = new NativePlaygroundService({ + catalogDirectory, + catalogStorage: { + link, + mkdir, + open: fileSyncEpermOpen(eperm), + move: rename, + remove: rm, + }, + discover: async () => suite(), + inspectArtifact: async (candidate) => Object.freeze({ + binding: Object.freeze({ + manifestPath: 'agent-bundle.manifest.json', + source: 'explicit' as const, + targetDigests: candidate.epoch.targetDigests, + }), + root: candidate.root, + }), + planFixture: async () => fixturePlan, + projectRoot: '/project', + }); + try { + await expect(service.catalog(epoch('epoch-win32-file-fsync', join(root, 'artifact')))).resolves.toMatchObject({ + epochId: 'epoch-win32-file-fsync', + }); + } finally { + await service.close(); + if (previousPlatform === undefined) delete runtime[nativeCatalogDurabilityPlatformKey]; + else runtime[nativeCatalogDurabilityPlatformKey] = previousPlatform; + await rm(root, { force: true, recursive: true }); + } +}); + +it('fails catalog publication when file fsync EPERM is not a Windows FlushFileBuffers gap', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-posix-file-fsync-')); + const catalogDirectory = join(root, 'catalog'); + const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); + const service = new NativePlaygroundService({ + catalogDirectory, + catalogStorage: { + link, + mkdir, + open: fileSyncEpermOpen(eperm), + move: rename, + remove: rm, + }, + discover: async () => suite(), + inspectArtifact: async (candidate) => Object.freeze({ + binding: Object.freeze({ + manifestPath: 'agent-bundle.manifest.json', + source: 'explicit' as const, + targetDigests: candidate.epoch.targetDigests, + }), + root: candidate.root, + }), + planFixture: async () => fixturePlan, + projectRoot: '/project', + }); + try { + await expect(service.catalog(epoch('epoch-posix-file-fsync', join(root, 'artifact')))).rejects.toBe(eperm); + } finally { + await service.close(); + await rm(root, { force: true, recursive: true }); + } +}); + it('preserves a catalog replacement raced into rollback and fsyncs the parent after cleanup', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-rollback-race-')); const catalogDirectory = join(root, 'catalog'); From 64ad3a20e117237deb10b473e429ce63457fcbba Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 05:32:39 +0000 Subject: [PATCH 16/18] fix(lint): drop unused EpochStore #syncPath directory flag File and directory FlushFileBuffers errors share one Windows tolerance path, so the directory argument tripped no-unused-vars on Docs CI. Co-authored-by: Zack Jackson --- packages/agent-bundle/src/dev/epoch-store.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index e5c17cbbe..a0201557f 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -642,7 +642,7 @@ export class EpochStore { yield* liftPromise(() => this.#removeStagingMarker(record)); yield* liftPromise(() => this.#move(record.root, epochRoot)); moved = true; - yield* liftPromise(() => this.#syncPath(this.#epochsPath, true)); + yield* liftPromise(() => this.#syncPath(this.#epochsPath)); if (beforeActivate !== undefined) { publication = (yield* liftPromise(() => beforeActivate(record.epoch))) ?? undefined; } @@ -691,19 +691,19 @@ export class EpochStore { const markerPath = join(record.root, stagingMarkerFileName); await this.#syncPath(markerPath); await this.#durabilityStorage.remove(markerPath); - await this.#syncPath(record.root, true); + await this.#syncPath(record.root); } async #removePublicationPath(path: string, parent: string, recursive = false): Promise { await rm(path, recursive ? { force: true, recursive: true } : { force: true }); try { - await this.#syncPath(parent, true); + await this.#syncPath(parent); } catch (error) { if (!isErrno(error, 'ENOENT')) throw error; } } - async #syncPath(path: string, directory = false): Promise { + async #syncPath(path: string): Promise { const handle = await this.#durabilityStorage.open(path, 'r'); try { await handle.sync(); @@ -733,7 +733,7 @@ export class EpochStore { for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { await this.#syncTree(join(path, entry.name)); } - await this.#syncPath(path, true); + await this.#syncPath(path); } async #verifyStaging(record: StagingRecord): Promise { @@ -897,7 +897,7 @@ export class EpochStore { async #writeJsonAtomically(path: string, value: EpochMetadata, progress?: AtomicWriteProgress): Promise { const directory = dirname(path); await mkdir(directory, { recursive: true }); - await this.#syncPath(dirname(directory), true); + await this.#syncPath(dirname(directory)); const temporaryPath = join( directory, `.${basename(path)}.stage-${process.pid}-${Math.random().toString(16).slice(2)}`, @@ -907,7 +907,7 @@ export class EpochStore { await this.#syncPath(temporaryPath); await rename(temporaryPath, path); if (progress !== undefined) progress.renamed = true; - await this.#syncPath(directory, true); + await this.#syncPath(directory); } finally { await rm(temporaryPath, { force: true }); } From 4d1b15dadd7a7aed8c280c99431a64946a8cb858 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 05:33:50 +0000 Subject: [PATCH 17/18] fix(lint): keep EpochStore #syncPath directory intent as _directory File and directory FlushFileBuffers errors share one Windows tolerance path, so the flag is unused. Call sites still pass true for directories; underscore it for rslint instead of dropping the argument. Co-authored-by: Zack Jackson --- packages/agent-bundle/src/dev/epoch-store.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index a0201557f..c3ff4fe03 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -642,7 +642,7 @@ export class EpochStore { yield* liftPromise(() => this.#removeStagingMarker(record)); yield* liftPromise(() => this.#move(record.root, epochRoot)); moved = true; - yield* liftPromise(() => this.#syncPath(this.#epochsPath)); + yield* liftPromise(() => this.#syncPath(this.#epochsPath, true)); if (beforeActivate !== undefined) { publication = (yield* liftPromise(() => beforeActivate(record.epoch))) ?? undefined; } @@ -691,19 +691,19 @@ export class EpochStore { const markerPath = join(record.root, stagingMarkerFileName); await this.#syncPath(markerPath); await this.#durabilityStorage.remove(markerPath); - await this.#syncPath(record.root); + await this.#syncPath(record.root, true); } async #removePublicationPath(path: string, parent: string, recursive = false): Promise { await rm(path, recursive ? { force: true, recursive: true } : { force: true }); try { - await this.#syncPath(parent); + await this.#syncPath(parent, true); } catch (error) { if (!isErrno(error, 'ENOENT')) throw error; } } - async #syncPath(path: string): Promise { + async #syncPath(path: string, _directory = false): Promise { const handle = await this.#durabilityStorage.open(path, 'r'); try { await handle.sync(); @@ -733,7 +733,7 @@ export class EpochStore { for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { await this.#syncTree(join(path, entry.name)); } - await this.#syncPath(path); + await this.#syncPath(path, true); } async #verifyStaging(record: StagingRecord): Promise { @@ -897,7 +897,7 @@ export class EpochStore { async #writeJsonAtomically(path: string, value: EpochMetadata, progress?: AtomicWriteProgress): Promise { const directory = dirname(path); await mkdir(directory, { recursive: true }); - await this.#syncPath(dirname(directory)); + await this.#syncPath(dirname(directory), true); const temporaryPath = join( directory, `.${basename(path)}.stage-${process.pid}-${Math.random().toString(16).slice(2)}`, @@ -907,7 +907,7 @@ export class EpochStore { await this.#syncPath(temporaryPath); await rename(temporaryPath, path); if (progress !== undefined) progress.renamed = true; - await this.#syncPath(directory); + await this.#syncPath(directory, true); } finally { await rm(temporaryPath, { force: true }); } From 89377e8590400625c2ed4a8657dab44c650ee9d4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 12 Sep 2026 05:37:26 +0000 Subject: [PATCH 18/18] fix(windows): fsync epoch files through write-capable r+ handles Hosted Windows FlushFileBuffers refuses read-only regular-file handles (AB7100). EpochStore now opens existing files with non-truncating r+ on win32, keeps directory opens read-only, and restores directory-only EPERM tolerance so a genuine file sync failure still refuses publication. Co-authored-by: Zack Jackson --- .../host-install-windows-path-identity.md | 2 +- packages/agent-bundle/src/core/errors.ts | 6 +- packages/agent-bundle/src/dev/epoch-store.ts | 13 ++-- .../playground/native-playground-service.ts | 9 +-- .../agent-bundle/tests/epoch-store.test.ts | 62 ++++++++++++++++--- .../tests/native-playground-service.test.ts | 6 +- 6 files changed, 68 insertions(+), 30 deletions(-) diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md index 989ee7b43..0c2f0f497 100644 --- a/.changeset/host-install-windows-path-identity.md +++ b/.changeset/host-install-windows-path-identity.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; development epoch publication and native Playground catalog snapshot publication treat Windows FlushFileBuffers `EACCES`/`EINVAL`/`EPERM` on directories and newly written files as best-effort durability instead of failing the rebuild with `AB7100`; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) +On Windows, `createProjectContext` now judges existing project paths by on-disk identity so 8.3 aliases and native config paths no longer raise `AB7001`; missing paths resolve the nearest existing ancestor (so a dangling leaf under an escaping symlink still fails closed) instead of throwing `ENOENT`. After that containment gate, the configuration file and every recorded source input must still exist (`ENOENT`); prebuilt hook and payload paths that are allowed not to exist yet stay valid. Source snapshots record the same identity path `createProjectContext` uses, so a development rebuild no longer fails with `AB7101` when `path.resolve` spelling differs from the realpath project identity, and a successful `agent-bundle dev` rebuild publishes `artifact.available` from the epoch it just built so host-install attaches even when a prepare snapshot digest and compiler project revision disagree. `agent-bundle prepack` launches npm's `npm-cli.js` through `process.execPath` and ignores a pnpm `npm_execpath`. Development host install publishes Windows directory junctions with absolute realpath targets and replaces an existing junction by moving the previous pointer aside; development epoch publication opens existing regular files with write-capable non-truncating `r+` on Windows before `fsync` so FlushFileBuffers is not refused as `EPERM` (`AB7100`), while directory FlushFileBuffers `EACCES`/`EINVAL`/`EPERM` stays best-effort and genuine file-sync failures still refuse publication; and a declared state root whose parent is a file records as unproven (`EEXIST`) instead of failing install. (#787) diff --git a/packages/agent-bundle/src/core/errors.ts b/packages/agent-bundle/src/core/errors.ts index 89a3e6d04..7bfbd201b 100644 --- a/packages/agent-bundle/src/core/errors.ts +++ b/packages/agent-bundle/src/core/errors.ts @@ -23,9 +23,9 @@ export class CodedError extends Error { /** * Windows FlushFileBuffers capability failures. Directory handles have no * public fsync primitive and fail with EACCES, EINVAL, or EPERM depending on - * the volume and Node/libuv mapping. Hosted Windows runners also return - * EPERM for some newly written regular files. Callers that already persisted - * bytes treat these codes as best-effort durability, not a lost write. + * the volume and Node/libuv mapping. Callers that already persisted a + * directory treat these codes as best-effort durability, not a lost write. + * Regular-file sync still fails closed. */ export const isTolerableWin32SyncError = (platform: string, error: unknown): boolean => platform === 'win32' diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index c3ff4fe03..da6e63ff1 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -703,15 +703,16 @@ export class EpochStore { } } - async #syncPath(path: string, _directory = false): Promise { - const handle = await this.#durabilityStorage.open(path, 'r'); + async #syncPath(path: string, directory = false): Promise { + // Windows FlushFileBuffers requires write-capable access for regular + // files. `r+` does not create or truncate. Directory handles have no + // public fsync primitive and stay read-only plus the documented gap. + const flags = this.#platform === 'win32' && !directory ? 'r+' : 'r'; + const handle = await this.#durabilityStorage.open(path, flags); try { await handle.sync(); } catch (error) { - // Hosted Windows FlushFileBuffers fails for directories and, on GHA - // runners, some newly written regular files (`AB7100` fsync EPERM - // after a successful compile). The bytes are already on disk. - if (isTolerableWin32SyncError(this.#platform, error)) return; + if (directory && isTolerableWin32SyncError(this.#platform, error)) return; throw error; } finally { await handle.close(); } diff --git a/packages/agent-bundle/src/dev/playground/native-playground-service.ts b/packages/agent-bundle/src/dev/playground/native-playground-service.ts index 291feef67..cc52b8a57 100644 --- a/packages/agent-bundle/src/dev/playground/native-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/native-playground-service.ts @@ -1244,14 +1244,7 @@ export class NativePlaygroundService { try { handle = await this.#catalogStorage.open(temporary, 'wx', 0o600); await handle.writeFile(contents, 'utf8'); - try { - await handle.sync(); - } catch (error) { - // Hosted Windows FlushFileBuffers fails for some newly written - // catalog staging files (`AB7100` fsync EPERM after a successful - // compile). The snapshot bytes are already on disk. - if (!isTolerableWin32SyncError(catalogDurabilityPlatform(), error)) throw error; - } + await handle.sync(); staged = await handle.stat(); if (!staged.isFile() || staged.nlink !== 1) { throw new Error('Native Playground catalog staging file is invalid.'); diff --git a/packages/agent-bundle/tests/epoch-store.test.ts b/packages/agent-bundle/tests/epoch-store.test.ts index d0c21d35a..10869a70e 100644 --- a/packages/agent-bundle/tests/epoch-store.test.ts +++ b/packages/agent-bundle/tests/epoch-store.test.ts @@ -1,4 +1,4 @@ -import { chmod, mkdtemp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, lstat, mkdtemp, mkdir, open, readFile, readdir, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, dirname, join } from 'node:path'; @@ -270,15 +270,54 @@ it.each(['marker removal', 'marker file sync', 'marker directory sync'] as const }, ); -it('treats Windows FlushFileBuffers EPERM on staged files as best-effort durability', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-epoch-win32-file-fsync-')); +it('opens Windows regular files with write-capable non-truncating flags and preserves staged bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-epoch-win32-rplus-')); + const opened: { readonly directory: boolean; readonly flags: unknown }[] = []; + const recordingOpen: typeof open = async (path, flags, mode) => { + opened.push({ directory: (await lstat(path)).isDirectory(), flags }); + return open(path, flags, mode); + }; + try { + const epoch = epochFor(root, 'epoch-win32-rplus'); + const store = new EpochStore({ + durabilityStorage: Object.freeze({ open: recordingOpen, remove: rm }), + platform: 'win32', + projectRoot: root, + }); + const staging = await store.createStagingEpoch({ epoch, targets: Object.keys(epoch.targetDigests) }); + const pluginPayload = 'win32-rplus-plugin\n'; + const manifestPayload = '{"kind":"rplus"}\n'; + await Promise.all(Object.keys(epoch.targetDigests).map(async (target) => { + await mkdir(join(staging.root, target), { recursive: true }); + await writeFile(join(staging.root, target, 'plugin.json'), pluginPayload); + })); + await writeFile(join(staging.root, 'agent-bundle.manifest.json'), manifestPayload); + await staging.publish(async () => undefined); + await expect(store.readActiveEpoch()).resolves.toEqual(epoch); + await expect(readFile(join(root, '.agent-bundle', 'epochs', epoch.id, 'claude', 'plugin.json'), 'utf8')) + .resolves.toBe(pluginPayload); + await expect(readFile(join(root, '.agent-bundle', 'epochs', epoch.id, 'agent-bundle.manifest.json'), 'utf8')) + .resolves.toBe(manifestPayload); + expect(opened.some((entry) => !entry.directory)).toBe(true); + expect(opened.some((entry) => entry.directory)).toBe(true); + for (const entry of opened) { + expect(entry.flags).toBe(entry.directory ? 'r' : 'r+'); + } + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses publication when a Windows regular-file fsync fails and keeps the previous active epoch', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-epoch-win32-file-fsync-fatal-')); const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); + let failFiles = false; const controlledOpen: typeof open = async (path, flags, mode) => { const handle = await open(path, flags, mode); return new Proxy(handle, { get(target, property) { if (property === 'sync') return async () => { - if ((await target.stat()).isFile()) throw eperm; + if (failFiles && (await target.stat()).isFile()) throw eperm; await target.sync(); }; const value = Reflect.get(target, property, target); @@ -287,20 +326,27 @@ it('treats Windows FlushFileBuffers EPERM on staged files as best-effort durabil }); }; try { - const epoch = epochFor(root, 'epoch-win32-file-fsync'); const store = new EpochStore({ durabilityStorage: Object.freeze({ open: controlledOpen, remove: rm }), platform: 'win32', projectRoot: root, }); - await publishEpoch(store, epoch); - await expect(store.readActiveEpoch()).resolves.toEqual(epoch); + const active = epochFor(root, 'epoch-win32-file-sync-kept'); + const replacement = epochFor(root, 'epoch-win32-file-sync-rejected'); + await publishEpoch(store, active); + failFiles = true; + await expect(publishEpoch(store, replacement)).rejects.toBe(eperm); + await expect(store.readActiveEpoch()).resolves.toEqual(active); + const epochEntries = await readdir(join(root, '.agent-bundle', 'epochs')); + expect(epochEntries).toEqual(expect.arrayContaining([active.id, '.metadata'])); + expect(epochEntries).not.toContain(replacement.id); + expect(epochEntries.filter((entry) => entry.startsWith('.stage-'))).toEqual([]); } finally { await rm(root, { force: true, recursive: true }); } }); -it('fails epoch publication when file fsync EPERM is not a Windows FlushFileBuffers gap', async () => { +it('fails epoch publication when file fsync EPERM is not a Windows directory FlushFileBuffers gap', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-epoch-posix-file-fsync-')); const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); const controlledOpen: typeof open = async (path, flags, mode) => { diff --git a/packages/agent-bundle/tests/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index 3a0d5f3b9..c5968362a 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -630,7 +630,7 @@ const fileSyncEpermOpen = (eperm: Error): NativePlaygroundCatalogStorage['open'] }); }; -it('treats Windows FlushFileBuffers EPERM on catalog staging files as best-effort durability', async () => { +it('fails catalog publication when Windows regular-file fsync EPERM is not a directory FlushFileBuffers gap', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-native-playground-win32-file-fsync-')); const catalogDirectory = join(root, 'catalog'); const eperm = Object.assign(new Error('EPERM: operation not permitted, fsync'), { code: 'EPERM' }); @@ -659,9 +659,7 @@ it('treats Windows FlushFileBuffers EPERM on catalog staging files as best-effor projectRoot: '/project', }); try { - await expect(service.catalog(epoch('epoch-win32-file-fsync', join(root, 'artifact')))).resolves.toMatchObject({ - epochId: 'epoch-win32-file-fsync', - }); + await expect(service.catalog(epoch('epoch-win32-file-fsync', join(root, 'artifact')))).rejects.toBe(eperm); } finally { await service.close(); if (previousPlatform === undefined) delete runtime[nativeCatalogDurabilityPlatformKey];