Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
66f2a77
ci(host-filesystem): run the host-install filesystem slice on three OSes
cursoragent Sep 12, 2026
5420782
fix(ci): canonicalize worker TMPDIR and drop example payload from OS …
cursoragent Sep 12, 2026
789a531
fix(windows): host-install path identity, durable fsync, packed bin l…
cursoragent Sep 12, 2026
b189762
fix(test): resolve npm-cli.js from nvm and official Node layouts
cursoragent Sep 12, 2026
c84c6a4
fix: lexical project paths, PATH npm-cli, and Windows host-fs skips
cursoragent Sep 12, 2026
a8163c1
fix(test): resolve host-install fixture deps on Windows without pnpm …
cursoragent Sep 12, 2026
e443ca9
fix: prepack npm-cli.js, nearest-ancestor containment, Windows junctions
cursoragent Sep 12, 2026
61b9201
fix(test): occupy receipt path with a directory for portable write fa…
cursoragent Sep 12, 2026
52db175
fix(test): inject receipt-write failure via spy so rollback stays por…
cursoragent Sep 12, 2026
1b4cace
fix(windows): realpath install identity and host-install junctions
cursoragent Sep 12, 2026
d85454c
fix(windows): publish artifact.available when epoch revision splits
cursoragent Sep 12, 2026
5f9c953
fix(windows): snapshot source paths with on-disk identity
cursoragent Sep 12, 2026
6e270b9
fix: require config and recorded sources after containment
cursoragent Sep 12, 2026
a377fbb
fix(windows): treat epoch file fsync EPERM as best-effort
cursoragent Sep 12, 2026
2b0875c
fix(windows): treat catalog staging file fsync EPERM as best-effort
cursoragent Sep 12, 2026
64ad3a2
fix(lint): drop unused EpochStore #syncPath directory flag
cursoragent Sep 12, 2026
4d1b15d
fix(lint): keep EpochStore #syncPath directory intent as _directory
cursoragent Sep 12, 2026
89377e8
fix(windows): fsync epoch files through write-capable r+ handles
cursoragent Sep 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/host-install-windows-path-identity.md
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Rewrite the changeset as an imperative user-facing summary

The changeset is a declarative, implementation-heavy account beginning with “now judges” and detailing internal flags, fsync behavior, and error handling, rather than an imperative release summary. Rewrite it in imperative voice around the user-visible commands and behavior while retaining the relevant diagnostic codes and (#787) suffix.

AGENTS.md reference: AGENTS.md:L124-L125

Useful? React with 👍 / 👎.

40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 51 additions & 4 deletions docs/local-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`)
Comment on lines +171 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the npm CLI test in the documented slice

This list says it enumerates the tests run by pnpm test:host-filesystem, but it omits npm-cli-resolution.test.ts, which is included by rstest.host-filesystem.config.ts:29. Add that test to the list so the local-CI documentation matches the actual hosted matrix coverage.

AGENTS.md reference: AGENTS.md:L96-L99

Useful? React with 👍 / 👎.


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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1375,9 +1376,10 @@ export const prepack = async (options: BuildOptions): Promise<PrepackResult> =>
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,
Expand All @@ -1387,9 +1389,7 @@ export const prepack = async (options: BuildOptions): Promise<PrepackResult> =>
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),
Expand Down
7 changes: 4 additions & 3 deletions packages/agent-bundle/src/core/durable-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 9 additions & 2 deletions packages/agent-bundle/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ export class CodedError<TCode extends string = string> 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'));
87 changes: 87 additions & 0 deletions packages/agent-bundle/src/core/npm-cli.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string | undefined>>;
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,
});
Loading
Loading