diff --git a/.changeset/host-install-windows-path-identity.md b/.changeset/host-install-windows-path-identity.md new file mode 100644 index 000000000..0c2f0f497 --- /dev/null +++ b/.changeset/host-install-windows-path-identity.md @@ -0,0 +1,5 @@ +--- +"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 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8663aed8..652053cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -378,6 +378,46 @@ 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. 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: >- + ${{ !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/.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/docs/local-ci.md b/docs/local-ci.md index 48fea412c..c994afe92 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,46 @@ 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` (`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) +- `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) +- `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 +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 +273,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..d2df30cd8 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.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", "test:host-install:session:claude": "pnpm build && AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION=1 pnpm test:host-install:session", 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/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..7bfbd201b 100644 --- a/packages/agent-bundle/src/core/errors.ts +++ b/packages/agent-bundle/src/core/errors.ts @@ -20,6 +20,13 @@ export class CodedError extends Error { } } -/** Windows denies fsync on directories and AV-locked files; durability there is best-effort. */ +/** + * 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. 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' && (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/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 7d2d80d0e..a962767dd 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -1,11 +1,12 @@ 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'; 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'; @@ -258,21 +259,73 @@ const projectRelativePath = (root: string, value: string, label: string): string return projectRelative; }; -const resolvedProjectPath = (root: string, value: string, label: string): string => { +/** + * 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; + } + } +}; + +/** + * 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, + options: { readonly requireExists?: boolean } = {}, +): 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 lexicalPath = isAbsolute(value) ? resolve(value) : resolve(lexicalRoot, value); + const referencedPath = onDiskOrNearestAncestorPath(lexicalPath); 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; }; 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, @@ -495,7 +548,14 @@ 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 (nearest existing + // ancestor on ENOENT). + source: + hook.prebuiltPath === undefined || isAbsolute(hook.source) + ? canonicalCompilerPath(root, hook.source, 'Hook source path') + : hook.source, })), ...(detached.mcpApps === undefined ? {} @@ -613,6 +673,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', { requireExists: true }); + const canonicalSourceInputs = ( root: string, inputs: readonly ProjectSourceSnapshotInput[], @@ -621,7 +685,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, @@ -640,8 +704,10 @@ 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 sourceInputs = canonicalSourceInputs(options.root, options.sourceInputs); + 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) { throw new TypeError(`Configuration source ${JSON.stringify(configPath)} must have a SHA-256 digest.`); 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/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 0e6e8ce02..da6e63ff1 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'; @@ -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; @@ -700,11 +704,15 @@ export class EpochStore { } async #syncPath(path: string, directory = false): Promise { - const handle = await this.#durabilityStorage.open(path, 'r'); + // 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) { - if (directory && process.platform === 'win32' && (isErrno(error, 'EACCES') || isErrno(error, 'EINVAL'))) return; + if (directory && isTolerableWin32SyncError(this.#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..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, @@ -327,14 +328,24 @@ 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' + ? 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'); 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/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/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/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 5cf928cc3..c5ad14e4f 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'; @@ -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 }); @@ -44,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; }; @@ -369,7 +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. const root = await createRoot(); const codexRoot = await mkdtemp(join(tmpdir(), 'codex-')); roots.push(codexRoot); @@ -578,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 = await realpath(resolve(built.artifactRoot, '..')); const home = await createRoot(); await mkdir(join(home, '.cursor'), { recursive: true }); const eventHub = new ProjectEventHub(); @@ -598,15 +602,60 @@ 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 }), + // 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, + }), root: projectRoot, }); - const destination = join(home, '.cursor', 'plugins', 'local', 'host-install-proof'); + 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(); + 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) { + 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'); expect(mcpBefore).toContain(`"command":${JSON.stringify(process.execPath)}`); expect(await readFile(join(destination, 'skills', 'probe', 'SKILL.md'), 'utf8')).toContain( @@ -649,6 +698,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(); } diff --git a/packages/agent-bundle/tests/dev-services.test.ts b/packages/agent-bundle/tests/dev-services.test.ts index b446efd89..d5b304d56 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); @@ -525,6 +530,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,10 +600,71 @@ 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 }), ]); } }); +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([ '---', 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/epoch-store.test.ts b/packages/agent-bundle/tests/epoch-store.test.ts index 8476f7728..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,6 +270,111 @@ it.each(['marker removal', 'marker file sync', 'marker directory sync'] as const }, ); +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 (failFiles && (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: 'win32', + projectRoot: root, + }); + 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 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) => { + 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[] = []; diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 188ac5d64..9672f4883 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -1,11 +1,11 @@ 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'; -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,7 +26,9 @@ 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'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { writeInstallFixtureManifest } from './support/install-fixture.ts'; @@ -95,9 +97,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`); @@ -162,7 +166,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 => @@ -334,32 +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. - const receiptStore = join(hostRoot, 'agent-bundle', 'receipts'); + // 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(receiptStore, { recursive: true }); - if (process.getuid?.() === 0) return; // root ignores directory modes; the receipt write cannot be made to fail here. - await chmod(receiptStore, 0o555); + 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', - ]); - await chmod(receiptStore, 0o755); + 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 }); } @@ -908,7 +922,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')); @@ -931,8 +945,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'); @@ -1356,13 +1369,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')); @@ -1593,8 +1609,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')); @@ -1674,8 +1689,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')); @@ -2131,7 +2145,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/native-playground-service.test.ts b/packages/agent-bundle/tests/native-playground-service.test.ts index 26ceb063f..c5968362a 100644 --- a/packages/agent-bundle/tests/native-playground-service.test.ts +++ b/packages/agent-bundle/tests/native-playground-service.test.ts @@ -600,25 +600,107 @@ 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' })], + } finally { + if (previousPlatform === undefined) delete runtime[nativeCatalogDurabilityPlatformKey]; + else runtime[nativeCatalogDurabilityPlatformKey] = previousPlatform; + await rm(root, { force: true, recursive: true }); + } +}); + +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; + }, }); - await service.close(); + }; + +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' }); + 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')))).rejects.toBe(eperm); } 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'); 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..cc55403c0 --- /dev/null +++ b/packages/agent-bundle/tests/npm-cli-resolution.test.ts @@ -0,0 +1,99 @@ +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); +}); + +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/packed-install-bin.test.ts b/packages/agent-bundle/tests/packed-install-bin.test.ts index d788eb8fa..58d792ec7 100644 --- a/packages/agent-bundle/tests/packed-install-bin.test.ts +++ b/packages/agent-bundle/tests/packed-install-bin.test.ts @@ -1,14 +1,17 @@ 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'; 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 { resolveProcessNpmCliJs } from './support/npm-cli.ts'; +import { packedNativeNodeCommand } from './support/packed-native-smoke.ts'; import { cachedNpmInstallArguments, installedEnvironment, @@ -21,10 +24,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 +38,48 @@ interface Run { readonly stdout: string; } +/** npm's JavaScript CLI, never the extensionless `.bin` shim or `npm.cmd`. */ +const resolveNpmCli = (): string => resolveProcessNpmCliJs(); + +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 +89,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 +127,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 +152,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 }); @@ -130,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/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/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-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..7d6a7c82a 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'; @@ -35,15 +36,26 @@ 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'); + 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); + } +}); - 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(root.split(/[/\\]/u).some((segment) => segment.startsWith(rstestWorkerRootPrefix))).toBe(true); + 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/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 4933bb350..047926a46 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, @@ -598,12 +611,33 @@ 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'), 'dir'); + 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); const result = await run(process.execPath, [ cli, 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..f3d604e8d --- /dev/null +++ b/packages/agent-bundle/tests/support/npm-cli.ts @@ -0,0 +1,5 @@ +export { + resolveNpmCliJs, + resolveProcessNpmCliJs, + type NpmCliResolutionIo, +} from '../../src/core/npm-cli.ts'; 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.config.ts b/rstest.config.ts index 48aec29a7..2e10dc19f 100644 --- a/rstest.config.ts +++ b/rstest.config.ts @@ -22,6 +22,8 @@ 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. + * `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.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.host-filesystem.config.ts b/rstest.host-filesystem.config.ts new file mode 100644 index 000000000..d0854955a --- /dev/null +++ b/rstest.host-filesystem.config.ts @@ -0,0 +1,42 @@ +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/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; + +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..239ad7752 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -1,9 +1,14 @@ 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'; +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 => { @@ -76,7 +85,15 @@ 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`. 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 => { 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 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` 规则不会缩窄