From 1feb836baccf6dfcc304506198f71cf999643307 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:47:48 +0000 Subject: [PATCH 01/11] feat(build): package the canonical npm root --- .changeset/canonical-npm-root.md | 5 + docs/diagnostics.md | 18 +- docs/effect-conventions.md | 4 +- docs/entry-conventions.md | 58 ++-- docs/framework-mode.md | 10 +- packages/agent-bundle/README.md | 16 +- .../fixtures/web-surface/package.json | 1 + packages/agent-bundle/rslib.config.ts | 1 - packages/agent-bundle/src/api.ts | 4 +- packages/agent-bundle/src/build/build.ts | 10 +- packages/agent-bundle/src/build/cli-bins.ts | 11 +- .../agent-bundle/src/build/entry-shell.ts | 19 -- .../src/build/launch-env-shell.ts | 8 +- .../src/build/pack-dependencies.ts | 7 +- .../agent-bundle/src/build/pack-inventory.ts | 33 +- .../agent-bundle/src/build/package-build.ts | 316 +++++++++--------- packages/agent-bundle/src/dev/coordinator.ts | 5 +- .../src/dev/package-build-service.ts | 13 +- packages/agent-bundle/src/install-entry.ts | 230 ------------- .../tests/cli-routes-build.test.ts | 8 +- .../agent-bundle/tests/entry-shell.test.ts | 11 - .../tests/installer-entry.test.ts | 308 ----------------- .../tests/packed-consumer.test.ts | 3 - .../tests/packed-web-command.test.ts | 79 ++++- packages/agent-bundle/tests/prepack.test.ts | 137 +++----- rstest.integration-tests.ts | 1 - .../en/guide/authoring/package-entries.mdx | 43 ++- website/docs/en/guide/distribution/index.mdx | 18 +- .../en/guide/distribution/installation.mdx | 26 +- .../docs/en/guide/distribution/validation.mdx | 13 +- website/docs/en/reference/cli.mdx | 7 +- .../zh/guide/authoring/package-entries.mdx | 34 +- website/docs/zh/guide/distribution/index.mdx | 15 +- .../zh/guide/distribution/installation.mdx | 21 +- .../docs/zh/guide/distribution/validation.mdx | 9 +- website/docs/zh/reference/cli.mdx | 4 +- 36 files changed, 494 insertions(+), 1012 deletions(-) create mode 100644 .changeset/canonical-npm-root.md delete mode 100644 packages/agent-bundle/src/install-entry.ts delete mode 100644 packages/agent-bundle/tests/installer-entry.test.ts diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md new file mode 100644 index 000000000..c2fad1783 --- /dev/null +++ b/.changeset/canonical-npm-root.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#TBD). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index db2bdfa4c..cb501c9ac 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -854,12 +854,13 @@ exactly where `build` would refuse. ## Artifact-hosted routed CLI (`AB4765`–`AB4766`) -A generated-mode `src/cli/**` surface compiles into the npm package bin -(`dist/bin/.js`) **and** into every host artifact whose adapter +A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus `bin/-flight.mjs` when any command renders). Every built-in target hosts it; the two codes cover a target that does not and a host file -that claims the same path. See “The routed CLI shell” in +that claims the same path. The npm root copies the manifest-declared bin +unchanged and points `package.json` at it rather than recompiling the graph. +See “The routed CLI shell” in `docs/entry-conventions.md` for the layout and the sibling-path convention. | Code | Severity | Trigger | @@ -1100,8 +1101,8 @@ Conventional `src/cli/**` routes compile into one collision-checked command graph (#102 stages 2-3): the file path below the CLI root is the command nesting (`src/cli/library/audit.ts` runs as ` library audit`), the static `config` export supplies `description`, `aliases`, `positionals`, and -the `exitCode` policy, and the graph feeds one framework-generated package -executable named after the plugin (`dist/bin/.js`), replacing +the `exitCode` policy, and the graph feeds one framework-generated artifact +executable named after the plugin (`bin/.mjs`), replacing the `src/cli.ts` convention for that project. Every command route exports `inputSchema` and `resultSchema` zod schemas plus one async default function receiving `{ input, signal }`, and runs inside the typed Agent request @@ -1517,8 +1518,7 @@ consumes it as the recovery. `agent-bundle uninstall [--from ] [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] -[--force] [--plan] [--json]`, the package-relative installer bin's -`uninstall `, and the emitted `install.mjs --uninstall` are the +[--force] [--plan] [--json]` and the emitted `install.mjs --uninstall` are the receipt-owned reverse of `install` (#101; the maintainer's 2026-09-01 G4 deferral of mutation was reversed on 2026-09-03 with the request to fix every open issue). Every mutation is opt-in and bounded by the receipt: @@ -1874,7 +1874,7 @@ The `AB7000`–`AB7004` codes are shared by two families of emitters with distinct meanings. `agent-bundle install` and `agent-bundle uninstall` (`install/install.ts`, `install/uninstall.ts`, `install/cursor-marketplace.ts`) throw them as `DiagnosticError`s with `target` set to the host; the -`install-entry` CLI wraps any non-diagnostic failure as `AB7004`. The +installation command boundary wraps any non-diagnostic failure as `AB7004`. The development project service (`dev/project-service.ts`) and `inspectProject` emit them as **error** diagnostics on a failed preparation with `sourcePath` set to the config file and a fixed `recovery` ending in "then inspect again". @@ -1887,7 +1887,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose bytes differ from its `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the installation command boundary. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/docs/effect-conventions.md b/docs/effect-conventions.md index 2e1a2f2f4..b398912e4 100644 --- a/docs/effect-conventions.md +++ b/docs/effect-conventions.md @@ -646,8 +646,8 @@ Wiring rules: - **Protocol stdout stays raw.** MCP stdio JSON-RPC (`mcp-entry.ts`, `mcp run`), hook result JSON (`adapters/hook-contract.ts`), the emitted routed-CLI shell (`cli-entry.ts`'s `writeOut`/`writeErr` ports and the - `entry-shell.ts` bin template), generated installers (`install-entry.ts`, - `install/surface.ts`), and child/worker stderr forwarding keep their direct + `entry-shell.ts` bin template), installers (`install/surface.ts`), and + child/worker stderr forwarding keep their direct `process.stdout`/`process.stderr` adapters: emitted artifacts must not carry a platform runtime, and byte-exact protocol frames are not terminal text. - **The route-facing terminal capability is plain Node, not `Terminal`.** diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index ac2dae32b..e0cc62d97 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -13,25 +13,20 @@ rendering. `agent-bundle build` always emits the composite plugin root. When the project declares `bin`/`lib` (or provides them by convention), the CLI build also -produces the node-consumable package build under `dist/` — the outputs -`package.json` `bin` and `exports` point at: +produces the node-consumable npm root under `dist/`. It copies the validated +composite artifact there and adds `package.json`, standard package docs, and +package-only entries: | Config | Output | Notes | | --- | --- | --- | -| `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | +| `bin: { '': './src/cli.ts' }` | `dist/bin/.js` | Authored self-executing ESM bundle, `#!/usr/bin/env node` shebang, executable bit. | +| generated `src/cli/**` routes | `dist/bin/.mjs` | The manifest-declared artifact executable, copied byte-for-byte; `package.json` `bin` points directly at it. | | `lib: { entry: './src/index.ts', dts: true }` | `dist/.js` + `dist/**/*.d.ts` | Single-entry ESM profile, node target, es2022 syntax. | -- When package outputs are built inside the project and the plugin root - carries at least one of the `claude`, `codex`, or `cursor` projections, the - framework also emits one self-contained - package-relative installer. It is `dist/bin/.js` when that name - is free, otherwise `dist/bin/-install.js`; if both are occupied, - a numeric suffix (`-install-2`, `-install-3`, …) guarantees a free name. - Declare the matching `package.json` `bin` value. Its grammar is - `install [--scope ] [--json]`; help lists only built hosts. - The baked URL resolves the shipped plugin root from `import.meta.url`, - never the caller's working directory, and delegates to the same - `installBundle` implementation as `agent-bundle install`. +- The npm root is not a parallel plugin compilation and contains no nested + `artifact/` directory. Generated routed CLI commands, including `web`, run + from the one artifact executable; no package-relative installer wrapper is + generated. - `agent-bundle prepack [--root ] [--output ] [--json]` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the exact package/artifact inventory, manifest hashes, package bin @@ -825,8 +820,8 @@ The compiler statically projects `inputSchema` onto argv (the bounded grammar and every policy rule are documented in [Diagnostics](diagnostics.md#route-graph-state-layout-and-provider-conventions-ab4800ab4842-ab4940ab4942)), generates nested help (`--help` at every level, `--version` at the root), and emits -`dist/bin/.js` with the shebang and executable bit through the -same Rslib synthesis as every other bin. At run time the shell resolves the +`bin/.mjs` in the composite artifact with the shebang and +executable bit. At run time the shell resolves the command path, parses and coerces argv, validates through the module's own zod schemas, executes the default function inside the typed Agent request context (`invocation.kind: 'cli'`), writes one canonical JSON line to @@ -874,7 +869,7 @@ is 2 in every mode. A `.tsx` command route swaps the default function for an async default Server Component with the same `{ input, signal }` props and renders through the runtime dispatcher's public `stream()` against a sibling -`dist/bin/-flight.mjs` react-server worker (one warm worker per +`bin/-flight.mjs` react-server worker (one warm worker per invocation; raw Flight bytes never reach the terminal). The four output modes: an interactive TTY updates progress in place and prints the final document as Markdown; piped output emits exactly one final Markdown document @@ -886,11 +881,10 @@ machine output owns stdout. Rendered scripts (`src/scripts/.tsx`) share the same shell and output contract with `{ argv, signal }` component props and status-derived exit codes. -#### The routed CLI inside the plugin root +#### The routed CLI inside every distribution -The package bin only reaches users who install the npm package. Hooks, -skills, and script routes ship with the **plugin root**, so the build also -emits the same compiled command graph into that root whenever a selected host's +Hooks, skills, script routes, and npm consumers all use the **plugin root**, +so the build emits one compiled command graph there whenever a selected host's adapter publishes the `cli` capability — all built-in hosts do (`claude`, `codex`, `cursor`, `portable`) — because the artifact root is already a plain directory Node executes `mcp/` and `scripts/` files from. @@ -906,15 +900,11 @@ artifact/ mcp/… ``` -The artifact bin is a self-contained ESM module with no shebang or -executable bit — invoke it as `node /bin/.mjs -`, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, -exit codes, and signals are identical to the package bin. One deliberate -difference: workspace-durable state without a host-supplied -`AGENT_BUNDLE_PLUGIN_ROOT` anchors on the **artifact root** (the parent of -`bin/`, the same fallback the generated MCP worker beside it uses) rather -than `$PWD/.agent-bundle/state`, so a co-installed CLI and server observe -one store. The npm package bin keeps its `cwd` fallback. +The artifact bin is a self-contained executable ESM module with a Node +shebang. The npm root copies it unchanged and points `package.json` `bin` +directly at it, so help, argv parsing, output modes, exit codes, signals, and +the full command set are byte-for-byte the same. Both forms resolve plugin +code from the root above `bin/` and follow the same runtime state policy. Reaching the bin from the other surfaces: @@ -945,7 +935,8 @@ capability without that layout, or with a `cliBin` layout naming another directory or omitting `.mjs`, is rejected at registration. A target without the capability omits the bin and reports `AB4765`; a host-emitted file at the same path (a Claude `claude.bin` directory shipping `.mjs`) is -`AB4766`. The package build's `dist/bin/.js` is unchanged. +`AB4766`. The npm root copies this manifest-declared executable; it does not +compile another routed CLI. #### Project generated MCP tools into the CLI (power tier) @@ -1484,8 +1475,9 @@ treats a missing file as the normal case and an unreadable one as skipped. The dotenv grammar has no `${VAR}` interpolation. Under `mcp run` the plugin root is the project root, so the shell's pass is a no-op; `--env-file` and `--no-env` are handed down as `AGENT_BUNDLE_ENV_FILE` so the shell follows the -operator's choice. The npm package bin reads no pack file. Doctor reports the -presence and variable count of each file (`AB7331`). +operator's choice. Because the npm bin is this same artifact executable, it +reads the same pack files. Doctor reports the presence and variable count of +each file (`AB7331`). Two details keep the installed order equal to the `mcp run` table above: diff --git a/docs/framework-mode.md b/docs/framework-mode.md index f452e1741..e077283a8 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -271,11 +271,11 @@ the handwritten `runRscCli` compatibility path still serializes validated results and never renders JSX. Routed `src/cli/**` commands and `src/scripts/**` scripts follow one sentence: `.tsx` renders through the Agent renderer (TTY progress, piped Markdown, `--json`, `--ndjson`); `.ts` -is plain. The routed CLI ships twice from one build: as the npm package bin -(`dist/bin/.js`) for users who install the package, and as -`bin/.mjs` inside the plugin root so the plugin's own skills, -hooks, and scripts can run it with `node` from the installed plugin root -(see [Entry conventions](entry-conventions.md#the-routed-cli-inside-the-plugin-root)). +is plain. The routed CLI compiles once as `bin/.mjs` in the plugin root; +the npm root copies that proven executable unchanged and points its +`package.json` `bin` at it, so npm users and the plugin's own skills, hooks, +and scripts run the same command surface (see +[Entry conventions](entry-conventions.md#the-routed-cli-inside-every-distribution)). ## Release identity in source: `agent-bundle/meta` diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 30fbeb805..aa71ca3ee 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -291,15 +291,13 @@ content)` (`AB7308`), `version mismatch` (`AB7309`), `foreign install` (`AB7321`), `not installed` (`AB7307`), or `unknown` when the host inventory could not be read. -When package outputs ship one of those host packs, the build also emits a -package-relative installer bin. It uses the plugin name when no configured bin -claims it and `-install` otherwise. Map that name to the generated -`dist/bin/*.js` file in `package.json`; consumers run -` install [--scope ] [--replace|--force] [--json]` and -` uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`. -The executable locates the artifact directory beside the installed package, so -it works from `node_modules` regardless of the current directory. No npm -lifecycle performs an installation. +When package outputs ship one of those host packs, `dist/` becomes the npm +root containing that complete composite artifact. A generated routed CLI is +not compiled again: `package.json` points its `bin` at the manifest-declared +`bin/.mjs` copied unchanged from the validated artifact. Consumers +therefore get the same complete command surface, including `web`, whether they +run the artifact or the npm-installed bin. No npm lifecycle performs a host +installation. ### Uninstall by receipt diff --git a/packages/agent-bundle/fixtures/web-surface/package.json b/packages/agent-bundle/fixtures/web-surface/package.json index ffdeedac2..40a0a6131 100644 --- a/packages/agent-bundle/fixtures/web-surface/package.json +++ b/packages/agent-bundle/fixtures/web-surface/package.json @@ -2,6 +2,7 @@ "name": "web-surface-fixture", "private": true, "type": "module", + "version": "1.0.0", "devDependencies": { "@modelcontextprotocol/server": "2.0.0" } diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 8468fba9d..c46a5f707 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -151,7 +151,6 @@ export default defineConfig({ 'event-ipc': './src/events/ipc.ts', 'event-project': './src/events/project.ts', index: './src/index.ts', - 'install-entry': './src/install-entry.ts', 'launch-env': './src/launch-env.ts', 'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts', 'mcp-apps': './src/mcp-apps.ts', diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 5df201412..1fab7f5dd 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1321,7 +1321,7 @@ export const build = async (options: BuildOptions): Promise let packageBuild: PackageBuildResult | undefined; if (packageOutputRoot !== undefined) { packageBuild = await buildPackageOutputs({ - ...(isInsideOrEqual(prepared.root, output) ? { artifactRoot: output } : {}), + artifactRoot: output, model, projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), @@ -1395,7 +1395,7 @@ export const prepack = async (options: BuildOptions): Promise => }]); } const { stdout } = await execFile('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { - cwd: resolve(options.root), + cwd: result.packageBuild.outputRoot, }); const pack = packOutputFromJson(stdout); const diagnostics = await packInventoryDiagnostics({ diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index 3734023f1..63d954664 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, rm } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; import packageManifest from '../../package.json' with { type: 'json' }; @@ -20,7 +20,12 @@ import { type CompiledHookEntry, type CompiledMcpEntry, } from './entries.ts'; -import { planCliBinsSurface, planCompiledCliBins, type CompiledCliBin } from './cli-bins.ts'; +import { + cliBinExecutableMode, + planCliBinsSurface, + planCompiledCliBins, + type CompiledCliBin, +} from './cli-bins.ts'; import { composeProjections, type CompositePlan } from './compose.ts'; import { projectMeta } from './meta.ts'; import { @@ -770,6 +775,7 @@ export const build = async (options: BuildOptions): Promise => { } } } + await Promise.all(compiledCliBins.map((entry) => chmod(entry.output, cliBinExecutableMode))); const publishedCompiledEntries = deepFreeze(compiledEntries.map((entry) => ({ ...entry, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index e7960facf..7a4d933da 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -27,18 +27,18 @@ const webHostPageScript = await readWebHostPageScript(); /** * The artifact-hosted routed CLI (#387). A generated-mode `src/cli/**` - * surface already compiles into the npm package bin (`dist/bin/.js`); - * this module emits the same compiled command graph into every host artifact + * surface compiles here into every host artifact * whose adapter publishes the `cli` capability, as `bin/.mjs` beside * an optional `bin/-flight.mjs` react-server worker. The bin is a - * plain self-contained ESM module invoked as `node /bin/.mjs`, - * exactly like the artifact's `scripts/*.mjs`, so hooks, skills, and script + * self-contained executable ESM module, so hooks, skills, scripts, and the npm + * package bin * routes installed with the plugin can reach the routed CLI without a - * separate npm install. The package build's own bin emission is untouched. + * share one proven command surface without a second compilation. */ /** The one directory the compiler emits the routed CLI into; the registry pins every `cliBin` layout to it. */ export const cliBinDirectory: string = routedCliBinLayout.directory; +export const cliBinExecutableMode = 0o755; /** The artifact-relative executable path for one routed-CLI bin. */ export const cliBinArtifactPath = (name: string): string => `${cliBinDirectory}/${name}.mjs`; @@ -156,6 +156,7 @@ export const cliBinRslibEntries = ( [launchEnvRuntimeSpecifier]: launchEnvRuntimePath(), ...(entry.bin.web === true ? { [webHostRuntimeSpecifier]: webHostRuntimePath() } : {}), }, + banner: '#!/usr/bin/env node', name: `bin-${entry.name}`, virtualModules: [ operatorEnvLayerVirtualModule(), diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index f2d513adb..4c6df15a6 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -169,25 +169,6 @@ export const webHostRuntimeSpecifier = 'agent-bundle/web-host'; export const webHostRuntimePath = (): string => runtimeModulePath('web-host'); -export const installEntryRuntimeSpecifier = 'agent-bundle/install-entry'; - -export const installEntryRuntimePath = (): string => runtimeModulePath('install-entry'); - -export const generatedInstallBinEntrySource = (options: { - readonly artifactRelativeUrl: string; - readonly hosts: readonly ('claude' | 'codex' | 'cursor')[]; - readonly name: string; -}): string => [ - `import { runGeneratedInstallProcess } from ${JSON.stringify(installEntryRuntimeSpecifier)};`, - '', - 'process.exitCode = await runGeneratedInstallProcess(process.argv.slice(2), Object.freeze({', - ` artifactRoot: new URL(${JSON.stringify(options.artifactRelativeUrl)}, import.meta.url),`, - ` hosts: Object.freeze(${stableJson(options.hosts)}),`, - ` name: ${JSON.stringify(options.name)},`, - '}));', - '', -].join('\n'); - /** * Where workspace-durable state anchors when the host supplies no * `AGENT_BUNDLE_PLUGIN_ROOT`: `cwd` (the caller's `.agent-bundle/state`, the diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts index 134d7d366..4c3748efd 100644 --- a/packages/agent-bundle/src/build/launch-env-shell.ts +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -8,10 +8,10 @@ */ export const launchEnvRuntimeSpecifier = 'agent-bundle/launch-env'; -// This module is imported by the hook contract, which the installer bundle -// (`install-entry`) also carries and a consumer's own Rspack run re-bundles, -// so it must stay free of filesystem probing and `new URL(…, import.meta.url)`: -// `launchEnvRuntimePath` lives in `entry-shell.ts` beside the other paths. +// This module is imported by the hook contract and may be re-bundled by a +// consumer, so it stays free of filesystem probing and +// `new URL(…, import.meta.url)`: `launchEnvRuntimePath` lives in +// `entry-shell.ts` beside the other paths. /** * The generated module that applies the layer, served virtually to each diff --git a/packages/agent-bundle/src/build/pack-dependencies.ts b/packages/agent-bundle/src/build/pack-dependencies.ts index 3a9d07205..43286380a 100644 --- a/packages/agent-bundle/src/build/pack-dependencies.ts +++ b/packages/agent-bundle/src/build/pack-dependencies.ts @@ -1026,12 +1026,17 @@ const installScriptModuleDependencies = (options: { export const importedPackageNames = async (options: { /** Names to test for executable and install-script use; every other source is scanned whole. */ readonly declared: readonly string[]; + /** Root whose node_modules supplies dependency bin metadata while packing. */ + readonly dependencyRoot?: string; readonly packageDocument: Readonly>; readonly paths: readonly string[]; readonly projectRoot: string; }): Promise => { const projectRoot = resolve(options.projectRoot); - const executables = await executableCommands(options.declared, projectRoot); + const executables = await executableCommands( + options.declared, + resolve(options.dependencyRoot ?? projectRoot), + ); const evidenceByPath = new Map(await Promise.all(options.paths .filter((path) => javaScriptSuffix.test(path) || declarationSuffix.test(path)) .map(async (path) => [path, await fileEvidence(resolve(projectRoot, path), executables)] as const))); diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 93b58b37c..0c38a0ce9 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -1,5 +1,5 @@ import { lstat } from 'node:fs/promises'; -import { join, relative, resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import type { NormalizedPlugin } from '../core/types.ts'; import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; @@ -81,9 +81,6 @@ export const packOutputFromJson = (stdout: string, packageName?: string): PackOu return Object.freeze({ filename: entry.filename, files: Object.freeze(files) }); }; -const toPosixRelative = (root: string, path: string): string => - relative(resolve(root), resolve(path)).replaceAll('\\', '/'); - /** Stays on `lstat`: a dangling symlink at a host manifest path still counts as present. */ const exists = async (path: string): Promise => { try { @@ -149,6 +146,7 @@ const unresolvableRecovery = 'Depend on a published registry version, or bundle const dependencyDiagnostics = async (options: { readonly declaredRuntimeDependencies: ReadonlySet; + readonly dependencyRoot: string; readonly packageDocument: Readonly>; readonly packedPaths: readonly string[]; readonly packerRewritesWorkspaceProtocols: boolean; @@ -161,6 +159,7 @@ const dependencyDiagnostics = async (options: { // `require`/`createRequire`/`import.meta.resolve` evidence is not an import and may come from any packed file. const imported = await importedPackageNames({ declared: declared.filter((dependency) => dependency.installed).map((dependency) => dependency.name), + dependencyRoot: options.dependencyRoot, packageDocument: options.packageDocument, paths: options.packedPaths, projectRoot: options.projectRoot, @@ -242,21 +241,14 @@ export const packInventoryDiagnostics = async (options: { readonly packerRewritesWorkspaceProtocols: boolean; readonly projectRoot: string; }): Promise => { - const projectRoot = resolve(options.projectRoot); const artifactRoot = resolve(options.artifactRoot); - const artifactPrefix = toPosixRelative(projectRoot, artifactRoot); - const packagePrefix = toPosixRelative(projectRoot, options.packageBuild.outputRoot); + const packageRoot = resolve(options.packageBuild.outputRoot); const manifestPath = join(artifactRoot, artifactManifestName); const manifest = parseArtifactManifest(await runWithPlatform(readFileString(manifestPath))); - const packageDocument = await jsonRecord(join(projectRoot, 'package.json')); + const packageDocument = await jsonRecord(join(packageRoot, 'package.json')); const packed = new Set(options.packOutput.files.map((file) => file.path.replace(/^\.\//u, ''))); const expected = new Set([ - ...options.packageBuild.files.map((file) => `${packagePrefix}/${file.path}`), - `${artifactPrefix}/${artifactManifestName}`, - // Every emitted file is manifested, the install surface included: the - // artifact validator (`AB6023`/`AB6024`) already judged its presence by - // adapter identity, so the pack expects exactly what the manifest lists. - ...manifest.files.map((file) => `${artifactPrefix}/${file.path}`), + ...options.packageBuild.files.map((file) => file.path), 'README.md', ]); @@ -266,14 +258,14 @@ export const packInventoryDiagnostics = async (options: { diagnostics.push(diagnostic( 'AB7010', `npm pack omits expected files: ${quoteAll(missing)}.`, - 'Add the exact paths (including dist and the artifact directory) to the package.json "files" allowlist.', + 'Pack the generated npm root without excluding its files.', )); } const stale: string[] = []; for (const file of manifest.files) { const bytes = await runWithPlatform(readFileBytes(join(artifactRoot, file.path))); - if (sha256Hex(bytes) !== file.sha256) stale.push(`${artifactPrefix}/${file.path}`); + if (sha256Hex(bytes) !== file.sha256) stale.push(file.path); } if (stale.length > 0) { diagnostics.push(diagnostic( @@ -286,14 +278,14 @@ export const packInventoryDiagnostics = async (options: { const invalidBins = binEntries(packageDocument.bin) .filter(([, target]) => { const normalized = target.replace(/^\.\//u, ''); - return !normalized.startsWith(`${packagePrefix}/`) || normalized.startsWith('src/') || !packed.has(normalized); + return normalized.startsWith('src/') || !packed.has(normalized); }); if (invalidBins.length > 0) { diagnostics.push(diagnostic( 'AB7012', - `package.json bins must name packed dist outputs: ${invalidBins.map(([name, target]) => + `package.json bins must name files in the packed npm root: ${invalidBins.map(([name, target]) => `${JSON.stringify(name)} -> ${JSON.stringify(target)}`).join(', ')}.`, - 'Point every package.json bin value at its generated file under dist/bin and include that file in "files".', + 'Point routed CLIs at their manifest-declared bin/.mjs and authored bins at generated bin/*.js files.', )); } @@ -328,10 +320,11 @@ export const packInventoryDiagnostics = async (options: { diagnostics.push(...await dependencyDiagnostics({ declaredRuntimeDependencies: new Set((options.model.payloads ?? []).flatMap((payload) => payload.runtimeDependencies)), + dependencyRoot: resolve(options.projectRoot), packageDocument, packedPaths: [...packed], packerRewritesWorkspaceProtocols: options.packerRewritesWorkspaceProtocols, - projectRoot, + projectRoot: packageRoot, })); return deepFreeze(diagnostics.sort((left, right) => left.code.localeCompare(right.code))); diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index c61d34aaf..0ccf074bb 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -1,28 +1,31 @@ import { existsSync } from 'node:fs'; -import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; -import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; +import { + parseJsonWithoutDuplicateKeys, + snapshotStrictJsonValue, + type JsonValue, +} from '../core/strict-json.ts'; +import type { + AgentBundleToolsConfig, + NormalizedPackageBuild, + NormalizedPlugin, +} from '../core/types.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; -import { cliBinSourceInputs } from './cli-bins.ts'; import type { CompileResult } from './compile-result.ts'; import { buildWithRslib } from './compiler.ts'; import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts'; import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; import { - cliEntryRuntimePath, - cliEntryRuntimeSpecifier, - generatedCliBinEntrySource, generatedExecutableEntrySource, - generatedInstallBinEntrySource, - generatedRenderedRouteWorkerSource, - installEntryRuntimePath, - installEntryRuntimeSpecifier, terminalCapabilityRuntimePath, terminalCapabilityRuntimeSpecifier, } from './entry-shell.ts'; +import { readArtifactManifest } from './manifest-file.ts'; +import { artifactManifestName, type ArtifactManifest, type ArtifactManifestFileKind } from './manifest.ts'; import { projectMeta } from './meta.ts'; import { bundleSyntaxCheckFor } from './module-imports.ts'; import { isDeclarationGenerationFailure, type RslibEntry } from './rslib.ts'; @@ -30,13 +33,10 @@ import { runtimeIgnoredRoot } from './runtime-path.ts'; import { validateJavaScriptModules } from './validate-artifact-modules.ts'; /** - * The framework-owned npm package build: `bin` entries become self-executing - * `dist/bin/.js` bundles (shebang + executable bit) and the `lib` entry - * becomes `dist/.js` (+ a bundleless `.d.ts` declaration graph), all - * through the same Rslib synthesis, invariant assertions, staged atomic - * publication, and self-containment rule (`AB6005`) as artifact executables. - * This is the build audiobook-curator previously needed a second bundler - * config, a tsconfig, and a hand-written bin shim to produce. + * The framework-owned npm root: the proven artifact tree is copied unchanged, + * then authored package-only bins and the optional library entry are added. + * Routed CLI bins are never recompiled here; package.json points at the + * artifact executable recorded by agent-bundle.manifest.json. */ const binShebang = '#!/usr/bin/env node'; @@ -44,7 +44,7 @@ const executableMode = 0o755; export interface PackageOutputFile { readonly bytes: number; - readonly kind: 'bundle' | 'generated'; + readonly kind: ArtifactManifestFileKind; /** Present only for executable outputs. */ readonly mode?: number; /** POSIX path relative to the package output root. */ @@ -108,72 +108,12 @@ const synthesizeDtsTsconfig = async (options: { export const planPackageEntries = async ( model: NormalizedPlugin, dtsTsconfigPath: string | undefined, - options: { - readonly artifactRoot?: string; - readonly packageOutputRoot?: string; - } = {}, ): Promise => { const packageBuild = model.packageBuild; if (packageBuild === undefined) return Object.freeze([]); const entries: PlannedPackageEntry[] = []; for (const bin of packageBuild.bins) { - if (bin.generatedCli !== undefined) { - // A routed-CLI bin compiles the framework-generated command program; - // the cli-entry runtime shell is aliased in so the emitted executable - // stays self-contained, exactly like generated stdio MCP entries. - // Rendered commands add one sibling react-server Flight worker. - const rendered = bin.generatedCli.commands.some((command) => command.rendered); - const workerFile = `${bin.name}-flight.mjs`; - const sourceInputs = cliBinSourceInputs(model, bin); - const generatedCli = bin.generatedCli; - entries.push({ - aliases: { [cliEntryRuntimeSpecifier]: cliEntryRuntimePath() }, - banner: binShebang, - executable: true, - name: `bin-${bin.name}`, - outputRelativePath: `bin/${bin.name}.js`, - ...(rendered ? { rscManifest: true as const } : {}), - source: bin.source, - sourceInputs, - virtualSource: generatedCliBinEntrySource({ - commands: generatedCli.commands, - plugin: { - ...(model.metadata.description === undefined ? {} : { description: model.metadata.description }), - name: model.metadata.name, - version: model.metadata.version, - }, - ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), - providers: model.providers ?? [], - ...(generatedCli.projectionSources === undefined - ? {} - : { projectionSources: generatedCli.projectionSources }), - routes: generatedCli.routes, - ...(model.state === undefined ? {} : { state: model.state }), - ...(rendered ? { workerFile } : {}), - }), - }); - if (rendered) { - const renderedRoutes = bin.generatedCli.routes.filter((route) => - bin.generatedCli!.commands.some((command) => command.rendered && command.routeId === route.id)); - entries.push({ - executable: false, - name: `bin-${bin.name}-flight`, - outputRelativePath: `bin/${workerFile}`, - reactServer: true, - rscManifest: true, - source: bin.source, - sourceInputs, - virtualSource: generatedRenderedRouteWorkerSource({ - layouts: model.layouts ?? [], - ...(model.notices === undefined ? {} : { noticeRetention: model.notices.retention.resolved }), - providers: model.providers ?? [], - routes: renderedRoutes, - ...(model.state === undefined ? {} : { state: model.state }), - }), - }); - } - continue; - } + if (bin.generatedCli !== undefined) continue; // A bin entry exporting `main` (or a default function) receives the // generated process envelope; a self-executing module bundles directly. const exports = await scanEntryExports(bin.source); @@ -195,42 +135,6 @@ export const planPackageEntries = async ( }), }); } - const installHosts = Object.freeze((['claude', 'codex', 'cursor'] as const) - .filter((host) => model.targets.some((target) => target.name === host))); - if ( - installHosts.length > 0 && - options.artifactRoot !== undefined && - options.packageOutputRoot !== undefined - ) { - const occupiedNames = new Set(packageBuild.bins.map((bin) => bin.name)); - let name = model.metadata.name; - if (occupiedNames.has(name)) { - name = `${model.metadata.name}-install`; - let suffix = 2; - while (occupiedNames.has(name)) { - name = `${model.metadata.name}-install-${String(suffix)}`; - suffix += 1; - } - } - const outputRelativePath = `bin/${name}.js`; - const emittedBinDirectory = dirname(resolve(options.packageOutputRoot, outputRelativePath)); - const relativeArtifact = toPosixRelative(emittedBinDirectory, options.artifactRoot); - const source = packageBuild.bins[0]?.source ?? packageBuild.lib!.source; - entries.push({ - aliases: { [installEntryRuntimeSpecifier]: installEntryRuntimePath() }, - banner: binShebang, - executable: true, - name: `bin-${name}`, - outputRelativePath, - source, - sourceInputs: Object.freeze([model.metadata.provenance.sourcePath, source]), - virtualSource: generatedInstallBinEntrySource({ - artifactRelativeUrl: relativeArtifact === '' ? './' : `${relativeArtifact}/`, - hosts: installHosts, - name, - }), - }); - } if (packageBuild.lib !== undefined) { const lib = packageBuild.lib; entries.push({ @@ -284,8 +188,84 @@ const declarationSource = (sourceDir: string, declarationPath: string): string | return undefined; }; +const packagePath = (outputDir: string, value: JsonValue): JsonValue => { + if (typeof value === 'string') { + const directory = outputDir.replaceAll('\\', '/'); + const explicitPrefix = `./${directory}/`; + if (value.startsWith(explicitPrefix)) return `./${value.slice(explicitPrefix.length)}`; + const prefix = `${directory}/`; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; + } + if (Array.isArray(value)) return value.map((entry) => packagePath(outputDir, entry)); + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, packagePath(outputDir, entry)])); + } + return value; +}; + +const packageDocument = async ( + projectRoot: string, + packageBuild: NormalizedPackageBuild, + manifest: ArtifactManifest, +): Promise>> => { + const source = snapshotStrictJsonValue( + parseJsonWithoutDuplicateKeys(await readFile(join(projectRoot, 'package.json'), 'utf8')), + ); + if (source === null || Array.isArray(source) || typeof source !== 'object') { + throw new Error('package.json must contain a JSON object.'); + } + const bins = Object.fromEntries(packageBuild.bins.map((bin) => { + if (bin.generatedCli === undefined) return [bin.name, `./bin/${bin.name}.js`]; + const executable = manifest.executables.bins.find((entry) => entry.name === bin.name); + if (executable === undefined) { + throw new Error(`Artifact manifest does not declare routed CLI executable ${JSON.stringify(bin.name)}.`); + } + return [bin.name, `./${executable.path}`]; + })); + const transformed = Object.fromEntries(Object.entries(source) + .filter(([key]) => key !== 'files' && key !== 'bin') + .map(([key, value]) => [ + key, + ['exports', 'main', 'module', 'types', 'typesVersions'].includes(key) + ? packagePath(packageBuild.outputDir, value) + : value, + ])); + return Object.freeze({ + ...transformed, + ...(Object.keys(bins).length === 0 ? {} : { bin: bins }), + }); +}; + +const copyArtifactRoot = async ( + artifactRoot: string, + stageRoot: string, + manifest: ArtifactManifest, +): Promise => { + for (const file of [...manifest.files, { path: artifactManifestName }]) { + const destination = resolveArtifactDestination(stageRoot, file.path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolveArtifactDestination(artifactRoot, file.path), destination); + } +}; + +const copyStandardPackageFiles = async ( + projectRoot: string, + stageRoot: string, +): Promise> => { + const sources = new Map(); + for (const name of ['README.md', 'LICENSE', 'LICENSE.md', 'NOTICE', 'NOTICE.md']) { + const source = join(projectRoot, name); + if (!existsSync(source)) continue; + const destination = join(stageRoot, name); + if (existsSync(destination)) throw new Error(`Package metadata collides with artifact file ${JSON.stringify(name)}.`); + await copyFile(source, destination); + sources.set(name, source); + } + return sources; +}; + export const buildPackageOutputs = async (options: { - readonly artifactRoot?: string; + readonly artifactRoot: string; readonly model: NormalizedPlugin; readonly projectRoot: string; readonly tools?: AgentBundleToolsConfig; @@ -293,72 +273,107 @@ export const buildPackageOutputs = async (options: { const packageBuild = options.model.packageBuild; if (packageBuild === undefined) return undefined; const projectRoot = resolve(options.projectRoot); + const artifactRoot = resolve(options.artifactRoot); + const manifestRead = await readArtifactManifest(artifactRoot); + if (manifestRead.status !== 'ok') { + throw new Error(`Cannot build npm root from ${JSON.stringify(manifestRead.path)}: ${manifestRead.status}.`); + } + const manifest = manifestRead.manifest; const outputRoot = assertInside(projectRoot, resolve(projectRoot, packageBuild.outputDir)); const libSourceDir = packageBuild.lib === undefined ? undefined : dirname(packageBuild.lib.source); const dtsTsconfig = packageBuild.lib?.dts === true && libSourceDir !== undefined ? await synthesizeDtsTsconfig({ projectRoot, sourceDir: libSourceDir }) : undefined; - const entries = await planPackageEntries(options.model, dtsTsconfig?.path, { - ...(options.artifactRoot === undefined ? {} : { artifactRoot: resolve(options.artifactRoot) }), - packageOutputRoot: outputRoot, - }); - if (entries.length === 0) { - await dtsTsconfig?.cleanup(); - return undefined; - } + const entries = await planPackageEntries(options.model, dtsTsconfig?.path); const stageParent = dirname(outputRoot); await mkdir(stageParent, { recursive: true }); const stageRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.stage-`)); + const compileRoot = await mkdtemp(join(stageParent, `.${basename(outputRoot)}.compile-`)); try { const ignoredRuntimeRoots = Object.freeze([...new Set([ - ...(entries.some((entry) => entry.aliases?.[cliEntryRuntimeSpecifier] !== undefined) - ? [runtimeIgnoredRoot(cliEntryRuntimePath())] - : []), - ...(entries.some((entry) => entry.aliases?.[installEntryRuntimeSpecifier] !== undefined) - ? [runtimeIgnoredRoot(installEntryRuntimePath())] - : []), ...(entries.some((entry) => entry.aliases?.[terminalCapabilityRuntimeSpecifier] !== undefined) ? [runtimeIgnoredRoot(terminalCapabilityRuntimePath())] : []), ])]); - const evidence = await buildPackageEntries({ - cwd: projectRoot, - diagnosticPathPrefix: toPosixRelative(projectRoot, outputRoot), - entries, - ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), - logLevel: 'error', - meta: projectMeta(options.model.metadata), - outputRoot: stageRoot, - ...(options.tools === undefined ? {} : { tools: options.tools }), - }, dtsTsconfig === undefined || packageBuild.lib === undefined - ? undefined - : { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path }); + const evidence = entries.length === 0 + ? { assets: Object.freeze([]), diagnostics: Object.freeze([]) } + : await buildPackageEntries({ + cwd: projectRoot, + diagnosticPathPrefix: toPosixRelative(projectRoot, outputRoot), + entries, + ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), + logLevel: 'error', + meta: projectMeta(options.model.metadata), + outputRoot: compileRoot, + ...(options.tools === undefined ? {} : { tools: options.tools }), + }, dtsTsconfig === undefined || packageBuild.lib === undefined + ? undefined + : { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path }); const evidenceByPath = new Map(evidence.assets.map((entry) => [entry.path, entry.sourceInputs])); await Promise.all(entries .filter((entry) => entry.executable) - .map((entry) => chmod(resolveArtifactDestination(stageRoot, entry.outputRelativePath), executableMode))); + .map((entry) => chmod(resolveArtifactDestination(compileRoot, entry.outputRelativePath), executableMode))); + + await copyArtifactRoot(artifactRoot, stageRoot, manifest); + for (const file of await listArtifactFiles(compileRoot)) { + const destination = resolveArtifactDestination(stageRoot, file.path); + if (existsSync(destination)) throw new Error(`Package output collides with artifact file ${JSON.stringify(file.path)}.`); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(resolveArtifactDestination(compileRoot, file.path), destination); + } + const packageJsonSource = join(projectRoot, 'package.json'); + if (existsSync(join(stageRoot, 'package.json'))) { + throw new Error('Package metadata collides with artifact file "package.json".'); + } + await writeFile( + join(stageRoot, 'package.json'), + `${JSON.stringify(await packageDocument(projectRoot, packageBuild, manifest), null, 2)}\n`, + 'utf8', + ); + const packageSources = await copyStandardPackageFiles(projectRoot, stageRoot); + packageSources.set('package.json', packageJsonSource); const lib = packageBuild.lib; + const provenanceByPath = new Map(manifest.compiler.provenance.map((entry) => [entry.path, entry.sourceInputs])); + const artifactByPath = new Map(manifest.files.map((entry) => [entry.path, entry])); const staged = await listArtifactFiles(stageRoot); const files = staged.map((file): PackageOutputFile => { const bundled = evidenceByPath.get(file.path); + const artifact = artifactByPath.get(file.path); + const packageSource = packageSources.get(file.path); const declared = lib?.dts === true && libSourceDir !== undefined && file.path.endsWith('.d.ts') ? declarationSource(libSourceDir, file.path) ?? lib.source : undefined; - if (bundled === undefined && declared === undefined) { + if ( + bundled === undefined && + declared === undefined && + artifact === undefined && + packageSource === undefined && + file.path !== artifactManifestName + ) { throw new Error(`Package build emitted unexpected output ${JSON.stringify(file.path)}.`); } + let generatedSourceInputs: readonly string[]; + if (bundled !== undefined) { + generatedSourceInputs = bundled; + } else if (declared !== undefined) { + if (lib === undefined) { + throw new Error(`Declaration output has no package library entry: ${JSON.stringify(file.path)}.`); + } + generatedSourceInputs = [lib.provenance.sourcePath, declared]; + } else { + generatedSourceInputs = [packageSource ?? options.model.metadata.provenance.sourcePath]; + } return { bytes: file.bytes, - kind: bundled === undefined ? 'generated' : 'bundle', + kind: artifact?.kind ?? (bundled === undefined ? 'generated' : 'bundle'), ...((file.mode & 0o111) === 0 ? {} : { mode: file.mode }), path: file.path, sha256: file.sha256, - sourceInputs: relativeSourceInputs( - projectRoot, - bundled ?? [lib!.provenance.sourcePath, declared!], - ), + sourceInputs: artifact === undefined + ? relativeSourceInputs(projectRoot, generatedSourceInputs) + : provenanceByPath.get(file.path) ?? Object.freeze([]), }; }).sort((left, right) => left.path.localeCompare(right.path)); for (const entry of entries) { @@ -385,7 +400,7 @@ export const buildPackageOutputs = async (options: { artifactRoot: stageRoot, bundledPaths: new Set(files.filter((file) => file.kind === 'bundle').map((file) => file.path)), bundleSyntaxCheck: bundleSyntaxCheckFor(options.tools), - files: staged, + files: staged.filter((file) => !artifactByPath.has(file.path)), reportedRoot: toPosixRelative(projectRoot, outputRoot), validJson: new Set(), }); @@ -396,6 +411,7 @@ export const buildPackageOutputs = async (options: { } finally { // publishArtifact removes the stage on success; a failed build leaves it. await rm(stageRoot, { force: true, recursive: true }); + await rm(compileRoot, { force: true, recursive: true }); await dtsTsconfig?.cleanup(); } }; diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index cf4b92038..deca1d4d7 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -1,5 +1,5 @@ import { Cause, Deferred, Effect, Exit, Result, Semaphore } from 'effect'; -import { resolve } from 'node:path'; +import { dirname, resolve } from 'node:path'; import { freezeDiagnostics, hasErrors } from '../core/diagnostics.ts'; import { runPromise, runSync } from '../effect/boundary.ts'; @@ -552,7 +552,8 @@ export class DevCoordinator { // after the artifact epoch committed: its failure never invalidates the // epoch and surfaces as warning diagnostics on the succeeded attempt. const packageDiagnostics = result.outcome === 'succeeded' - ? (yield* liftPromise(() => this.#packageBuildService.build(prepared, invalidation))).diagnostics + ? (yield* liftPromise(() => + this.#packageBuildService.build(prepared, invalidation, dirname(result.epoch.manifestPath)))).diagnostics : Object.freeze([]); const diagnostics = freezeDiagnostics([...lintDiagnostics, ...result.diagnostics, ...packageDiagnostics]); if (result.outcome === 'succeeded') { diff --git a/packages/agent-bundle/src/dev/package-build-service.ts b/packages/agent-bundle/src/dev/package-build-service.ts index e88a7ace3..817a13743 100644 --- a/packages/agent-bundle/src/dev/package-build-service.ts +++ b/packages/agent-bundle/src/dev/package-build-service.ts @@ -45,7 +45,11 @@ export interface DevPackageBuildOutcome { } export interface DevPackageBuilder { - build(prepared: PreparedProject, invalidation: Invalidation): Promise; + build( + prepared: PreparedProject, + invalidation: Invalidation, + artifactRoot?: string, + ): Promise; } export interface DevPackageBuildServiceOptions { @@ -106,7 +110,11 @@ export class DevPackageBuildService implements DevPackageBuilder { this.#run = platformRunOf(options.platformRuntime); } - async build(prepared: PreparedProject, invalidation: Invalidation): Promise { + async build( + prepared: PreparedProject, + invalidation: Invalidation, + artifactRoot = join(prepared.root, prepared.artifactDistPath), + ): Promise { const model = prepared.model; if (model?.packageBuild === undefined) { this.#last = undefined; @@ -121,6 +129,7 @@ export class DevPackageBuildService implements DevPackageBuilder { } try { const result = await this.#buildOutputs({ + artifactRoot, model, projectRoot: prepared.root, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts deleted file mode 100644 index 759db515d..000000000 --- a/packages/agent-bundle/src/install-entry.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { lstat } from 'node:fs/promises'; -import { fileURLToPath } from 'node:url'; - -import { stableJson } from './core/digest.ts'; -import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; -import { formatInstallResult, formatUninstallResult } from './install/format.ts'; -import { - installBundle, - type InstallHost, - type InstallMode, - type InstallResult, - type InstallScope, -} from './install/install.ts'; -import { uninstallBundle, type UninstallResult } from './install/uninstall.ts'; - -export interface GeneratedInstallProcessOptions { - readonly artifactRoot: string | URL; - readonly hosts: readonly InstallHost[]; - readonly name: string; -} - -const usage = (options: GeneratedInstallProcessOptions): string => [ - `Usage: ${options.name} install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]`, - ` ${options.name} uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]`, - '', - `Built hosts: ${options.hosts.join(', ')}`, - '', - '--replace (alias --force) replaces an existing agent-bundle install of this plugin even when', - 'its version differs. Same-version content drift is replaced automatically; foreign installs', - 'are always refused.', - '', - 'uninstall removes exactly what the install receipt owns (files, directories, host registrations)', - 'and keeps durable runtime state unless --purge-data --confirm-purge is passed. It refuses a', - 'missing receipt or an owned-content mismatch unless --force; foreign directories are always', - 'refused. --plan prints the exact paths without changing anything.', - '', -].join('\n'); - -const diagnosticsFor = (error: unknown): readonly Diagnostic[] => - error instanceof DiagnosticError - ? error.diagnostics - : Object.freeze([Object.freeze({ - code: 'AB7004', - message: error instanceof Error ? error.message : String(error), - severity: 'error' as const, - })]); - -const isHost = (value: string): value is InstallHost => - value === 'claude' || value === 'codex' || value === 'cursor'; - -const isScope = (value: string): value is InstallScope => - value === 'local' || value === 'project' || value === 'user'; - -const isMode = (value: string): value is InstallMode => - value === 'local' || value === 'marketplace'; - -type InstallerVerb = 'install' | 'uninstall'; - -interface ParsedInstallArguments { - readonly confirmPurge: boolean; - readonly force: boolean; - readonly host: InstallHost; - readonly json: boolean; - readonly keepData: boolean; - readonly mode?: InstallMode; - readonly plan: boolean; - readonly purgeData: boolean; - readonly replace: boolean; - readonly scope: InstallScope; - readonly verb: InstallerVerb; -} - -const parseArguments = ( - argv: readonly string[], - options: GeneratedInstallProcessOptions, -): ParsedInstallArguments => { - const verb = argv[0]; - if (verb !== 'install' && verb !== 'uninstall') { - throw new TypeError(`Expected "install " or "uninstall "; built hosts: ${options.hosts.join(', ')}.`); - } - const candidate = argv[1]; - if (candidate === undefined || !isHost(candidate) || !options.hosts.includes(candidate)) { - throw new TypeError( - `Cannot ${verb} host ${JSON.stringify(candidate ?? '')}; built hosts: ${options.hosts.join(', ')}.`, - ); - } - let json = false; - let replace = false; - let force = false; - let keepData = false; - let purgeData = false; - let confirmPurge = false; - let plan = false; - let scope: InstallScope = 'user'; - let mode: InstallMode | undefined; - for (let index = 2; index < argv.length; index += 1) { - const argument = argv[index]; - if (argument === '--json') { - json = true; - continue; - } - if (argument === '--replace' && verb === 'install') { - replace = true; - continue; - } - if (argument === '--force') { - // `install --force` is the --replace alias; `uninstall --force` overrides a missing or mismatched receipt. - replace = true; - force = true; - continue; - } - if (argument === '--keep-data' && verb === 'uninstall') { - keepData = true; - continue; - } - if (argument === '--purge-data' && verb === 'uninstall') { - purgeData = true; - continue; - } - if (argument === '--confirm-purge' && verb === 'uninstall') { - confirmPurge = true; - continue; - } - if (argument === '--plan' && verb === 'uninstall') { - plan = true; - continue; - } - if (argument === '--mode') { - const value = argv[index + 1]; - if (value === undefined || !isMode(value)) { - throw new TypeError('Install mode must be local or marketplace.'); - } - mode = value; - index += 1; - continue; - } - if (argument === '--scope') { - const value = argv[index + 1]; - if (value === undefined || !isScope(value)) { - throw new TypeError('Install scope must be user, project, or local.'); - } - scope = value; - index += 1; - continue; - } - throw new TypeError(`Unknown installer argument ${JSON.stringify(argument)}.`); - } - return Object.freeze({ - confirmPurge, - force, - host: candidate, - json, - keepData, - ...(mode === undefined ? {} : { mode }), - plan, - purgeData, - replace, - scope, - verb, - }); -}; - -const writeResult = (parsed: ParsedInstallArguments, result: InstallResult | UninstallResult, human: string): void => { - process.stdout.write(parsed.json ? `${stableJson(result)}\n` : human); -}; - -export const runGeneratedInstallProcess = async ( - argv: readonly string[], - options: GeneratedInstallProcessOptions, -): Promise => { - if (argv.length === 0 || argv.includes('--help')) { - process.stdout.write(usage(options)); - return 0; - } - let parsed: ParsedInstallArguments | undefined; - try { - parsed = parseArguments(argv, options); - const artifactRoot = options.artifactRoot instanceof URL - ? fileURLToPath(options.artifactRoot) - : options.artifactRoot; - const metadata = await lstat(artifactRoot).catch(() => undefined); - if (metadata === undefined || !metadata.isDirectory()) { - throw new Error( - `Package artifact root is missing at ${JSON.stringify(artifactRoot)}; ` + - 'the package must ship its generated artifact directory.', - ); - } - switch (parsed.verb) { - case 'install': { - const result = await installBundle({ - from: artifactRoot, - host: parsed.host, - replace: parsed.replace, - ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), - scope: parsed.scope, - }); - writeResult(parsed, result, formatInstallResult(result)); - break; - } - case 'uninstall': { - const result = await uninstallBundle({ - confirmPurge: parsed.confirmPurge, - force: parsed.force, - from: artifactRoot, - host: parsed.host, - keepData: parsed.keepData, - ...(parsed.mode === undefined ? {} : { mode: parsed.mode }), - plan: parsed.plan, - purgeData: parsed.purgeData, - scope: parsed.scope, - }); - writeResult(parsed, result, formatUninstallResult(result)); - break; - } - default: { - const exhaustive: never = parsed.verb; - throw new TypeError(`Unknown installer verb ${String(exhaustive)}.`); - } - } - return 0; - } catch (error) { - if (parsed?.json === true) { - process.stderr.write(`${stableJson(diagnosticsFor(error))}\n`); - } else { - const diagnostics = diagnosticsFor(error); - process.stderr.write(`${diagnostics.map((entry) => `${entry.code}: ${entry.message}`).join('\n')}\n`); - } - return 1; - } -}; diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 82df9925b..0469a2940 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -257,13 +257,13 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 expect(result.model.packageBuild?.bins).toMatchObject([ { name: 'cli-bin-fixture', provenance: { kind: 'conventional' } }, ]); - const binPath = join(root, 'dist', 'bin', 'cli-bin-fixture.js'); + const binPath = join(root, 'dist', 'bin', 'cli-bin-fixture.mjs'); const binSource = await readFile(binPath, 'utf8'); expect(binSource.startsWith('#!/usr/bin/env node\n')).toBe(true); expect(binSource).not.toMatch(/from\s*['"]agent-bundle\/cli-entry['"]/u); expect((await stat(binPath)).mode & 0o111).not.toBe(0); // The emitted executable's provenance names every command route module. - const binEvidence = result.packageBuild!.files.find((file) => file.path === 'bin/cli-bin-fixture.js'); + const binEvidence = result.packageBuild!.files.find((file) => file.path === 'bin/cli-bin-fixture.mjs'); expect(binEvidence?.sourceInputs).toEqual(expect.arrayContaining([ 'src/cli/doctor.ts', 'src/cli/library/audit.ts', @@ -502,7 +502,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () beforeAll(async () => { // `process.cwd()` in the child is the resolved path; the fixture compares against it. root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-cli-projection-'))); - binPath = join(root, 'dist', 'bin', 'cli-projection-fixture.js'); + binPath = join(root, 'dist', 'bin', 'cli-projection-fixture.mjs'); await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(root, 'node_modules'), 'dir'); await Promise.all([ writeProjectFile(root, 'package.json', JSON.stringify({ @@ -620,7 +620,7 @@ describe('the CLI surface projection in the generated routed-CLI executable', () { name: 'cli-projection-fixture', provenance: { kind: 'conventional' } }, ]); await expect(stat(binPath)).resolves.toMatchObject({}); - const evidence = built.packageBuild!.files.find((file) => file.path === 'bin/cli-projection-fixture.js'); + const evidence = built.packageBuild!.files.find((file) => file.path === 'bin/cli-projection-fixture.mjs'); expect(evidence?.sourceInputs).toEqual(expect.arrayContaining([ 'src/mcp/demo/tools/ping.tsx', 'src/mcp/demo/tools/purge.cli.ts', diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 396e24448..f5ea85ef4 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -361,17 +361,6 @@ describe('generated entry templates', () => { expect(path.endsWith('terminal-capability.ts') || path.endsWith('terminal-capability.js')).toBe(true); }); - it('defers installer filesystem URL conversion to the guarded runtime', () => { - const source = entryShellModule.generatedInstallBinEntrySource({ - artifactRelativeUrl: '../../artifact/', - hosts: ['cursor'], - name: 'installer', - }); - - expect(source).toContain('artifactRoot: new URL("../../artifact/", import.meta.url)'); - expect(source).not.toContain('fileURLToPath'); - }); - it('routes a rejected progress report into the generated request failure path', async () => { const generated = generatedRenderedScriptEntrySource({ name: 'report', diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts deleted file mode 100644 index ff4134c86..000000000 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { execFile as executeFile } from 'node:child_process'; -import { mkdir, mkdtemp, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; - -import { afterEach, expect, it } from '@rstest/core'; - -import { build } from '../src/api.ts'; -import { runDoctor } from '../src/install/doctor.ts'; -import { installReceiptFile } from '../src/install/receipt.ts'; -import { installBundle } from '../src/install/install.ts'; - -const execFile = promisify(executeFile); -const roots: string[] = []; -const workspaceNodeModules = join(process.cwd(), 'node_modules'); - -afterEach(async () => { - await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); -}); - -const fixture = async (options: { - readonly author?: string; - readonly bin?: false | readonly string[]; - readonly target: 'cursor' | 'portable' | readonly ('claude' | 'codex' | 'cursor')[]; -}): Promise => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-installer-entry-')); - roots.push(root); - await mkdir(join(root, 'src'), { recursive: true }); - await symlink(workspaceNodeModules, join(root, 'node_modules'), 'dir'); - await Promise.all([ - writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installer-fixture', - type: 'module', - version: '1.2.3', - })), - writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - ...(options.bin === undefined - ? [] - : options.bin === false - ? [' bin: false,'] - : [` bin: { ${options.bin.map((name) => `${JSON.stringify(name)}: './src/cli.ts'`).join(', ')} },`]), - ...(options.author === undefined ? [] : [` cursor: { author: { name: ${JSON.stringify(options.author)} } },`]), - " lib: './src/index.ts',", - " plugin: { name: 'installer-fixture' },", - ` targets: ${JSON.stringify(typeof options.target === 'string' ? [options.target] : options.target)},`, - '};', - '', - ].join('\n')), - writeFile(join(root, 'src', 'cli.ts'), 'export const main = async () => 0;\n'), - writeFile(join(root, 'src', 'index.ts'), 'export const value = 1;\n'), - ]); - return root; -}; - -const run = async ( - executable: string, - args: readonly string[], - options: { readonly cwd: string; readonly env?: NodeJS.ProcessEnv }, -): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { - try { - const result = await execFile(executable, [...args], options); - return { code: 0, stderr: result.stderr, stdout: result.stdout }; - } catch (error) { - const failure = error as { readonly code?: number; readonly stderr?: string; readonly stdout?: string }; - return { - code: typeof failure.code === 'number' ? failure.code : 1, - stderr: failure.stderr ?? '', - stdout: failure.stdout ?? '', - }; - } -}; - -it('builds a package-relative installer with fallback naming and built-host argv validation', async () => { - const root = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); - const result = await build({ - output: 'nested/non-default-host-packs', - packageOutputs: true, - root, - }); - const installer = join(root, 'dist', 'bin', 'installer-fixture-install.js'); - - expect(result.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture-install.js'); - expect((await stat(installer)).mode & 0o111).not.toBe(0); - expect(await readFile(installer, 'utf8')).not.toMatch(/from\s*['"]agent-bundle/u); - - const help = await run(installer, [], { cwd: tmpdir() }); - expect(help).toMatchObject({ code: 0, stderr: '' }); - expect(help.stdout).toContain('install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]'); - expect(help.stdout).toContain('uninstall [--scope ] [--mode local|marketplace] [--keep-data | --purge-data --confirm-purge] [--force] [--plan] [--json]'); - expect(help.stdout).toContain('cursor'); - expect(help.stdout).not.toContain('claude'); - - const rejected = await run(installer, ['install', 'claude'], { cwd: tmpdir() }); - expect(rejected.code).toBe(1); - expect(rejected.stderr).toContain('claude'); - expect(rejected.stderr).toContain('cursor'); - - const artifactRoot = join(root, 'nested', 'non-default-host-packs'); - const hiddenArtifact = `${artifactRoot}-hidden`; - await rename(artifactRoot, hiddenArtifact); - const missing = await run(installer, ['install', 'cursor'], { cwd: tmpdir() }); - expect(missing.code).toBe(1); - expect(missing.stderr).toContain('Package artifact root is missing'); - expect(missing.stderr).toContain('must ship its generated artifact directory'); - await rename(hiddenArtifact, artifactRoot); - - const home = join(root, 'home'); - await mkdir(join(home, '.cursor'), { recursive: true }); - const installed = await run(installer, ['install', 'cursor', '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(installed).toMatchObject({ code: 0, stderr: '' }); - const installedDocument = JSON.parse(installed.stdout) as { readonly contentHash?: string }; - expect(installedDocument).toMatchObject({ - contentHash: expect.stringMatching(/^[0-9a-f]{64}$/u), - host: 'cursor', - plugin: 'installer-fixture', - state: 'installed', - version: '1.2.3', - }); - const destination = join(home, '.cursor', 'plugins', 'local', 'installer-fixture'); - await expect(stat(destination)).resolves.toBeDefined(); - await expect(stat(join(destination, installReceiptFile))).resolves.toBeDefined(); - - // Both replace spellings parse; an identical artifact stays a no-op even when forced. - for (const flag of ['--replace', '--force']) { - const noop = await run(installer, ['install', 'cursor', flag, '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(noop, flag).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(noop.stdout), flag).toMatchObject({ contentHash: installedDocument.contentHash, state: 'already-installed' }); - } - - // Same-version content drift of the receipt-managed copy is replaced without a flag. - await writeFile(join(destination, 'INSTALL.md'), '# stale\n'); - const replaced = await run(installer, ['install', 'cursor'], { cwd: tmpdir(), env: { ...process.env, HOME: home } }); - expect(replaced).toMatchObject({ code: 0, stderr: '' }); - expect(replaced.stdout).toMatch(/^Replaced installer-fixture@1\.2\.3 for cursor \(local mode\) at .* \(content [0-9a-f]{12} -> [0-9a-f]{12}\)\n$/u); - - const unknown = await run(installer, ['install', 'cursor', '--overwrite'], { cwd: tmpdir() }); - expect(unknown.code).toBe(1); - expect(unknown.stderr).toContain('Unknown installer argument "--overwrite"'); - - const staged = await run(installer, ['install', 'cursor', '--mode', 'marketplace', '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(staged).toMatchObject({ code: 0, stderr: '' }); - const repository = join(home, '.cursor', 'agent-bundle', 'marketplaces', 'installer-fixture'); - expect(JSON.parse(staged.stdout)).toMatchObject({ - commit: expect.stringMatching(/^[0-9a-f]{40}$/u), - destination: repository, - marketplace: 'installer-fixture-marketplace', - mode: 'marketplace', - state: 'staged', - }); - await expect(stat(join(repository, '.cursor-plugin', 'marketplace.json'))).resolves.toBeDefined(); - await expect(stat(join(repository, 'plugins', 'installer-fixture', '.cursor-plugin', 'plugin.json'))).resolves.toBeDefined(); - - const badMode = await run(installer, ['install', 'cursor', '--mode', 'remote'], { cwd: tmpdir() }); - expect(badMode.code).toBe(1); - expect(badMode.stderr).toContain('Install mode must be local or marketplace.'); - - // The same bin uninstalls: --plan is a no-op that names every path, the real run consumes the receipt and - // leaves nothing of the local copy behind, and --purge-data is refused without --confirm-purge. - const env = { ...process.env, HOME: home }; - const plan = await run(installer, ['uninstall', 'cursor', '--plan', '--json'], { cwd: tmpdir(), env }); - expect(plan).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(plan.stdout)).toMatchObject({ - destination, - mode: 'local', - receipt: { status: 'consumed' }, - state: 'planned', - }); - await expect(stat(join(destination, installReceiptFile))).resolves.toBeDefined(); - const unconfirmed = await run(installer, ['uninstall', 'cursor', '--purge-data'], { cwd: tmpdir(), env }); - expect(unconfirmed.code).toBe(1); - expect(unconfirmed.stderr).toContain('AB7008'); - const uninstalled = await run(installer, ['uninstall', 'cursor'], { cwd: tmpdir(), env }); - expect(uninstalled).toMatchObject({ code: 0, stderr: '' }); - expect(uninstalled.stdout).toMatch(/^Uninstalled installer-fixture@1\.2\.3 for cursor \(local mode\) at /u); - await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }); - const again = await run(installer, ['uninstall', 'cursor', '--json'], { cwd: tmpdir(), env }); - expect(JSON.parse(again.stdout)).toMatchObject({ state: 'not-installed' }); - // Marketplace-mode staging from earlier in this test is removed the same way. - const marketplaceGone = await run(installer, ['uninstall', 'cursor', '--mode', 'marketplace', '--json'], { cwd: tmpdir(), env }); - expect(marketplaceGone).toMatchObject({ code: 0, stderr: '' }); - expect(JSON.parse(marketplaceGone.stdout)).toMatchObject({ mode: 'marketplace', state: 'uninstalled' }); - await expect(stat(repository)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(stat(join(home, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - const unknownVerbFlag = await run(installer, ['install', 'cursor', '--plan'], { cwd: tmpdir(), env }); - expect(unknownVerbFlag.code).toBe(1); - expect(unknownVerbFlag.stderr).toContain('Unknown installer argument "--plan"'); -}, 120_000); - -it('chooses an unused installer name when both primary candidates are bins', async () => { - const root = await fixture({ - bin: ['installer-fixture', 'installer-fixture-install'], - target: 'cursor', - }); - const result = await build({ output: 'host-packs', packageOutputs: true, root }); - - expect(result.packageBuild?.files.map((file) => file.path)).toEqual(expect.arrayContaining([ - 'bin/installer-fixture.js', - 'bin/installer-fixture-install.js', - 'bin/installer-fixture-install-2.js', - ])); - const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install-2.js'), ['--help'], { cwd: tmpdir() }); - expect(help).toMatchObject({ code: 0, stderr: '' }); -}, 120_000); - -it('handles installer help when the project path contains a percent sign', async () => { - const originalRoot = await fixture({ bin: ['installer-fixture'], target: 'cursor' }); - const root = `${originalRoot}%build`; - await rename(originalRoot, root); - roots.splice(roots.indexOf(originalRoot), 1, root); - - await build({ output: 'host-packs', packageOutputs: true, root }); - const help = await run(join(root, 'dist', 'bin', 'installer-fixture-install.js'), ['--help'], { cwd: tmpdir() }); - - expect(help).toMatchObject({ code: 0, stderr: '' }); - expect(help.stdout).toContain('install [--scope ] [--mode local|marketplace] [--replace|--force] [--json]'); -}, 120_000); - -it('uses the plugin name when free and skips portable-only artifacts', async () => { - const cursorRoot = await fixture({ author: 'Fixture Owner', bin: false, target: 'cursor' }); - const cursor = await build({ output: 'host-packs', packageOutputs: true, root: cursorRoot }); - expect(cursor.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); - - const portableRoot = await fixture({ bin: false, target: 'portable' }); - const portable = await build({ output: 'host-packs', packageOutputs: true, root: portableRoot }); - expect(portable.packageBuild?.files.map((file) => file.path)) - .not.toContain('bin/installer-fixture.js'); - // The Agent Plugins pack has no .cursor-plugin manifest, so its install.mjs must refuse marketplace mode. - const portableHome = join(portableRoot, 'home'); - await mkdir(join(portableHome, '.cursor'), { recursive: true }); - const portableMarketplace = await run( - process.execPath, - [join(portableRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], - { cwd: tmpdir(), env: { ...process.env, HOME: portableHome } }, - ); - expect(portableMarketplace.code).toBe(1); - expect(portableMarketplace.stderr).toContain('--mode marketplace requires a Cursor Plugin'); - await expect(stat(join(portableHome, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - - // A bundle carrying nested Git metadata would be committed as an empty gitlink; the emitted installer refuses it. - const cursorHome = join(cursorRoot, 'home'); - await mkdir(join(cursorHome, '.cursor'), { recursive: true }); - await mkdir(join(cursorRoot, 'host-packs', 'vendor', '.git'), { recursive: true }); - const nestedGit = await run( - process.execPath, - [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], - { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, - ); - expect(nestedGit.code).toBe(1); - expect(nestedGit.stderr).toContain('refuses bundle-internal Git metadata at "vendor/.git"'); - await expect(stat(join(cursorHome, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); - await rm(join(cursorRoot, 'host-packs', 'vendor'), { recursive: true }); - - // The emitted install.mjs and `agent-bundle install cursor --mode marketplace` derive owner/description from the - // same emitted manifest (authored cursor.author here), so staging with one and rerunning the other is idempotent. - const stagedByScript = await run( - process.execPath, - [join(cursorRoot, 'host-packs', 'install.mjs'), '--mode', 'marketplace'], - { cwd: tmpdir(), env: { ...process.env, HOME: cursorHome } }, - ); - expect(stagedByScript).toMatchObject({ code: 0, stderr: '' }); - const stagedManifest = JSON.parse(await readFile( - join(cursorHome, '.cursor', 'agent-bundle', 'marketplaces', 'installer-fixture', '.cursor-plugin', 'marketplace.json'), - 'utf8', - )); - expect(stagedManifest.owner).toEqual({ name: 'Fixture Owner' }); - const rerunByCli = await installBundle({ - from: join(cursorRoot, 'host-packs'), - home: cursorHome, - host: 'cursor', - mode: 'marketplace', - }); - expect(rerunByCli).toMatchObject({ mode: 'marketplace', state: 'already-installed' }); - - const pluginRoot = await fixture({ bin: false, target: ['claude', 'codex', 'cursor'] }); - const plugin = await build({ output: 'host-packs', packageOutputs: true, root: pluginRoot }); - const pluginInstaller = join(pluginRoot, 'dist', 'bin', 'installer-fixture.js'); - expect(plugin.packageBuild?.files.map((file) => file.path)).toContain('bin/installer-fixture.js'); - const help = await run(pluginInstaller, ['--help'], { cwd: tmpdir() }); - expect(help.stdout).toContain('claude, codex, cursor'); - const home = join(pluginRoot, 'home'); - await mkdir(join(home, '.cursor'), { recursive: true }); - const doctor = await runDoctor({ - from: join(pluginRoot, 'host-packs'), - home, - hosts: ['cursor'], - }); - expect(doctor.hosts[0]?.bundle).toMatchObject({ - bundleRoot: join(pluginRoot, 'host-packs'), - name: 'installer-fixture', - version: '1.2.3', - }); - const installed = await run(pluginInstaller, ['install', 'cursor', '--json'], { - cwd: tmpdir(), - env: { ...process.env, HOME: home }, - }); - expect(installed).toMatchObject({ code: 0, stderr: '' }); -}, 120_000); diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 6ad0959fb..8ddb9f2c2 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -442,15 +442,12 @@ it('uses only an installed tarball after source deletion', async () => { expect(packageModules.violations).toEqual([]); expect(artifactModules.modules.filter((module) => /^mcp\/[^/]+\.mjs$/u.test(module.path))).toHaveLength(1); expect(packageModules.modules.map((module) => module.path)).toEqual(expect.arrayContaining([ - 'bin/framework-build-fixture-install.js', 'bin/framework-build-fixture.js', 'index.js', ])); const packedBin = join(frameworkRoot, 'dist', 'bin', 'framework-build-fixture.js'); - const packedInstallerBin = join(frameworkRoot, 'dist', 'bin', 'framework-build-fixture-install.js'); expect((await stat(packedBin)).mode & 0o111).not.toBe(0); - expect((await stat(packedInstallerBin)).mode & 0o111).not.toBe(0); expect((await readFile(packedBin, 'utf8')).startsWith('#!/usr/bin/env node\n')).toBe(true); await expect(execFile(packedBin, ['alpha'], { cwd: frameworkRoot, env: installedEnvironment() })) .resolves.toMatchObject({ stdout: 'packed bin ran:alpha\n' }); diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index ddd0c9cbc..d80e5adfd 100644 --- a/packages/agent-bundle/tests/packed-web-command.test.ts +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile, type ChildProcess } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -13,7 +13,12 @@ import { WEB_HOST_SEED_ELEMENT_ID, type WebHostPageSeed } from '../src/web-host/ import { WEB_HOST_TOKEN_HEADER } from '../src/web-host/page.ts'; import { awaitStdoutLine, connectionRefused, isProcessGone, killAll, runBin, type BinRun } from './support/bin-process.ts'; import { eventuallyPasses, within } from './support/eventually.ts'; -import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; +import { + cachedNpmInstallArguments, + installedEnvironment, + packOutputFromJson, + sharedPackedTarball, +} from './support/shared-pack.ts'; import { timeScale } from './support/time-scale.ts'; const execFile = promisify(executeFile); @@ -45,7 +50,10 @@ const webReadyKeys: readonly (keyof WebReadyDocument)[] = ['app', 'port', 'resou let consumer = ''; let project = ''; let artifact = ''; +let packageRoot = ''; +let artifactBin = ''; let bin = ''; +let packedPaths: readonly string[] = []; const spawned = new Set(); const observedProcessIds = new Set(); @@ -80,6 +88,7 @@ beforeAll(async () => { consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-web-command-')); project = join(consumer, 'project'); artifact = join(project, 'artifact'); + packageRoot = join(project, 'dist'); await cp(fixtureRoot, project, { recursive: true }); // The generated routed-CLI bin resolves `@agent-bundle/runtime` (and its // React peer) from the consumer, exactly like the packed stdio proof. @@ -96,7 +105,38 @@ beforeAll(async () => { cwd: project, env: installedEnvironment(), }); - bin = join(artifact, 'bin', `${pluginName}.mjs`); + artifactBin = join(artifact, 'bin', `${pluginName}.mjs`); + + const tarballs = join(consumer, 'tarballs'); + const installedConsumer = join(consumer, 'installed-consumer'); + await Promise.all([ + mkdir(tarballs), + mkdir(installedConsumer), + ]); + await writeFile(join(installedConsumer, 'package.json'), '{"private":true}\n'); + const { stdout: packJson } = await execFile('npm', [ + 'pack', + '--json', + '--ignore-scripts', + '--pack-destination', + tarballs, + ], { cwd: packageRoot, env: installedEnvironment() }); + const packed = packOutputFromJson(packJson, pluginName); + packedPaths = packed.files.map((file) => file.path); + await execFile('npm', [ + 'install', + ...cachedNpmInstallArguments, + join(tarballs, packed.filename), + ], { cwd: installedConsumer, env: installedEnvironment() }); + const installedPackageRoot = join(installedConsumer, 'node_modules', pluginName); + const packageDocument = JSON.parse( + await readFile(join(installedPackageRoot, 'package.json'), 'utf8'), + ) as { readonly bin?: Readonly> }; + const declaredBin = packageDocument.bin?.[pluginName]; + if (declaredBin === undefined) { + throw new Error(`Packed ${pluginName} package does not declare its executable.`); + } + bin = resolve(installedPackageRoot, declaredBin); // `packed-deleted-source`: the bin serves out of the artifact alone, so the // config, the routes, the server, and the App view are removed and verified // absent before any process runs. @@ -143,6 +183,39 @@ it('builds the exposed App into the composite root: a manifest web section and o expect(source).toContain(WEB_HOST_SEED_ELEMENT_ID); }); +it('packs the composite root as the npm root and runs the package.json bin with the artifact command surface byte-for-byte', async () => { + expect(packedPaths).toContain('agent-bundle.manifest.json'); + expect(packedPaths).toContain(`bin/${pluginName}.mjs`); + expect(packedPaths.some((path) => path.startsWith('artifact/'))).toBe(false); + await expect(readFile(bin)).resolves.toEqual(await readFile(artifactBin)); + + const [artifactHelp, packedHelp] = [artifactBin, bin].map((executable) => + spawnBin(executable, ['--help'])); + await Promise.all([ + expect(within(artifactHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + expect(within(packedHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + ]); + expect(packedHelp.stdout()).toBe(artifactHelp.stdout()); + expect(packedHelp.stdout()).toMatch(/^\s+dashboard\b/mu); + expect(packedHelp.stdout()).toMatch(/^\s+web\b/mu); + + const [artifactDashboard, packedDashboard] = [artifactBin, bin].map((executable) => + spawnBin(executable, ['dashboard', '--json'])); + await Promise.all([ + expect(within(artifactDashboard.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + expect(within(packedDashboard.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + ]); + expect(packedDashboard.stdout()).toBe(artifactDashboard.stdout()); + + const [artifactWebHelp, packedWebHelp] = [artifactBin, bin].map((executable) => + spawnBin(executable, ['web', '--help'])); + await Promise.all([ + expect(within(artifactWebHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + expect(within(packedWebHelp.exit, 30_000 * timeScale)).resolves.toEqual({ code: 0, signal: null }), + ]); + expect(packedWebHelp.stdout()).toBe(artifactWebHelp.stdout()); +}); + it('serves the App from `web --json --no-open` as a real process out of the deleted-source consumer, gates its routes by token, and tears down on SIGINT', { timeout: 120_000 }, async () => { const run = spawnBin(bin, ['web', '--no-open', '--json']); const line = await awaitStdoutLine(run, (candidate) => candidate.startsWith('{'), startupBudget); diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index c9cc3fdb7..d4a05aa41 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -1,8 +1,6 @@ -import { execFile as executeFile } from 'node:child_process'; -import { cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; -import { promisify } from 'node:util'; import { gzipSync } from 'node:zlib'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; @@ -18,7 +16,6 @@ import { type PackOutput, } from '../src/build/pack-inventory.ts'; -const execFile = promisify(executeFile); const workspaceNodeModules = join(process.cwd(), 'node_modules'); /** A gzipped ustar archive in npm's layout: one `package/package.json` entry with the given manifest text. */ @@ -52,8 +49,6 @@ beforeAll(async () => { await symlink(workspaceNodeModules, join(projectRoot, 'node_modules'), 'dir'); await Promise.all([ writeFile(join(projectRoot, 'package.json'), `${JSON.stringify({ - bin: { 'installer-fixture': './dist/bin/installer-fixture.js' }, - files: ['dist', 'host-packs', 'README.md'], name: 'installer-fixture', type: 'module', version: '1.2.3', @@ -125,8 +120,8 @@ it('selects the intended pack entry by package name when npm lists sibling works it('prepack validates the complete dry-run inventory', async () => { expect(await diagnostics()).toEqual([]); - expect(result.pack.files.map((file) => file.path)).toContain('dist/bin/installer-fixture.js'); - expect(result.pack.files.map((file) => file.path)).toContain('host-packs/agent-bundle.manifest.json'); + expect(result.pack.files.map((file) => file.path)).toContain('index.js'); + expect(result.pack.files.map((file) => file.path)).toContain('agent-bundle.manifest.json'); }); it('exposes --root, --output, and --json through the prepack command', async () => { @@ -157,7 +152,7 @@ it('exposes --root, --output, and --json through the prepack command', async () it('reports missing allowlisted artifacts as AB7010', async () => { const pack = { ...result.pack, - files: result.pack.files.filter((file) => file.path !== 'host-packs/INSTALL.md'), + files: result.pack.files.filter((file) => file.path !== 'INSTALL.md'), }; expect(await diagnostics(pack)).toContainEqual(expect.objectContaining({ code: 'AB7010' })); }); @@ -193,14 +188,20 @@ const withPackageDocument = async ( mutate: (document: Record) => void, run: () => Promise, ): Promise => { - const packagePath = join(projectRoot, 'package.json'); + const sourcePackagePath = join(projectRoot, 'package.json'); + const packagePath = join(projectRoot, 'dist', 'package.json'); + const sourceOriginal = await readFile(sourcePackagePath, 'utf8'); const original = await readFile(packagePath, 'utf8'); + const sourceDocument = JSON.parse(sourceOriginal) as Record; const document = JSON.parse(original) as Record; + mutate(sourceDocument); mutate(document); + await writeFile(sourcePackagePath, `${JSON.stringify(sourceDocument, null, 2)}\n`); await writeFile(packagePath, `${JSON.stringify(document, null, 2)}\n`); try { await run(); } finally { + await writeFile(sourcePackagePath, sourceOriginal); await writeFile(packagePath, original); } }; @@ -309,7 +310,7 @@ it('accepts a package loaded through a createRequire() binding, literal or compu '', ].join('\n')); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/aliased.mjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'aliased.mjs' }] }; const [reported] = withCode(await diagnostics(pack), 'AB7014'); expect(reported?.message).toContain('"never-loaded"'); expect(reported?.message).not.toContain('"driver-package"'); @@ -361,7 +362,7 @@ it.each([ const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); await writeFile(consumer, `${source}\n`); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; const [reported] = withCode(await diagnostics(pack), 'AB7014'); expect(reported?.message).toContain('"never-loaded"'); expect(reported?.message).not.toContain('"driver-package"'); @@ -380,7 +381,7 @@ it.each([ const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'qualified.mjs' : 'qualified.cjs'); await writeFile(consumer, `${source}\n`); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(consumer, { force: true }); @@ -465,24 +466,25 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier }; }, async () => { - await mkdir(join(projectRoot, 'scripts', 'lib'), { recursive: true }); - await mkdir(join(projectRoot, 'vendor', 'vendored'), { recursive: true }); - await mkdir(join(projectRoot, 'vendor', 'bad-manifest'), { recursive: true }); + const packageRoot = join(projectRoot, 'dist'); + await mkdir(join(packageRoot, 'scripts', 'lib'), { recursive: true }); + await mkdir(join(packageRoot, 'vendor', 'vendored'), { recursive: true }); + await mkdir(join(packageRoot, 'vendor', 'bad-manifest'), { recursive: true }); await Promise.all([ - writeFile(join(projectRoot, 'scripts', 'install.js'), 'import "./driver-setup.cjs";\n'), - writeFile(join(projectRoot, 'scripts', 'driver-setup.cjs'), 'module.exports = require("optional-driver");\n'), - writeFile(join(projectRoot, 'scripts', 'my install.cjs'), 'require("optional-tester");\n'), - writeFile(join(projectRoot, 'scripts', 'hooks.cjs'), 'require("#hooks/hook");\nrequire("./lib");\n'), - writeFile(join(projectRoot, 'scripts', 'hook-setup.cjs'), 'require("optional-hook");\n'), - writeFile(join(projectRoot, 'scripts', 'lib', 'package.json'), '{ "main": "setup.cjs" }\n'), - writeFile(join(projectRoot, 'scripts', 'lib', 'setup.cjs'), 'require("optional-main");\n'), - writeFile(join(projectRoot, 'scripts', 'preload.mjs'), 'import "optional-imported";\n'), - writeFile(join(projectRoot, 'scripts', 'root-setup.cjs'), 'require("optional-root");\n'), - writeFile(join(projectRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), - writeFile(join(projectRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), - writeFile(join(projectRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), - writeFile(join(projectRoot, 'vendor', 'bad-tarred-manifest.tgz'), packageTarball('not json\n')), - writeFile(join(projectRoot, 'vendor', 'not-archive.tgz'), 'not a tarball\n'), + writeFile(join(packageRoot, 'scripts', 'install.js'), 'import "./driver-setup.cjs";\n'), + writeFile(join(packageRoot, 'scripts', 'driver-setup.cjs'), 'module.exports = require("optional-driver");\n'), + writeFile(join(packageRoot, 'scripts', 'my install.cjs'), 'require("optional-tester");\n'), + writeFile(join(packageRoot, 'scripts', 'hooks.cjs'), 'require("#hooks/hook");\nrequire("./lib");\n'), + writeFile(join(packageRoot, 'scripts', 'hook-setup.cjs'), 'require("optional-hook");\n'), + writeFile(join(packageRoot, 'scripts', 'lib', 'package.json'), '{ "main": "setup.cjs" }\n'), + writeFile(join(packageRoot, 'scripts', 'lib', 'setup.cjs'), 'require("optional-main");\n'), + writeFile(join(packageRoot, 'scripts', 'preload.mjs'), 'import "optional-imported";\n'), + writeFile(join(packageRoot, 'scripts', 'root-setup.cjs'), 'require("optional-root");\n'), + writeFile(join(packageRoot, 'vendor', 'vendored', 'package.json'), '{ "name": "vendored", "version": "1.0.0" }\n'), + writeFile(join(packageRoot, 'vendor', 'bad-manifest', 'package.json'), '{\n'), + writeFile(join(packageRoot, 'vendor', 'tarred.tgz'), packageTarball('{ "name": "tarred", "version": "1.0.0" }')), + writeFile(join(packageRoot, 'vendor', 'bad-tarred-manifest.tgz'), packageTarball('not json\n')), + writeFile(join(packageRoot, 'vendor', 'not-archive.tgz'), 'not a tarball\n'), ]); const pack = { ...result.pack, files: [...result.pack.files, { path: 'node_modules/embedded/package.json' }, @@ -536,8 +538,8 @@ it('reports git, GitHub-shorthand, remote-tarball, and path dependency specifier for (const name of ['alias', 'tilde', 'versioned', 'embedded', 'vendored', 'tarred']) { expect(reported[0]?.message).not.toContain(JSON.stringify(name)); } - await rm(join(projectRoot, 'scripts'), { force: true, recursive: true }); - await rm(join(projectRoot, 'vendor'), { force: true, recursive: true }); + await rm(join(packageRoot, 'scripts'), { force: true, recursive: true }); + await rm(join(packageRoot, 'vendor'), { force: true, recursive: true }); expect(reported[0]?.recovery).toContain('registry'); const underPnpm = withCode(await diagnostics(pack, true), 'AB7015'); @@ -551,7 +553,7 @@ it('withholds AB7014 when packed JavaScript has a computed import() that could l const consumer = join(projectRoot, 'dist', 'computed.mjs'); await writeFile(consumer, 'export const load = (name) => import(name);\n'); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/computed.mjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'computed.mjs' }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); // Without that file the same declaration is reported. expect(withCode(await diagnostics(), 'AB7014')).toHaveLength(1); @@ -568,7 +570,7 @@ it('withholds AB7014 when packed JavaScript the lexer rejects may hide an import // An unbalanced call: the lexer throws before reporting any import, so nothing proves the package unused. await writeFile(consumer, 'export const load = () => import("chosen-at-runtime"\n'); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/unlexable.mjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'unlexable.mjs' }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(consumer, { force: true }); @@ -589,7 +591,7 @@ it.each([ const consumer = join(projectRoot, 'dist', source.startsWith('module.exports') ? 'computed.cjs' : 'computed.mjs'); await writeFile(consumer, `${source}\n`); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(consumer, { force: true }); @@ -610,7 +612,7 @@ it.each([ const consumer = join(projectRoot, 'dist', source.startsWith('import') ? 'alias.mjs' : 'alias.cjs'); await writeFile(consumer, `${source}\n`); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: relative(projectRoot, consumer) }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: relative(join(projectRoot, 'dist'), consumer) }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(consumer, { force: true }); @@ -631,7 +633,7 @@ it.each([ const consumer = join(projectRoot, 'dist', 'not-alias.cjs'); await writeFile(consumer, `${source}\n`); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/not-alias.cjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'not-alias.cjs' }] }; const [reported] = withCode(await diagnostics(pack), 'AB7014'); expect(reported?.message).toContain('"never-loaded"'); } finally { @@ -650,7 +652,7 @@ it('still reports AB7014 when the only resolve() calls are path or Promise resol '', ].join('\n')); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/resolvers.mjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'resolvers.mjs' }] }; expect(withCode(await diagnostics(pack), 'AB7014')[0]?.message).toContain('"never-loaded"'); } finally { await rm(consumer, { force: true }); @@ -707,7 +709,7 @@ it('accepts a dependency reached through a package imports map or run by a consu await mkdir(wrapper, { recursive: true }); await writeFile(join(wrapper, 'package.json'), JSON.stringify({ name: '@scope/real', version: '1.0.0', bin: 'cli.js' })); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/mapped.mjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'mapped.mjs' }] }; // `#driver` reaches driver-package; the script names named-in-script directly, typescript through its `tsc` bin, // and the alias through `real`, the bin npm derives from the installed manifest's name. `prepare` proves nothing. const [withImport] = withCode(await diagnostics(pack), 'AB7014'); @@ -809,23 +811,17 @@ it('reads an installed manifest as npm does, so the last of duplicate name keys }, )); -it('prepack succeeds and surfaces the warning when the only finding is an unresolvable optional dependency', () => withPackageDocument( +it('prepack rejects an unused unresolvable optional dependency and retains its fetch warning', () => withPackageDocument( (document) => { document.optionalDependencies = { 'optional-native': 'github:owner/optional-native' }; - // The build rewrites dist, so the packed module that loads the optional package lives in its own packed - // directory; an install script naming it instead would make the failed fetch fatal. - document.files = [...(document.files as readonly string[]), 'extras']; }, async () => { - const extras = join(projectRoot, 'extras'); - await mkdir(extras, { recursive: true }); - await writeFile(join(extras, 'optional.mjs'), 'export const native = await import("optional-native").catch(() => undefined);\n'); - try { - const packed = await prepack({ root: projectRoot }); - expect(packed.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity])).toEqual([['AB7015', 'warning']]); - } finally { - await rm(extras, { force: true, recursive: true }); - } + await expect(prepack({ root: projectRoot })).rejects.toMatchObject({ + diagnostics: [ + expect.objectContaining({ code: 'AB7014', severity: 'error' }), + expect.objectContaining({ code: 'AB7015', severity: 'warning' }), + ], + }); }, )); @@ -841,7 +837,7 @@ it('accepts a dependency that only packed declaration files reference, including '', ].join('\n')); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.d.ts' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'consumer.d.ts' }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(declaration, { force: true }); @@ -881,7 +877,7 @@ it('accepts a dependency that packed JavaScript imports, requires, or only resol '', ].join('\n')); try { - const pack = { ...result.pack, files: [...result.pack.files, { path: 'dist/consumer.mjs' }] }; + const pack = { ...result.pack, files: [...result.pack.files, { path: 'consumer.mjs' }] }; expect(withCode(await diagnostics(pack), 'AB7014')).toHaveLength(0); } finally { await rm(consumer, { force: true }); @@ -945,7 +941,7 @@ it('accepts a dependency that only a prebuilt payload module imports: prepack pa expect(withCode(reported, 'AB6005')).toHaveLength(0); expect(withCode(reported, 'AB7014')).toHaveLength(0); // The payload is copied once into the composite root (#555): no `/` partition under `distPath`. - expect(packed.pack.files.map((file) => file.path)).toContain('host-packs/runtime/mcp/server.js'); + expect(packed.pack.files.map((file) => file.path)).toContain('runtime/mcp/server.js'); }, 180_000); it('fails prepack with compile-time AB6005, never AB7014, when only a compiled dist bundle imports a declared dependency', async () => { @@ -983,34 +979,3 @@ it('fails prepack with compile-time AB6005, never AB7014, when only a compiled d }); expect(withCode(reported, 'AB7014')).toHaveLength(0); }, 180_000); - -it('installs a real packed tarball and runs its Cursor installer from node_modules', async () => { - const tarballs = join(cleanupRoot, 'tarballs'); - const consumer = join(cleanupRoot, 'consumer'); - const home = join(cleanupRoot, 'home'); - await Promise.all([ - mkdir(tarballs), - mkdir(consumer), - mkdir(join(home, '.cursor'), { recursive: true }), - ]); - const { stdout } = await execFile('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], { - cwd: projectRoot, - }); - const packed = packOutputFromJson(stdout); - await writeFile(join(consumer, 'package.json'), '{"private":true}\n'); - await execFile('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', join(tarballs, packed.filename)], { - cwd: consumer, - }); - const sourceCopy = join(cleanupRoot, 'source-copy'); - await cp(projectRoot, sourceCopy, { recursive: true, filter: (source) => source !== join(projectRoot, 'node_modules') }); - await rm(projectRoot, { force: true, recursive: true }); - - const installedBin = join(consumer, 'node_modules', '.bin', 'installer-fixture'); - const installed = await execFile(installedBin, ['install', 'cursor', '--json'], { - cwd: consumer, - env: { ...process.env, HOME: home }, - }); - expect(JSON.parse(installed.stdout)).toMatchObject({ host: 'cursor', state: 'installed' }); - await expect(stat(join(home, '.cursor', 'plugins', 'local', 'installer-fixture'))).resolves.toBeDefined(); - expect(await readFile(join(sourceCopy, 'src', 'index.ts'), 'utf8')).toBe('export const value = 1;\n'); -}); diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index 2840ce230..bcb04f6e3 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -59,7 +59,6 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/host-mcp-proxy.test.ts', 'packages/agent-bundle/tests/host-install-proof.test.ts', 'packages/agent-bundle/tests/host-install-session.test.ts', - 'packages/agent-bundle/tests/installer-entry.test.ts', 'packages/agent-bundle/tests/integration-matrix.test.ts', 'packages/agent-bundle/tests/layout-build.test.ts', 'packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts', diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index a16e94217..62e2e787e 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -112,8 +112,8 @@ export default async function inspect( ``` The compiler statically projects `inputSchema` onto argv, generates nested help (`--help` at -every level, `--version` at the root), and emits `dist/bin/.js` through the same -bundler synthesis as every other bin. At run time the shell resolves the command path, parses and +every level, `--version` at the root), and emits `bin/.mjs` in the composite +artifact. At run time the shell resolves the command path, parses and coerces argv, validates through the module's own schemas, executes the default function inside the typed Agent request context, writes one canonical JSON line to stdout, and maps exit codes deterministically: @@ -370,19 +370,17 @@ projection field; `ToolConfig` gains nothing. Short `-x` aliases (the shell rejects single-dash tokens today) and an async `mapInput` are deferred. -### The routed CLI inside host artifacts +### The same routed CLI in every distribution -The package bin only reaches users who install the npm package, while hooks, Skills, and scripts -ship with the host artifact. So the build also emits the same compiled command graph once into -the artifact root as `bin/.mjs` (plus `bin/-flight.mjs` when any +Hooks, Skills, scripts, and npm consumers all use the plugin root, so the build emits the compiled +command graph once into the artifact root as `bin/.mjs` (plus +`bin/-flight.mjs` when any command renders), shared by every selected host. Every built-in target publishes the `cli` -capability that admits it. The artifact bin is a self-contained ESM module with no shebang or -executable bit — run it as `node /bin/.mjs `, exactly like -`scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals match the package bin; -the one difference is that -workspace-durable state without a host-supplied `AGENT_BUNDLE_PLUGIN_ROOT` anchors on the artifact -root (the parent of `bin/`, the same fallback the generated MCP worker uses) instead of -`$PWD/.agent-bundle/state`, so a co-installed CLI and server share one store. +capability that admits it. The artifact bin is a self-contained executable ESM module with a Node +shebang. The npm root copies it unchanged and points `package.json` `bin` directly at it, so help, +argv parsing, output modes, exit codes, signals, and the complete command set are byte-for-byte +the same. Both forms resolve plugin code from the root above `bin/` and follow the same runtime +state policy. Reach it from the other surfaces with the plugin-root token — `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` in Claude Skill Markdown and hook commands, `${PLUGIN_ROOT}/…` in Codex hooks, `${CURSOR_PLUGIN_ROOT}/…` @@ -390,8 +388,8 @@ in Cursor hooks — or, from a compiled script, as the sibling `new URL('../bin/.mjs', import.meta.url)`. `inspect` accounts for the bin as one `cli` component per target and the artifact manifest records both files. A target without the `cli` capability omits the bin and reports `AB4765`; a host-emitted file at the same path (a `claude.bin` -directory shipping `.mjs`) is `AB4766`. The package build's `dist/bin/.js` -is unchanged. +directory shipping `.mjs`) is `AB4766`. The npm root copies this manifest-declared +executable; it does not compile another routed CLI. ### Projecting MCP tools into the CLI @@ -433,15 +431,14 @@ Outside an agent-bundle compilation the published module throws rather than repo fabricated identity, and a release build refuses a project with no release version at all (`AB4013`). -## Packaging and installers +## Packaging the npm root -When package outputs and at least one Claude, Codex, or Cursor projection are built inside the -project, the framework also emits one self-contained package-relative installer at -`dist/bin/.js` — or `-install.js` when that name is taken, with a -numeric suffix if both are. Declare the matching `package.json` `bin` value. Its grammar is -`install [--scope ] [--json]`, help lists only built hosts, and it resolves the -shipped artifact directory from `import.meta.url` rather than the caller's working directory, so -it works from `node_modules` regardless of the current directory. No npm lifecycle performs an +When package outputs are enabled, `dist/` contains the complete validated composite artifact plus +the package metadata, standard documentation, and package-only entries. A generated routed CLI is +not compiled a second time: the installed `package.json` points at the +`bin/.mjs` executable declared by `agent-bundle.manifest.json` and copied unchanged +from the artifact. The tarball therefore has one plugin root and one routed CLI, with no nested +`artifact/` directory or package-relative installer wrapper. No npm lifecycle performs a host installation. ```sh diff --git a/website/docs/en/guide/distribution/index.mdx b/website/docs/en/guide/distribution/index.mdx index 8cef7d7e1..dd244b326 100644 --- a/website/docs/en/guide/distribution/index.mdx +++ b/website/docs/en/guide/distribution/index.mdx @@ -43,8 +43,8 @@ once the artifact validates: Compiled surfaces are built once and attributed to the **composite identity** — the selected hosts sorted and joined by `+`, such as `claude+codex` — never once per host. Both stages and the -`dist/` package build compose their bundler config the same way — profile, `tools.rsbuild`, -`tools.rspack`, then the framework invariants — as described under +package-only library and authored-bin entries compose their bundler config the same way — +profile, `tools.rsbuild`, `tools.rspack`, then the framework invariants — as described under [`tools`](../../reference/configuration.mdx#tools). `agent-bundle inspect --bundler` prints the result; its `distPath.root` is the artifact output itself. @@ -80,10 +80,16 @@ occupies that directory. ## The npm-facing half -A project that also ships as an npm package has a second output: `dist/bin/.js` executables -and a library entry, described in [Package entries](../authoring/package-entries.mdx). When -package outputs and a root with at least one built-in host are built together, the framework also -emits a package-relative installer bin — see [Host installation](./installation.mdx). +A project that also ships as an npm package has a second output, `dist/`, but it is not a second +plugin build. `dist/` is the npm package root: it contains the complete composite artifact, +`package.json`, the standard package documentation files, and any package-only library or +authored-bin entries described in [Package entries](../authoring/package-entries.mdx). + +For a generated routed CLI, the installed package's `bin` points directly at +`bin/.mjs`, copied unchanged from the validated artifact. The npm CLI therefore has the +same commands — including `web` — and the same bytes as the artifact CLI. The tarball has no +nested `artifact/` directory, and the framework does not compile a parallel `dist/bin/.js` +application or generate a package-relative installer bin. `agent-bundle prepack` is the gate for that half: it runs the release build, dry-runs `npm pack` without scripts, and verifies packaged outputs, artifact hashes, bins, and versions. diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index c8cb8c228..5bdb0db26 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -1,5 +1,5 @@ --- -description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: the install command, host scopes, the standalone install.mjs, the installer bin, and Doctor.' +description: 'Install a built agent-bundle into Claude Code, Codex, or Cursor: the install command, host scopes, the standalone install.mjs, npm CLI parity, and Doctor.' --- # Host installation @@ -75,21 +75,13 @@ Cursor behaviour: the provenance is `derived`, and a root that carries `.cursor- Artifact validation rejects a root whose required install surface is missing, so a bundle cannot ship without the installer it promises. -## The package-relative installer bin +## The npm-installed CLI -When package outputs ship beside a root with a built-in host selected, the build also emits a -package-relative installer bin. It uses the plugin name when no configured bin claims it, and -`-install` otherwise (with a numeric suffix if both are taken). Map that name to the -generated `dist/bin/*.js` file in `package.json`; consumers then run: - -```sh - install [--scope ] [--json] -``` - -Help lists only the hosts that were actually built. The executable locates the artifact directory -beside the installed package from `import.meta.url`, never the caller's working directory, so it -works from `node_modules` regardless of where it is invoked. **No npm lifecycle performs an -installation** — installing a package never mutates a host's plugin state. +The npm package root contains the composite plugin root itself. For a generated routed CLI, +`package.json` points its `bin` at the same `bin/.mjs` executable the artifact exposes, +without a second compilation or wrapper. Its complete command surface, including `web`, therefore +works from `node_modules` exactly as it does from a copied artifact. **No npm lifecycle performs a +host installation** — installing a package never mutates a host's plugin state. ## Development installs are a different thing @@ -100,8 +92,8 @@ covered in [Developer Workbench](../development/workbench.mdx) and is not the sa ## Reinstalling after a rebuild -Every emitted installer — `agent-bundle install `, the package-relative bin, and the -standalone `install.mjs` — shares one replace policy. An identical copy is an `already-installed` +Every emitted installer — `agent-bundle install ` and the standalone `install.mjs` — +shares one replace policy. An identical copy is an `already-installed` no-op. A copy of the **same version whose content hash differs** is replaced automatically, so rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A different version is refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index c825227fc..c870070b4 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -27,8 +27,9 @@ files are checked too — a manifest-declared `logo` that is missing from the ar the deploy tree reports `AB6025`. Self-containment is proven from the compilation's externals (`AB6005` at compile time). The -compiler service lowers every host-pack surface and package-build entry (`dist/bin/*.js`, a -rendered route's Flight worker `.mjs`, and the `lib` entry). The framework-owned +compiler service lowers every host-pack surface and package-only entry (an authored bin or the +`lib` entry). The npm root reuses the manifest-declared routed CLI and its rendered route Flight +worker from the validated artifact. The framework-owned `ArtifactDependencyAuditPlugin` taps `thisCompilation` to record every module Rspack kept external, and the service reads that evidence before trusting an asset. Only Node built-ins (`node:fs`, `fs`), `pnpapi`, and an emitted sibling of the same artifact may remain external; any @@ -214,8 +215,8 @@ framework did not compile can still import one. `--output` is an artifact path r prevents recursion, and no npm lifecycle ever performs a host install. That inlining is the compiler's contract, not a bundler default a toolchain upgrade may flip. -Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and the -package build's JavaScript bundles — compile with Rslib's `autoExternal` disabled, `bundle: true`, +Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and +package-only JavaScript bundles — compile with Rslib's `autoExternal` disabled, `bundle: true`, and `splitChunks: false`, and the framework adds no `externals` of its own; Rslib's `node` target leaves only Node built-ins (`node:fs`, `path`, plus Yarn PnP's `pnpapi`) external. MCP App views inline every script and style into one HTML file, and the same audit plugin sits in their Rsbuild @@ -242,9 +243,9 @@ npm cannot install. | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest or file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. | +| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest or file, install surface, or README. Pack the generated `dist/` npm root without excluding its files. | | `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild, and do not modify generated host packs. | -| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | +| `AB7012` | A `package.json` bin points outside the packed npm root (including `src/`) or names a file npm omitted. Point a routed CLI at its manifest-declared `bin/.mjs`; point an authored bin at its generated `bin/*.js`. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | | `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so `import` evidence comes from prebuilt payload modules and other scripts the framework did not compile, while `require`, `createRequire`, and `import.meta.resolve` calls count from any packed file), no packed declaration references them, no `imports` mapping or consumer install script reaches them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | | `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns instead of failing, since npm continues without it; it stays an error when unparseable, or when a consumer install script needs the skipped package (runs its command — not merely mentions it — loads it from an inline `node -e` program — scanned for `require`, `createRequire`, and `import()` like a packed file — preloads it with `node -r`/`--require`/`--import`/`--loader`, or runs a packed file that imports it, `node .` running the root `main` included; each command after `&&`, `;`, or a newline is read on its own, shell quotes and backslash escapes resolved, and `node`'s options belong to `node` alone and end at the program — `node install.js --require x` preloads nothing, while a `NODE_OPTIONS=--require=x` assignment on the same `node` command does; every script word naming a packed JavaScript file counts as run, deliberately, so that runners the gate does not model — `tsx`, `zx`, `bun`, `deno run` — still have their file's dependencies traced, at the cost of a rare escalation for a word such as `echo install.js`, which the diagnostic makes visible by naming the file). A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry; npm never reads the duplicate peer's selector. `workspace:`/`catalog:` are reported too unless the `prepack` lifecycle runs under pnpm, Yarn, or Bun, which rewrite them at pack time — `npm publish` does not, so under npm `AB7015` fires; entries the tarball itself carries — `bundleDependencies` npm actually packed (never peers), and a `file:` path inside the package whose packed source is installable (a directory with a parseable `package.json`, or a well-formed tarball whose `package.json` parses) — are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index a59e8fe88..17fbff4b3 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -185,8 +185,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | -The emitted standalone `install.mjs` and the package-relative installer bin accept the same -`--replace`. Cursor copies carry an install receipt (`.agent-bundle-install.json`), replacement +The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies carry an install +receipt (`.agent-bundle-install.json`), replacement touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling and Codex runs `codex plugin remove` before `add`. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: @@ -224,8 +224,7 @@ second run is a `not-installed` no-op. The typed `data.outcome` says what happen state per host: Cursor `kept` / `purged` / `absent`; Claude `retained-by-host` (the cached copy is orphaned for Claude's ~14-day grace period; a purge also removes `state/` and `plugins/data//`); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the -cached tree and has no keep-data option). The package-relative installer bin accepts -`uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with +cached tree and has no keep-data option). The emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. ## doctor diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 1e1af67e4..9134a100c 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -104,7 +104,7 @@ export default async function inspect( ``` 编译器把 `inputSchema` 静态投影到 argv 上,生成分层帮助(每一层都有 `--help`,根层有 `--version`), -并通过与其他 bin 相同的打包器合成流程输出 `dist/bin/.js`。运行时,外壳解析命令路径、 +并在组合产物中输出 `bin/.mjs`。运行时,外壳解析命令路径、 解析并强制转换 argv、用模块自身的 schema 校验、在带类型的 Agent 请求上下文中执行默认函数、向 stdout 写出一行规范 JSON,并确定性地映射退出码: @@ -130,7 +130,7 @@ Run 'curator doctor --help' for usage. 携带相同 `error` 对象的 `type: "error"` 事件。 `.tsx` 命令路由把默认函数换成一个具有相同 props 的 async 默认 Server Component,并通过运行时分发器 -针对同级的 `dist/bin/-flight.mjs` worker 渲染。它由此获得 +针对同级的 `bin/-flight.mjs` worker 渲染。它由此获得 [脚本与资源](./scripts-assets.mdx)中描述的四种输出模式。路由式 CLI 项目需要把 `@agent-bundle/runtime` 作为依赖,因为生成的可执行文件通过它装配请求上下文。 @@ -339,24 +339,22 @@ id。只要路由从生成的 CLI 可执行文件运行,无论命令由哪种 短格式 `-x` 别名(shell 目前会拒绝单短横线 token)与异步 `mapInput` 暂不支持。 -### 宿主产物中的路由式 CLI +### 每种分发中的同一个路由式 CLI -包 bin 只能到达安装了 npm 包的用户,而 hook、Skill 与脚本是随宿主产物一起交付的。因此构建还会把同一张 -编译后的命令图只发射一次到产物根目录,路径为 `bin/.mjs`(若有任一命令需要渲染,则再加上 +hook、Skill、脚本与 npm 消费者都使用插件根目录,因此构建把编译后的命令图只发射一次到产物根目录, +路径为 `bin/.mjs`(若有任一命令需要渲染,则再加上 `bin/-flight.mjs`),由所有选中宿主共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 -自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 -`node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 -包 bin 一致;唯一的区别是,在宿主未提供 `AGENT_BUNDLE_PLUGIN_ROOT` 时,工作区级持久状态锚定在产物根目录 -(`bin/` 的父目录,与生成的 MCP worker 使用同一个回退),而不是 `$PWD/.agent-bundle/state`,这样一起安装 -的 CLI 与服务器观察到的是同一份存储。 +带 Node shebang 和可执行位的自包含 ESM 模块。npm 根目录原样复制它,并让 `package.json` 的 `bin` +直接指向它,因此帮助、argv 解析、输出模式、退出码、信号与完整命令集合都逐字节相同。两种形式都从 +`bin/` 的上级根目录解析插件代码,并遵循同一套运行时状态策略。 从其他表面引用它时使用插件根 token——Claude Skill Markdown 与 hook 命令中写 `${CLAUDE_PLUGIN_ROOT}/bin/.mjs`,Codex hook 中写 `${PLUGIN_ROOT}/…`,Cursor hook 中写 `${CURSOR_PLUGIN_ROOT}/…`——或者在编译后的脚本里用同级引用 `new URL('../bin/.mjs', import.meta.url)`。`inspect` 把该 bin 记为每个 target 的一个 `cli` 组件,产物清单会记录这两个文件。不具备 `cli` 能力的 target 会省略该 bin 并报告 `AB4765`;宿主在同一路径 -发射的文件(例如 `claude.bin` 目录中交付的 `.mjs`)是 `AB4766`。包构建的 -`dist/bin/.js` 保持不变。 +发射的文件(例如 `claude.bin` 目录中交付的 `.mjs`)是 `AB4766`。npm 根目录复制清单声明的 +这个可执行文件,不再编译另一份路由式 CLI。 ### 把 MCP 工具投影进 CLI @@ -389,14 +387,12 @@ import meta, { name, packageName, packageVersion, version } from 'agent-bundle/m 在 agent-bundle 编译之外,已发布的该模块会抛出错误,而不是报告一个伪造的标识;完全没有发布版本号的 发布构建会被拒绝(`AB4013`)。 -## 打包与安装器 +## 打包 npm 根目录 -当包输出与至少一个 Claude、Codex 或 Cursor 投影在同一项目内构建时,框架还会输出一个自包含、相对 -包路径的安装器,位于 `dist/bin/.js`——若该名称已被占用则为 `-install.js`, -两者都被占用时再追加数字后缀。请在 `package.json` 的 `bin` 中声明对应取值。它的语法是 -`install [--scope ] [--json]`,帮助中只列出已构建的宿主;它通过 `import.meta.url` 而 -不是调用者的工作目录来定位随包发布的产物目录,因此无论当前目录在哪里,从 `node_modules` 中都能正常 -工作。没有任何 npm 生命周期会执行安装。 +启用包输出后,`dist/` 包含完整的已校验组合产物,以及包元数据、标准文档和包专用入口。生成的路由式 CLI +不会再次编译:已安装的 `package.json` 指向 `agent-bundle.manifest.json` 声明并从产物原样复制的 +`bin/.mjs`。因此 tarball 只有一个插件根和一个路由式 CLI,没有嵌套的 `artifact/` 目录或 +相对包的安装器包装器。没有任何 npm 生命周期会执行宿主安装。 ```sh npx agent-bundle prepack --root . --output artifact --json diff --git a/website/docs/zh/guide/distribution/index.mdx b/website/docs/zh/guide/distribution/index.mdx index a1783cc89..cf29b7a6b 100644 --- a/website/docs/zh/guide/distribution/index.mdx +++ b/website/docs/zh/guide/distribution/index.mdx @@ -37,8 +37,9 @@ npx agent-bundle build --root . --output artifact 保留各自的源码证据。 编译产出面只构建一次,归属于**组合身份**——所选宿主按名称排序并以 `+` 连接,例如 `claude+codex`—— -绝不会逐宿主各编译一次。两个阶段与 `dist/` 包构建以同样的方式合成打包器配置——profile、`tools.rsbuild`、 -`tools.rspack`,最后是框架不变量——见 [`tools`](../../reference/configuration.mdx#tools)。 +绝不会逐宿主各编译一次。两个阶段与包专用的库入口、手写 bin 以同样的方式合成打包器配置——profile、 +`tools.rsbuild`、`tools.rspack`,最后是框架不变量——见 +[`tools`](../../reference/configuration.mdx#tools)。 `agent-bundle inspect --bundler` 会打印合成结果;它的 `distPath.root` 就是产物输出本身。 ## 根目录里发布了什么 @@ -67,9 +68,13 @@ Claude 与 Codex 投影始终包含本地市场清单,正是这一点让它们 ## 面向 npm 的那一半 -同时作为 npm 包发布的项目还有第二份输出:`dist/bin/.js` 可执行文件与一个库入口,见 -[包入口](../authoring/package-entries.mdx)。当包输出与至少选中了一个内置宿主的根目录被一起构建时,框架 -还会输出一个相对包的安装器 bin ——见[宿主安装](./installation.mdx)。 +同时作为 npm 包发布的项目还有第二份输出 `dist/`,但它不是第二次插件构建。`dist/` 就是 npm 包根目录: +它包含完整的组合产物、`package.json`、标准包文档,以及[包入口](../authoring/package-entries.mdx)所述的 +包专用库入口或手写 bin。 + +对于生成的路由式 CLI,已安装包的 `bin` 直接指向 `bin/.mjs`;该文件从已校验产物原样复制。因此 +npm CLI 与产物 CLI 拥有完全相同的命令(包括 `web`)和完全相同的字节。tarball 中没有嵌套的 +`artifact/` 目录;框架不会并行编译另一份 `dist/bin/.js` 应用,也不会生成相对包的安装器 bin。 `agent-bundle prepack` 是这一半的门禁:它运行发布构建、以不执行脚本的方式 dry-run `npm pack`,并核实 打包后的输出、产物哈希、bin 与版本号。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 7f49fce50..a032a0ecf 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -1,5 +1,5 @@ --- -description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cursor:install 命令、宿主作用域、独立的 install.mjs、相对包的安装器 bin,以及 Doctor。' +description: '把已构建的 agent-bundle 安装进 Claude Code、Codex 或 Cursor:install 命令、宿主作用域、独立的 install.mjs、npm CLI 一致性,以及 Doctor。' --- # 宿主安装 @@ -61,19 +61,12 @@ node ./install.mjs 产物校验会拒绝缺少必需安装表面的根目录,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 -## 相对包的安装器 bin +## npm 安装的 CLI -当包输出与选中了内置宿主的根目录一起发布时,构建还会输出一个相对包的安装器 bin。当没有配置的 bin 占用该名字时,它使用 -插件名,否则使用 `-install`(两者都被占用时追加数字后缀)。请在 `package.json` 中把这个 -名字映射到生成的 `dist/bin/*.js` 文件;消费者随后运行: - -```sh - install [--scope ] [--json] -``` - -帮助信息只列出实际构建出来的宿主。该可执行文件通过 `import.meta.url` 在已安装包旁边定位产物目录,绝不 -依赖调用方的工作目录,因此无论从哪里调用,它在 `node_modules` 中都能工作。**任何 npm 生命周期都不会执行 -安装**——安装一个包绝不会改动宿主的插件状态。 +npm 包根目录本身就包含组合插件根。对于生成的路由式 CLI,`package.json` 的 `bin` 指向产物公开的同一个 +`bin/.mjs` 可执行文件,不会再次编译,也没有包装器。因此它的完整命令面(包括 `web`)从 +`node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 +绝不会改动宿主的插件状态。 ## 开发期安装是另一回事 @@ -83,7 +76,7 @@ node ./install.mjs ## 重建后重新安装 -每个输出的安装器——`agent-bundle install `、相对包的 bin 与独立的 `install.mjs`——共用同一套替换策略。 +每个输出的安装器——`agent-bundle install ` 与独立的 `install.mjs`——共用同一套替换策略。 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录 ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 6817da30a..dd68b6f79 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -23,7 +23,8 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。 自包含性首先由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面 -以及每个包构建入口(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs` 与 `lib` 入口)。框架自有的 +以及包专用入口(手写 bin 或 `lib` 入口)。npm 根目录复用已校验产物中由清单声明的路由式 CLI 及其渲染式 +路由 Flight worker。框架自有的 `ArtifactDependencyAuditPlugin` 会接入 `thisCompilation`,记录 Rspack 保持外部化的每个模块,服务则在 信任任何产物之前读取这份证据。只有 Node 内建模块(`node:fs`、`fs`)、`pnpapi` 以及同一产物已输出的同级 文件可以保持外部化;其他请求无论被 Rspack 输出成 `import`、`require` 还是其 `createRequire` shim,都会 @@ -185,7 +186,7 @@ bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及 脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 这种内联是编译器的契约,而不是一次工具链升级就可能翻转的打包器默认值。生成的可执行文件——产物脚本、路由式 -CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 +CLI、MCP 入口、钩子包装层以及包专用 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 `splitChunks: false` 的配置编译,框架自身也不添加任何 `externals`;Rslib 的 `node` target 只把 Node 内建模块(`node:fs`、`path`,以及 Yarn PnP 的 `pnpapi`)保持外部化。MCP App 视图则把每个脚本与样式都 内联进同一个 HTML 文件,同一个审计插件也位于它们的 Rsbuild 编译器中:浏览器文档不允许任何 external, @@ -206,9 +207,9 @@ CLI、MCP 入口、钩子包装层以及包构建的 JavaScript bundle——都 | 代码 | 含义 | | --- | --- | -| `AB7010` | dry-run 的 npm 清单遗漏了某个包输出、产物清单或文件、安装表面,或 README。请把 `dist` 与产物目录加入包的 `files` 允许列表。 | +| `AB7010` | dry-run 的 npm 清单遗漏了某个包输出、产物清单或文件、安装表面,或 README。请打包生成的 `dist/` npm 根目录,且不要排除其中的文件。 | | `AB7011` | 磁盘上的某个产物文件与其清单 SHA-256 不再匹配。请重新构建,且不要修改生成的宿主包。 | -| `AB7012` | 某个 `package.json` bin 指向了打包后的 `dist` 输出之外(包括 `src/`),或指名了一个被 npm 忽略的文件。请把它指向生成的 `dist/bin` 文件。 | +| `AB7012` | 某个 `package.json` bin 指向了打包后的 npm 根目录之外(包括 `src/`),或指名了一个被 npm 忽略的文件。路由式 CLI 应指向清单声明的 `bin/.mjs`;手写 bin 应指向生成的 `bin/*.js`。 | | `AB7013` | `package.json`、规范化后的插件元数据、某份宿主清单或产物 provenance 报告了不同的发布版本。请让每处发布标识一致。 | | `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此 `import` 证据只来自预构建 payload 模块以及其他框架没有编译的脚本,而 `require`、`createRequire` 与 `import.meta.resolve` 调用则可来自任何打包后的文件),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它,也没有任何预构建 payload 在 `runtimeDependencies` 中声明它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | | `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。`optionalDependencies` 中的抓取类项只发出警告而不会失败,因为 npm 会继续安装;不可解析的项,或消费者安装脚本需要的项(运行其命令——而非仅仅提及它——在内联的 `node -e` 程序中加载它——该程序像已打包文件一样被扫描 `require`、`createRequire` 与 `import()`——用 `node -r`/`--require`/`--import`/`--loader` 预加载它,或运行某个导入它的已打包文件,包括通过根 `main` 运行的 `node .`;`&&`、`;` 或换行之后的每条命令单独判读,shell 引号与反斜杠转义均已解析,`node` 的选项只属于 `node` 且到程序为止——`node install.js --require x` 不会预加载任何东西,而同一条 `node` 命令上的 `NODE_OPTIONS=--require=x` 赋值会;脚本中每个点名已打包 JavaScript 文件的词都视为被运行,这是有意为之,使本闸门未建模的运行器——`tsx`、`zx`、`bun`、`deno run`——所运行文件的依赖仍被追踪,代价是像 `echo install.js` 这样的词偶有误升级,诊断会点名该文件使之可见),仍是错误。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断;npm 从不读取重复 peer 的选择器。`workspace:`/`catalog:` 同样会被报告,除非 `prepack` 生命周期运行在 pnpm、Yarn 或 Bun 之下——它们会在打包时重写这些协议;`npm publish` 不会重写,因此在 npm 下 `AB7015` 会触发;tarball 自身携带的项——`bundleDependencies` 中 npm 实际打包进去的项(peer 除外),以及已打包来源可供安装的包内 `file:` 路径(带有可解析 `package.json` 的目录,或格式正确且其 `package.json` 可解析的 tarball)——不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 3e6aac2cd..21c29ce5e 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -178,7 +178,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | -输出的独立 `install.mjs` 与相对包的安装器 bin 接受同样的 `--replace`。Cursor 副本携带安装回执 +输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 先运行 `claude plugin uninstall --keep-data` 再重新安装,Codex 则先 `codex plugin remove` 再 `add`。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 @@ -212,7 +212,7 @@ uninstall 只删除回执归属的内容:记录的文件与安装器创建的 空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除 `state/` 与 `plugins/data//`);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 -keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 +keep-data 选项)。输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 ## doctor From 13385dbdb2da546fff8ab1933a2f9dbce65fa85d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:48:10 +0000 Subject: [PATCH 02/11] docs(changeset): reference PR 639 --- .changeset/canonical-npm-root.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index c2fad1783..739e5add8 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#TBD). +Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#639). From 2fa71d4272d712d0a69de479d49ea07ca37f8df0 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:16:23 -0700 Subject: [PATCH 03/11] feat(runtime): separate the framework state root from the plugin code root (#640) * feat(runtime): separate the framework state root from the plugin code root Artifact-hosted shells (stdio MCP entry, Flight worker, artifact CLI bin and render worker, standalone hook wrappers) resolve their state root under ~/.agent-bundle/state/- (or $XDG_STATE_HOME/agent-bundle/...) instead of /state, so a read-only install launches and two installs never share state. AGENT_BUNDLE_PLUGIN_ROOT keeps naming the code root; AGENT_BUNDLE_STATE_ROOT overrides the state root. Closes #637 * chore: name #640 in the changeset * fix(state-root): anchor script workers on the artifact, pin dev sessions to /state, ignore relative XDG_STATE_HOME Self-review findings: rendered script workers kept the cwd fallback; dev and Workbench MCP sessions would have derived one user-data root per build epoch; a relative XDG_STATE_HOME would have anchored state on the shell's cwd. uninstall --purge-data / doctor coverage of the derived root is #641. * docs: scope --purge-data to legacy state, fix zh plugin.value.root, XDG absolute-path wording --- .changeset/637-state-root.md | 6 + docs/entry-conventions.md | 62 ++++-- docs/framework-mode.md | 6 +- .../agent-bundle.config.ts | 28 +++ .../fixtures/durable-web-surface/package.json | 5 + .../durable-web-surface/src/cli/entries.ts | 25 +++ .../src/mcp/journal/tools/record.tsx | 34 +++ .../fixtures/durable-web-surface/src/state.ts | 15 ++ .../durable-web-surface/views/status.html | 10 + .../durable-web-surface/views/status.ts | 1 + .../src/adapters/hook-contract.ts | 8 +- packages/agent-bundle/src/api.ts | 23 +- packages/agent-bundle/src/build/cli-bins.ts | 7 +- packages/agent-bundle/src/build/entries.ts | 6 +- .../agent-bundle/src/build/entry-shell.ts | 51 +++-- .../src/build/launch-env-shell.ts | 6 +- .../agent-bundle/src/core/state-inspection.ts | 2 +- packages/agent-bundle/src/core/types.ts | 11 +- .../src/dev/mcp-session/mcp-session-launch.ts | 9 +- packages/agent-bundle/src/index.ts | 2 +- packages/agent-bundle/src/routes/public.ts | 15 +- packages/agent-bundle/src/services/mcp-run.ts | 10 +- packages/agent-bundle/src/web-host/launch.ts | 25 ++- .../tests/cli-routes-build.test.ts | 6 +- .../agent-bundle/tests/entry-shell.test.ts | 26 ++- .../agent-bundle/tests/inspect-state.test.ts | 2 +- .../tests/mcp-session-service.test.ts | 6 +- .../tests/packed-readonly-state-root.test.ts | 205 ++++++++++++++++++ .../tests/packed-stdio-projection.test.ts | 11 +- .../tests/route-manifest-routes.test.ts | 2 +- .../tests/rstest-worker-isolation.test.ts | 7 + .../agent-bundle/tests/web-launch.test.ts | 18 ++ packages/rsc-runtime/src/agent-request.ts | 13 +- packages/rsc-runtime/src/plugin-root.ts | 96 ++++++-- packages/rsc-runtime/src/plugin.ts | 12 +- .../rsc-runtime/tests/plugin-root.test.ts | 154 ++++++++++++- .../tests/route-manifest-client.test.ts | 2 +- rstest.integration-tests.ts | 1 + rstest.worker-isolation.ts | 5 + website/docs/en/guide/authoring/mcp.mdx | 37 ++-- .../en/guide/authoring/package-entries.mdx | 6 +- .../en/guide/distribution/installation.mdx | 18 +- website/docs/en/reference/cli.mdx | 15 +- .../docs/en/reference/runtime-environment.mdx | 30 ++- website/docs/zh/guide/authoring/mcp.mdx | 18 +- .../zh/guide/authoring/package-entries.mdx | 6 +- .../zh/guide/distribution/installation.mdx | 10 +- website/docs/zh/reference/cli.mdx | 13 +- .../docs/zh/reference/runtime-environment.mdx | 24 +- 49 files changed, 903 insertions(+), 207 deletions(-) create mode 100644 .changeset/637-state-root.md create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/package.json create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/src/state.ts create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/views/status.html create mode 100644 packages/agent-bundle/fixtures/durable-web-surface/views/status.ts create mode 100644 packages/agent-bundle/tests/packed-readonly-state-root.test.ts diff --git a/.changeset/637-state-root.md b/.changeset/637-state-root.md new file mode 100644 index 000000000..434709d6b --- /dev/null +++ b/.changeset/637-state-root.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +"@agent-bundle/runtime": minor +--- + +Separate the generated plugin's state root from its code root: make every artifact-hosted shell (the stdio MCP entry, Flight worker, artifact CLI bin and render worker, rendered script workers, and standalone hook wrappers) keep the SQLite state kernel, notice ledger, and lineage journal under `~/.agent-bundle/state/-` (`$XDG_STATE_HOME/agent-bundle/-` when `XDG_STATE_HOME` is an absolute path) instead of `/state`, so a read-only install launches and two installs never share state; keep `AGENT_BUNDLE_PLUGIN_ROOT` naming the installed code root and let `AGENT_BUNDLE_STATE_ROOT` override the state root; add `stateAnchor` and `home` options and `stateSource` to `resolvePluginRoot`, and export `PLUGIN_STATE_ROOT_ENV_ANCHOR`, `pluginStateSegment`, `userStateHome`, and `userDataStateRoot` from `@agent-bundle/runtime` and `pluginStateRootEnvAnchor` from `agent-bundle`. State existing installs wrote beneath the plugin root is not migrated: after upgrading and rebuilding, an installed plugin starts from an empty state root; set `AGENT_BUNDLE_STATE_ROOT=/state` to keep using it. `agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `/state`, so their state still lives beside the build epoch; `uninstall --purge-data` and `doctor` still address only the legacy `/state` directory (#641). (#640) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 67d1efc80..f92b1ba4c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -150,23 +150,33 @@ Volatile lifetimes use the memory driver. Request lifetime opens and releases fresh project and notice stores per invocation; process lifetime shares them for the generated worker or executable process. -Workspace-durable generated MCP workers store under -`$AGENT_BUNDLE_PLUGIN_ROOT/state`. If that host-provided anchor is absent, -the worker derives the artifact root from the parent of its own `mcp/` -directory. The npm package's routed CLI bin and rendered scripts use -`$AGENT_BUNDLE_PLUGIN_ROOT/state` when present and otherwise -`$PWD/.agent-bundle/state`; the artifact-hosted routed CLI bin -(`bin/.mjs` in the plugin root) derives the artifact root from the parent of its -own `bin/` directory instead, like the MCP worker. Each generated process -resolves that anchor exactly once (`resolvePluginRoot` from -`@agent-bundle/runtime`, #468): the state kernel, the notice ledger, the -lineage journal, and every request scope the process opens read the same -value, published as `(await agent()).plugin` — `{ root, stateRoot }` with -`source: 'native'` from `AGENT_BUNDLE_PLUGIN_ROOT` or `'derived'` from the -fallback — and handed to conventional providers as `plugin` beside -`invocation` and `signal`. An anchor still carrying an unexpanded `${…}` -token is treated as unset (reported once on stderr), never joined into a -path. Notice authorization is deliberately permissive +Workspace-durable artifact shells — generated MCP workers, artifact CLI bins +and their render workers, rendered script workers, and standalone hook +wrappers — call `resolvePluginRoot` +with `stateAnchor: 'user-data'`. `AGENT_BUNDLE_PLUGIN_ROOT` still names the +code root and otherwise falls back to the artifact root derived from the +shell's own location. An expanded, non-blank `AGENT_BUNDLE_STATE_ROOT` +independently overrides the framework state root and is made absolute with +`resolve()`; otherwise the state root is +`~/.agent-bundle/state/-`, or +`$XDG_STATE_HOME/agent-bundle/-` when `XDG_STATE_HOME` is an absolute path (a relative value is ignored). +`` is the code root's safe basename (or `plugin`) and `` +is the first 16 hexadecimal characters of SHA-256 over that code root's +realpath, so symlinked spellings share one state root while distinct installs +do not. `resolvePluginRoot` uses `os.homedir()` unless its `home` test seam is +supplied. The npm package's routed CLI bin and rendered scripts keep the default +`stateAnchor: 'root'`: `$AGENT_BUNDLE_PLUGIN_ROOT/state` when supplied and +otherwise `$PWD/.agent-bundle/state`. + +Each generated process resolves both roots exactly once +(`resolvePluginRoot` from `@agent-bundle/runtime`, #468): the state kernel, +notice ledger, lineage journal, and every request scope the process opens read +the same `stateRoot`, published with the code `root` as +`(await agent()).plugin` and handed to conventional providers as `plugin` +beside `invocation` and `signal`. `source` records whether the code root was +native or derived; `stateSource` does the same independently for the state +root. An unexpanded `${…}` token in either root override is treated as unset +(and reported once on stderr), never joined into a path. Notice authorization is deliberately permissive in generated mounting v1 (`authorized`); recipient/principal matching remains enforced by the ledger — every generated scope mounts the request's `lineage` on the notice principal, so `recipient.conversation` / `recipient.root` are @@ -272,7 +282,7 @@ interface AgentProviderContext { host: Observed<{ name }>; // exactly what the route reads on `await agent()` session: Observed<{ sessionId }>; workspace: Observed<{ root }>; - plugin: Observed<{ root; stateRoot }>; // the resolved plugin root (#468) + plugin: Observed<{ root; stateRoot }>; // resolved code and framework state roots (#468) lineage: Observed; // own chain plus the live `tree` (#457) state?: { lifetime; read(options?) }; // the mounted state handle, `read` only notices?: { inbox(); published() }; // the request's notice handle, reads only @@ -911,10 +921,12 @@ executable bit — invoke it as `node /bin/.mjs `, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals are identical to the package bin. One deliberate difference: workspace-durable state without a host-supplied -`AGENT_BUNDLE_PLUGIN_ROOT` anchors on the **artifact root** (the parent of -`bin/`, the same fallback the generated MCP worker beside it uses) rather -than `$PWD/.agent-bundle/state`, so a co-installed CLI and server observe -one store. The npm package bin keeps its `cwd` fallback. +`AGENT_BUNDLE_STATE_ROOT` uses `stateAnchor: 'user-data'`, deriving +`~/.agent-bundle/state/-` (or the `XDG_STATE_HOME` equivalent) +from the artifact code root. The generated MCP worker beside it makes the +same derivation, so a co-installed CLI and server observe one store without +writing beneath a read-only artifact. The npm package bin keeps +`stateAnchor: 'root'` and its `cwd` fallback, `$PWD/.agent-bundle/state`. Reaching the bin from the other surfaces: @@ -1652,7 +1664,11 @@ input, and a refresh rebinds that retained result. ` web` keeps the installed artifact immutable: framework-owned per-server web state (`${PLUGIN_DATA}` in declared env) lives under the user's home (`~/.agent-bundle/web-data/-/`), never inside the -plugin root, so a read-only install still launches. +plugin root. The spawned server's SQLite state kernel, notice ledger, and +lineage journal likewise use `stateAnchor: 'user-data'` and live under +`~/.agent-bundle/state/-` (or the `XDG_STATE_HOME` equivalent) +unless `AGENT_BUNDLE_STATE_ROOT` overrides it, so a read-only install still +launches. ## `agent-bundle/app` — the App-side bridge client diff --git a/docs/framework-mode.md b/docs/framework-mode.md index f452e1741..65df08e6e 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -635,7 +635,8 @@ manifest. A root whose selection includes `cursor` or `portable` also includes a standalone `install.mjs`. Its staged copy is idempotent for identical content, records an install receipt (`.agent-bundle-install.json`: plugin, version, content hash, owned files and directories), replaces a same-version stale copy of its -own plugin in place (owned files only; `state/` survives), and accepts +own plugin in place (owned files only; legacy `state/` survives, while current builds keep +framework state outside the plugin root), and accepts `--replace` (alias `--force`) to replace a different installed version or adopt a pre-receipt copy. Foreign directories are refused with a content-hash comparison. It never invokes sudo or changes PATH. `agent-bundle install @@ -669,7 +670,8 @@ node artifact/install.mjs --uninstall [--plan] [--mode marketplace] Uninstall removes exactly what the receipt owns and reverses exactly the registrations it recorded; anything else stays and is listed as retained. -Durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge`; +Legacy durable runtime state (`state/`) is kept unless `--purge-data --confirm-purge` +(current builds keep framework state outside the plugin root); the typed `data.outcome` says what the host itself decided where Agent Bundle cannot (`retained-by-host` for Claude's ~14-day orphaned copy, `removed-by-host` / `unavailable` for Codex, which has no keep-data option). A diff --git a/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts new file mode 100644 index 000000000..67bec4d46 --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/agent-bundle.config.ts @@ -0,0 +1,28 @@ +// Plain object export keeps this packed fixture independent of the package +// build, like web-surface. The generated `journal` server (src/mcp/journal) +// carries the config-declared status App, and src/state.ts makes the project +// workspace-durable: the packed read-only-install proof spawns its entry and +// its CLI bin against an artifact nothing may write beneath (#637). +export default { + mcp: { + servers: { + journal: { + apps: { + status: { + entry: './views/status.ts', + resourceUri: 'ui://durable-web-surface-fixture/status.html', + targets: ['portable'], + template: './views/status.html', + }, + }, + }, + }, + }, + plugin: { + description: 'A workspace-durable plugin whose MCP App is exposed through web.apps and whose CLI reads the same state.', + name: 'durable-web-surface-fixture', + version: '1.0.0', + }, + targets: ['portable'], + web: { apps: [{ allow: ['call-tool'], app: 'journal/status' }] }, +}; diff --git a/packages/agent-bundle/fixtures/durable-web-surface/package.json b/packages/agent-bundle/fixtures/durable-web-surface/package.json new file mode 100644 index 000000000..a5879a15f --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/package.json @@ -0,0 +1,5 @@ +{ + "name": "durable-web-surface-fixture", + "private": true, + "type": "module" +} diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts b/packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts new file mode 100644 index 000000000..3ab3d63bf --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/cli/entries.ts @@ -0,0 +1,25 @@ +import { agent } from '@agent-bundle/runtime'; +import type { CliRouteConfig } from 'agent-bundle'; +import { z } from 'zod'; + +export const config = { + description: 'Lists the journal entries the MCP record tool has written.', +} satisfies CliRouteConfig; + +export const inputSchema = z.object({}).strict(); + +export const resultSchema = z.object({ + entries: z.array(z.object({ note: z.string() }).strict()), + revision: z.number().int().nonnegative(), +}).strict(); + +interface JournalState { + readonly entries: readonly { readonly note: string }[]; +} + +export default async function entries() { + const context = await agent(); + if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); + const snapshot = await context.state.read(); + return { entries: (snapshot.state as JournalState).entries, revision: snapshot.revision }; +} diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx b/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx new file mode 100644 index 000000000..6deb3ee79 --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/mcp/journal/tools/record.tsx @@ -0,0 +1,34 @@ +import { Agent, agent } from '@agent-bundle/runtime'; +import { z } from 'zod'; + +export const config = { + _meta: { ui: { resourceUri: 'ui://durable-web-surface-fixture/status.html' } }, + description: 'Appends one note to the durable journal and reports every entry.', + title: 'Record', +}; + +export const inputSchema = z.object({ note: z.string().min(1) }).strict(); + +export const resultSchema = z.object({ + entries: z.array(z.object({ note: z.string() }).strict()), + revision: z.number().int().nonnegative(), +}).strict(); + +interface JournalState { + readonly entries: readonly { readonly note: string }[]; +} + +export default async function Record({ input }: { readonly input: z.infer }) { + const context = await agent(); + if (context.state === undefined) throw new TypeError('Journal state is unavailable.'); + // The note is the idempotency key: a replayed note is recorded once. + await context.state.dispatch('recorded', { note: input.note }, { idempotencyKey: `record:${input.note}` }); + const snapshot = await context.state.read(); + const state = snapshot.state as JournalState; + const result = { entries: state.entries, revision: snapshot.revision }; + return ( + + {`recorded ${String(state.entries.length)} note(s)`} + + ); +} diff --git a/packages/agent-bundle/fixtures/durable-web-surface/src/state.ts b/packages/agent-bundle/fixtures/durable-web-surface/src/state.ts new file mode 100644 index 000000000..a6ffeea3e --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/src/state.ts @@ -0,0 +1,15 @@ +import { defineState } from '@agent-bundle/runtime/state'; +import { z } from 'zod'; + +const entrySchema = z.object({ note: z.string() }).strict(); + +export default defineState({ + events: { + recorded: entrySchema, + }, + id: 'durable-web-surface/journal', + initial: { entries: [] }, + lifetime: 'workspace-durable', + reduce: (state, event) => ({ entries: [...state.entries, event.payload] }), + schema: z.object({ entries: z.array(entrySchema) }).strict(), +}); diff --git a/packages/agent-bundle/fixtures/durable-web-surface/views/status.html b/packages/agent-bundle/fixtures/durable-web-surface/views/status.html new file mode 100644 index 000000000..ff379212c --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/views/status.html @@ -0,0 +1,10 @@ + + + + + Status + + +
+ + diff --git a/packages/agent-bundle/fixtures/durable-web-surface/views/status.ts b/packages/agent-bundle/fixtures/durable-web-surface/views/status.ts new file mode 100644 index 000000000..d0087b06b --- /dev/null +++ b/packages/agent-bundle/fixtures/durable-web-surface/views/status.ts @@ -0,0 +1 @@ +document.querySelector('#view')!.textContent = 'durable-web-surface fixture status'; diff --git a/packages/agent-bundle/src/adapters/hook-contract.ts b/packages/agent-bundle/src/adapters/hook-contract.ts index 741f40f61..41a59b98b 100644 --- a/packages/agent-bundle/src/adapters/hook-contract.ts +++ b/packages/agent-bundle/src/adapters/hook-contract.ts @@ -708,9 +708,11 @@ const eventRouteHookWrapperSource = ( 'const fail = (message) => { throw new Error(`Agent Bundle event route error: ${message}`); };', ...(standalone ? [ - // The wrapper lives in `hooks/`, so its artifact root is the parent - // directory — the same anchor the generated MCP entry resolves (#468). - "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });", + // The wrapper lives in `hooks/`, so its code root is the parent + // directory; the state root derives from it in the user state + // directory — the same two roots the generated MCP entry resolves + // (#468, #637), so the lineage journal it retires is the server's. + "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });", 'const renderStandalone = async (invocation, signal) => {', ' const worker = new Worker(new URL(/* webpackIgnore: true */ "./hooks-flight.mjs", import.meta.url), { stderr: true, stdout: true });', " worker.stdout?.on('data', (chunk) => process.stderr.write(chunk));", diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 4e9c14d7b..b17914618 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -672,11 +672,11 @@ export interface RunMcpOptions extends ArtifactOperationOptions { /** Set false to launch the server without any `.env` layer. */ readonly loadEnvFiles?: boolean; /** - * Root the env-declared plugin-root anchors (for example - * `AGENT_BUNDLE_PLUGIN_ROOT`) expand to. Defaults to the project root so - * durable server state survives artifact rebuilds; point it at the - * artifact target root for a byte-faithful rehearsal of a copied-artifact - * launch. + * Root the env-declared `AGENT_BUNDLE_PLUGIN_ROOT` expands to. Defaults to + * the project root so the derived state root (keyed by that root) survives + * artifact rebuilds; `AGENT_BUNDLE_STATE_ROOT` in the operator environment + * overrides the state location. Point it at the artifact target root for a + * byte-faithful rehearsal of a copied-artifact launch. */ readonly pluginRoot?: string; readonly server: string; @@ -1478,12 +1478,13 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise/`, and env-declared - * plugin-root anchors expand to the project root itself (override with - * `pluginRoot`). The launch environment layers, lowest to highest: manifest - * env, the project-root `.env` set (or `envFiles`), the operator's real - * `process.env`. + * Env-declared `AGENT_BUNDLE_PLUGIN_ROOT` expands to the project root by + * default so the derived state root (keyed by that root) survives artifact + * rebuilds; `AGENT_BUNDLE_STATE_ROOT` in the operator environment overrides + * the state location (override the plugin-root expansion with `pluginRoot`). + * Plugin-data state persists under `.agent-bundle/mcp-run//`. + * The launch environment layers, lowest to highest: manifest env, the + * project-root `.env` set (or `envFiles`), the operator's real `process.env`. */ export const runMcp = async (options: RunMcpOptions): Promise => { const registry = registryFor(options); diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index e7960facf..d2314782a 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -182,9 +182,10 @@ export const cliBinRslibEntries = ( ...(cli.projectionSources === undefined ? {} : { projectionSources: cli.projectionSources }), routes: cli.routes, ...(model.state === undefined ? {} : { state: model.state }), - // Durable state anchors on the artifact root (the parent of `bin/`), - // the same fallback the generated MCP worker beside it uses, so a - // co-installed CLI and server observe one store. + // The code root falls back to the artifact root (the parent of `bin/`) + // and the state root derives from it, the same two roots the generated + // MCP worker beside it resolves, so a co-installed CLI and server + // observe one store. stateFallback: 'artifact', ...(entry.bin.web === true ? { diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 4aa939b21..4d10c355b 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -225,7 +225,11 @@ export const planScriptsSurface = async ( source, }], ...(options.noticeRetention === undefined ? {} : { noticeRetention: options.noticeRetention }), - ...(options.state === undefined ? {} : { state: options.state }), + ...(options.state === undefined ? {} : { state: options.state }), + // The worker lives in `scripts/`, one directory below the + // artifact root, like `bin/` and `mcp/`: same code root, same + // derived state root as the MCP worker of the install. + stateFallback: 'artifact', }), }), ]; diff --git a/packages/agent-bundle/src/build/entry-shell.ts b/packages/agent-bundle/src/build/entry-shell.ts index f2d513adb..500c5875a 100644 --- a/packages/agent-bundle/src/build/entry-shell.ts +++ b/packages/agent-bundle/src/build/entry-shell.ts @@ -189,11 +189,14 @@ export const generatedInstallBinEntrySource = (options: { ].join('\n'); /** - * Where workspace-durable state anchors when the host supplies no - * `AGENT_BUNDLE_PLUGIN_ROOT`: `cwd` (the caller's `.agent-bundle/state`, the - * npm package bin's contract) or `artifact` (the parent of the executable's - * own directory — the target root — which the artifact-hosted routed CLI - * shares with the generated MCP worker beside it). + * The code root a generated module falls back to when the host supplies no + * `AGENT_BUNDLE_PLUGIN_ROOT`, and with it where its state root derives absent + * `AGENT_BUNDLE_STATE_ROOT`: `cwd` (the caller's `.agent-bundle`, the npm + * package bin's contract; state stays under `/state`) or `artifact` + * (the parent of the executable's own directory — the target root — shared by + * the artifact-hosted CLI, its render worker, the MCP entry and the Flight + * worker; state goes to the user state directory keyed by that root, never + * into the installed, possibly read-only artifact). */ export type GeneratedStateFallback = 'artifact' | 'cwd'; @@ -208,7 +211,7 @@ export interface GeneratedCliBinEntryOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - /** Durable-state anchor fallback; defaults to `cwd` (the npm package bin). */ + /** Code-root fallback; defaults to `cwd` (the npm package bin). */ readonly stateFallback?: GeneratedStateFallback; readonly web?: { readonly manifestRelativeUrl: string; @@ -243,16 +246,22 @@ const pluginRootFallbackExpression = ( : "join(process.cwd(), '.agent-bundle')"; /** - * The one plugin-root resolution of a generated module (#468): the SQLite + * The one plugin-root resolution of a generated module (#468, #637): the code + * root (`AGENT_BUNDLE_PLUGIN_ROOT`, else the fallback) and the state root + * (`AGENT_BUNDLE_STATE_ROOT`, else derived from the code root). The SQLite * kernel, the notice ledger, the lineage journal, and every request scope the * module opens read `pluginRoot`, so `(await agent()).plugin.stateRoot` is the - * directory they mount by construction. + * directory they mount by construction. An artifact-anchored module derives + * its state root in the user state directory keyed by the code root; the npm + * bin keeps `/state`. */ const pluginRootDeclaration = ( fallback: GeneratedStateFallback, relativeUrl?: string, ): string => - `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback, relativeUrl)} });`; + `const pluginRoot = resolvePluginRoot({ fallback: ${pluginRootFallbackExpression(fallback, relativeUrl)}${ + fallback === 'artifact' ? ", stateAnchor: 'user-data'" : '' + } });`; const generatedStateImports = ( state: NormalizedStateDefinition | undefined, @@ -579,7 +588,7 @@ export interface GeneratedRenderedRouteWorkerOptions { /** The project's resolved `notices.retention`; the runtime defaults apply when absent. */ readonly noticeRetention?: NormalizedNoticeRetentionPolicy; readonly state?: NormalizedStateDefinition; - /** Durable-state anchor fallback; defaults to `cwd` and must match the owning executable. */ + /** Code-root fallback; defaults to `cwd` and must match the owning executable. */ readonly stateFallback?: GeneratedStateFallback; } @@ -911,8 +920,8 @@ const wiresResourceUpdatedRoute = (options: NoticeRouteSelection): boolean => options.noticeDelivery?.['mcp-resource-updated'].state === 'supported'; /** - * The server process's own handle on the durable notice store. The anchor - * resolution matches the worker's so both open the same files. + * The server process's own handle on the durable notice store. Both roots + * resolve as in the worker, so both open the same files. */ const noticeDeliveryImports = (wired: boolean): readonly string[] => wired @@ -1078,9 +1087,9 @@ export const generatedRouteFlightWorkerSource = (options: GeneratedRouteFlightWo 'process.stdout.write = process.stderr.write.bind(process.stderr);', `const ARTIFACT_EPOCH = ${JSON.stringify(options.artifactEpoch)};`, 'const processLifetime = { hits: 0, instanceId: crypto.randomUUID(), pid: process.pid };', - // The worker resolves the same anchor as the server process beside it - // (same environment, same artifact layout); the server's observed value - // rides each render message and wins when present. + // The worker resolves the same code and state roots as the server process + // beside it (same environment, same artifact layout); the server's + // observed value rides each render message and wins when present. pluginRootDeclaration('artifact'), ...generatedStateOwner(options.state, options), ...providerRegistrySource(providers), @@ -1220,10 +1229,10 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti const wiresInbox = wiresInboxRoute(options); const wiresResourceUpdated = wiresResourceUpdatedRoute(options); // The lineage registry journals durably only where the project already - // accepted the sqlite kernel and its durable anchor (a workspace-durable + // accepted the sqlite kernel and its state root (a workspace-durable // `src/state.ts`); stateless and volatile projects keep a process-lifetime - // registry so `node:sqlite` never loads for them and no `state/` directory - // appears inside an artifact that declared none. + // registry so `node:sqlite` never loads for them and no state directory + // is created for an artifact that declared none. const durableLineage = options.state?.lifetime === 'workspace-durable'; return [ ...(hasEvents ? ["import { dirname, resolve } from 'node:path';"] : []), @@ -1244,12 +1253,12 @@ export const generatedRouteMcpEntrySource = (options: GeneratedRouteMcpEntryOpti ...routeImports(routes), '', `const ARTIFACT_EPOCH = ${JSON.stringify(artifactEpoch)};`, - // The server process's one anchor (#468): the lineage journal, the notice - // store, and every request identity it publishes read `pluginRoot`. + // The server process's one root resolution (#468): the lineage journal, + // the notice store, and every request identity it publishes read `pluginRoot`. pluginRootDeclaration('artifact'), ...(durableLineage ? [ - // Beside the project's own durable state, so a restarted MCP process + // In the project's own state root, so a restarted MCP process // still knows which subagents are alive. A store that cannot open // degrades to memory rather than failing the server: lineage is an // observed axis, never a precondition. diff --git a/packages/agent-bundle/src/build/launch-env-shell.ts b/packages/agent-bundle/src/build/launch-env-shell.ts index 134d7d366..5419dca09 100644 --- a/packages/agent-bundle/src/build/launch-env-shell.ts +++ b/packages/agent-bundle/src/build/launch-env-shell.ts @@ -48,8 +48,10 @@ export const operatorEnvLayerImports: readonly string[] = [ * The statement that applies the layer. Every artifact shell lives one * directory below the plugin root (`mcp/`, `hooks/`, `bin/`), so the fallback * anchor — used when the host set no `AGENT_BUNDLE_PLUGIN_ROOT` — is the - * bundle's parent directory, the same fallback the durable-state kernel uses - * (`import.meta.url` stays native in the emitted ESM, so it names the bundle). + * bundle's parent directory, the same code-root fallback `resolvePluginRoot` + * uses (`import.meta.url` stays native in the emitted ESM, so it names the + * bundle). This names the code root only; `resolvePluginRoot` derives the + * state root (`AGENT_BUNDLE_STATE_ROOT`, else the user state directory). * * A stdio MCP shell embeds its server's manifest `env` block as build-time * literals (`manifestEnv`): the host merges that block into the child diff --git a/packages/agent-bundle/src/core/state-inspection.ts b/packages/agent-bundle/src/core/state-inspection.ts index e315f0d99..26109ae71 100644 --- a/packages/agent-bundle/src/core/state-inspection.ts +++ b/packages/agent-bundle/src/core/state-inspection.ts @@ -52,7 +52,7 @@ export interface StateDefinitionProjection { } const durableStateLocation = - '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)'; + '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin'; const noticeLedgerInspection = 'Generated runtimes co-mount the notice ledger store at the same lifetime under reserved id @agent-bundle/runtime/agent-notice-ledger/v1.'; diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index d35895def..828e395ed 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -899,9 +899,12 @@ export const pathTokens = Object.freeze({ * MCP server entries, holding the plugin install root in the target's native * representation (`${CLAUDE_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, * `${CURSOR_PLUGIN_ROOT}`, or Codex's `./` resolved against the entry's - * plugin-root cwd). Server runtime code should resolve persistent state and - * bundled assets against it instead of the process working directory, which - * not every host anchors to the plugin root. A user-declared env entry with - * this key always wins over the injected value. + * plugin-root cwd). Server runtime code should resolve bundled assets against + * it instead of the process working directory, which not every host anchors + * to the plugin root. A user-declared env entry with this key always wins + * over the injected value. */ export const pluginRootEnvAnchor = 'AGENT_BUNDLE_PLUGIN_ROOT'; + +/** Explicit override of the framework state root; the runtime exports the same name as `PLUGIN_STATE_ROOT_ENV_ANCHOR`. */ +export const pluginStateRootEnvAnchor = 'AGENT_BUNDLE_STATE_ROOT'; diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts index 0741d69e7..a1a4a8163 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-launch.ts @@ -1,6 +1,7 @@ -import { isAbsolute, resolve } from 'node:path'; +import { isAbsolute, join, resolve } from 'node:path'; import { assertInside } from '../../core/paths.ts'; +import { pluginStateRootEnvAnchor } from '../../core/types.ts'; import { resolveMcpPathTokens } from '../../services/mcp-path-tokens.ts'; import type { ModernMcpServer, TargetMcpRuntimeContract } from '../../services/mcp-runtime.ts'; import type { McpSessionInspectorConfig } from './mcp-session-protocol.ts'; @@ -110,11 +111,15 @@ export const resolveMcpSessionLaunch = (options: ResolveMcpSessionLaunchOptions) const inheritedEnv = Object.fromEntries( Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); + // A dev session runs a build epoch, not an install: its framework state + // lives beside that epoch and goes with it, instead of accumulating one + // user-data root per rebuild. Declared env still wins, as for every key. + const stateRoot = join(options.resolved.targetRoot, 'state'); return Object.freeze({ args: Object.freeze([...resolved.args]), command: resolved.command, cwd, - env: Object.freeze({ ...inheritedEnv, ...(resolved.env ?? {}) }), + env: Object.freeze({ ...inheritedEnv, [pluginStateRootEnvAnchor]: stateRoot, ...(resolved.env ?? {}) }), inspectorEnv: inspectorEnvironment(resolved.env), kind: 'stdio', }); diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 9d619253d..43e177e94 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -4,7 +4,7 @@ import type { CursorConfigExtension } from './adapters/cursor.ts'; import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; -export { defineConfig, definePrebuilt, pathTokens, pluginRootEnvAnchor } from './core/types.ts'; +export { defineConfig, definePrebuilt, pathTokens, pluginRootEnvAnchor, pluginStateRootEnvAnchor } from './core/types.ts'; export { defineSkill, Skill } from './skills/define.ts'; export { classifySkillToken, diff --git a/packages/agent-bundle/src/routes/public.ts b/packages/agent-bundle/src/routes/public.ts index c822d4f20..dbaccc388 100644 --- a/packages/agent-bundle/src/routes/public.ts +++ b/packages/agent-bundle/src/routes/public.ts @@ -132,12 +132,15 @@ export interface AgentProviderWorkspaceIdentity { } /** - * The plugin install root and durable-state anchor a generated scope resolved - * (#468), as `(await agent()).plugin` observes it: `root` is the expanded - * `AGENT_BUNDLE_PLUGIN_ROOT` (`source: 'native'`) or the shell's fallback - * (`'derived'`), and `stateRoot` is `/state`, where the SQLite kernel, - * the notice ledger, and the lineage journal live. Structurally identical to - * the runtime's `AgentPluginIdentity`. + * The plugin code root and framework state root a generated scope resolved + * (#468), as `(await agent()).plugin` observes it: `root` is the code root — + * the expanded `AGENT_BUNDLE_PLUGIN_ROOT` (`source: 'native'`) or the shell's + * fallback (`'derived'`) — and `stateRoot` is the framework state root + * (`AGENT_BUNDLE_STATE_ROOT`, else `~/.agent-bundle/state/-` + * or `$XDG_STATE_HOME/agent-bundle/-` for an installed + * artifact; `/state` for the npm package bin and the test harnesses), + * where the SQLite kernel, the notice ledger, and the lineage journal live. + * Structurally identical to the runtime's `AgentPluginIdentity`. */ export interface AgentProviderPluginRoot { readonly root: string; diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index bd8d8ac94..c9046c288 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -41,11 +41,11 @@ export interface ResolvedMcpStdioLaunch { export interface ResolveMcpStdioLaunchOptions { readonly artifact: string; /** - * Root that plugin-root path tokens in *env values* expand to — the - * durable-state anchors like `AGENT_BUNDLE_PLUGIN_ROOT`. Defaults to - * `workspaceRoot`: under `mcp run` the artifact is an ephemeral build - * product, so anchoring durable state on it would fragment that state per - * rebuild. Point it back at the artifact target root for a byte-faithful + * Root that plugin-root path tokens in *env values* expand to. Env-declared + * `AGENT_BUNDLE_PLUGIN_ROOT` expands to the project root by default so the + * derived state root (keyed by that root) survives artifact rebuilds; + * `AGENT_BUNDLE_STATE_ROOT` in the operator environment overrides the state + * location. Point it at the artifact target root for a byte-faithful * rehearsal of a copied-artifact launch. */ readonly envPluginRoot?: string; diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts index e447c368f..48910abcf 100644 --- a/packages/agent-bundle/src/web-host/launch.ts +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -30,9 +30,12 @@ export class WebLaunchError extends CodedError { const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; /** - * One state segment per installed plugin root: the resolved root's digest - * keys the state, so two installs of the same plugin never share it, and the - * basename stays in front only when it is already a safe path segment. + * One data segment per installed plugin root: the resolved root's digest + * keys the directory, so two installs of the same plugin never share it, and + * the basename stays in front only when it is already a safe path segment. + * Spells the segment like `pluginStateSegment` in `@agent-bundle/runtime`, + * which the web host must not import (optional peer); `web-launch.test.ts` + * pins the two on a canonical root. */ const webPluginStateSegment = (pluginRoot: string): string => { const digest = sha256Hex(pluginRoot).slice(0, 16); @@ -41,9 +44,9 @@ const webPluginStateSegment = (pluginRoot: string): string => { }; /** - * Durable per-server web state, outside the installed artifact: the artifact - * stays immutable (it may be installed read-only), so framework-owned - * writable state anchors under the user's home instead of the plugin root. + * The author-facing `${PLUGIN_DATA}` directory of one server, outside the + * installed artifact: the artifact stays immutable (it may be installed + * read-only), so per-server data anchors under the user's home instead. */ export const webPluginDataDirectory = (pluginRoot: string, server: string, home = homedir()): string => join(home, '.agent-bundle', 'web-data', webPluginStateSegment(resolve(pluginRoot)), mcpServerStateDirectory(server)); @@ -52,10 +55,12 @@ const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); /** - * Declared env overrides inherited env, matching installed hosts. Plugin data - * lives outside the artifact (under the user's home), because the installed - * artifact is immutable — a read-only install must still launch when the - * server declares plugin-data state. + * Declared env overrides inherited env, matching installed hosts. The launch + * names only the code root (`AGENT_BUNDLE_PLUGIN_ROOT`): the spawned shell + * derives its framework state root from it exactly as a host-launched one + * does (`AGENT_BUNDLE_STATE_ROOT` and `XDG_STATE_HOME` pass through as + * inherited env), so a read-only install launches and the web-launched and + * host-launched servers of one install share state. */ export const resolveWebLaunch = async (options: ResolveWebLaunchOptions): Promise => { const pluginRoot = resolve(options.pluginRoot); diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index 938927c5d..e5124d795 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -399,7 +399,11 @@ it('builds and runs the generated routed-CLI executable', { retry: 2, timeout: 1 // The rendered .tsx script (#102 stage 3) ships beside plain scripts in // the target artifact with the same output contract. const scriptPath = join(root, 'artifact', 'scripts', 'summarize.mjs'); - await expect(stat(join(root, 'artifact', 'scripts', 'summarize-flight.mjs'))).resolves.toMatchObject({}); + // Its render worker anchors on the artifact root like the MCP worker and + // derives its state root from it (#637), never `/.agent-bundle/state`. + const scriptWorker = await readFile(join(root, 'artifact', 'scripts', 'summarize-flight.mjs'), 'utf8'); + expect(scriptWorker).toContain("stateAnchor: 'user-data'"); + expect(scriptWorker).not.toContain("join(process.cwd(), '.agent-bundle')"); const scriptMarkdown = await execFile(process.execPath, [scriptPath, 'alpha', 'beta']); expect(scriptMarkdown.stdout).toBe('Summarized 2 arguments.\n'); // The rendered script's provider sees `invocation.kind === 'script'` (#313). diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index 396e24448..d2d65ff84 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -204,6 +204,9 @@ describe('generated entry templates', () => { expect(artifactBin).not.toContain(stdioPreludeSpecifier); expect(artifactBin).not.toContain('applyOperatorEnv'); expect(artifactBin).toContain('import * as route0 from "/project/src/cli/report.ts";'); + // An artifact-hosted bin keeps its state out of the installed artifact: + // the code root is the artifact, the state root the user state directory. + expect(artifactBin).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); const durableBin = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, @@ -238,6 +241,9 @@ describe('generated entry templates', () => { }); expect(npmBin).not.toContain('agent-bundle/launch-env'); expect(npmBin).not.toContain('applyOperatorEnv'); + // The `cwd` fallback stays root-anchored: state lives under `/.agent-bundle/state`. + expect(npmBin).toContain("const pluginRoot = resolvePluginRoot({ fallback: join(process.cwd(), '.agent-bundle') });"); + expect(npmBin).not.toContain('stateAnchor'); }); it('conditionally wires the generated web command without changing non-web entry bytes', () => { @@ -270,7 +276,7 @@ describe('generated entry templates', () => { expect(routed).toContain('import { runWebCommand } from "agent-bundle/web-host";'); expect(routed).toContain("import webHostPage from 'agent-bundle/web-host-page';"); expect(routed).toContain( - "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL(\"../\", import.meta.url)) });", + "const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL(\"../\", import.meta.url)), stateAnchor: 'user-data' });", ); expect(routed).toContain('const artifactRoot = fileURLToPath(new URL("../", import.meta.url));'); expect(routed).toContain([ @@ -328,7 +334,8 @@ describe('generated entry templates', () => { // A routed bin without `web` carries no web wiring: its bytes are those of // the generator without #564 (hash of the same input on this commit's // `entry-shell.ts`; #596's projection steps and `kind: 'cli'` request - // moved the pin from the pre-#564 value). + // moved the pin from the pre-#564 value, #637's `stateAnchor` moved it + // again). const withoutWeb = entryShellModule.generatedCliBinEntrySource({ commands: [command], plugin: { name: 'fixture', version: '1.0.0' }, @@ -336,7 +343,7 @@ describe('generated entry templates', () => { stateFallback: 'artifact', }); expect(createHash('sha256').update(withoutWeb).digest('hex')) - .toBe('b177c34fc9ef98e972b5f5db1296c01219634572a455796fcae30bfaf070ba72'); + .toBe('ad8c21f371af0043464162750a8ed557d968f6155cdd9521ee63c0275253710a'); expect(withoutWeb).not.toContain('agent-bundle/web-host'); expect(withoutWeb).not.toContain('web: Object.freeze({'); }); @@ -624,9 +631,10 @@ it('journals the lineage registry through sqlite only for workspace-durable proj }); expect(source).toContain("import { agentLineageStateDefinition, createAgentLineageRegistry } from '@agent-bundle/runtime/lineage'"); expect(source).toContain("import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'"); - // One anchor per process (#468): the lineage journal opens on the same - // `pluginRoot` the server publishes as `request.plugin` and mounts state on. - expect(source).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + // One root resolution per process (#468): the lineage journal opens on the + // same `pluginRoot` the server publishes as `request.plugin` and mounts + // state on; the state root derives from the user state directory (#637). + expect(source).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); expect(source).toContain('createSqliteStateDriver({ root: pluginRoot.stateRoot })'); expect(source).toContain(' pluginRoot: pluginRoot.identity,'); expect(source).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); @@ -677,7 +685,7 @@ it('generates the warm react-server Flight worker separately from the MCP dispat expect(source).toContain("lineage: message.lineage ?? unavailable('not-provided'),"); expect(source).toContain("terminal: message.terminal ?? unavailable('not-provided'),"); expect(createHash('sha256').update(source).digest('hex')).toBe( - '93cdfe64b98e0add920ed3f4daa3916620a3f750ec9dbcefc6be6419efab38e5', + '4e2c248b5358b7e13650f2156cf282b03f6f7ede20e2badabafc4b33ae5b4bd5', ); expect(generate({ artifactEpoch: 'route-fixture@1.2.3', @@ -1599,7 +1607,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol expect(durableEntry).toContain("import { createGeneratedNoticeRuntime } from '@agent-bundle/runtime/mount';"); expect(durableEntry).toContain("import { createNoticeInboxSignaller } from '@agent-bundle/runtime/notices';"); expect(durableEntry).toContain("import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite';"); - expect(durableEntry).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + expect(durableEntry).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); // The host's advertisement is declared once and handed to both the ledger // (whose sensitivity ceilings it carries) and the signaller (#99 item 7). expect(durableEntry).toContain(`const noticeDeliveryAdvertisement = Object.freeze(${stableJson(claudeAdapter.noticeDelivery)});`); @@ -1722,7 +1730,7 @@ it('conditionally emits generated state mounting without leaking sqlite into vol state: state('workspace-durable'), }); expect(durable).toContain("from '@agent-bundle/runtime/state/sqlite'"); - expect(durable).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)) });"); + expect(durable).toContain("const pluginRoot = resolvePluginRoot({ fallback: fileURLToPath(new URL('..', import.meta.url)), stateAnchor: 'user-data' });"); expect(durable).toContain('createSqliteStateDriver({ root: pluginRoot.stateRoot })'); expect(durable).not.toContain('AGENT_BUNDLE_PLUGIN_ROOT'); diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index 088163508..f4a2fc466 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -114,7 +114,7 @@ it('inspects volatile and workspace-durable state without inventing runtime path }, declared: true, driver: 'sqlite', - durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + durableLocation: '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin', id: 'fixture/durable-state', lifetime: 'workspace-durable', }, diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 6455c55dc..51b860caf 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -118,7 +118,7 @@ const publishFixtureEpoch = async ( ' return {', " _meta: { ui: { resourceUri: 'ui://fixture/result.html' }, opaque: { nested: ['exact', 42] } },", ' content: [', - " { type: 'text', text: JSON.stringify({ cwd: process.cwd(), data: process.env.FIXTURE_DATA, inherited: process.env.AGENT_BUNDLE_PERSISTENT_INHERITED, pid: process.pid, root: process.env.FIXTURE_ROOT, workspace: process.env.FIXTURE_WORKSPACE }) },", + " { type: 'text', text: JSON.stringify({ cwd: process.cwd(), data: process.env.FIXTURE_DATA, inherited: process.env.AGENT_BUNDLE_PERSISTENT_INHERITED, pid: process.pid, root: process.env.FIXTURE_ROOT, stateRoot: process.env.AGENT_BUNDLE_STATE_ROOT, workspace: process.env.FIXTURE_WORKSPACE }) },", " { type: 'resource_link', name: 'fixture', uri: 'ui://fixture/resource.txt' },", ' ],', " structuredContent: { answer: 42, opaque: { exact: true } },", @@ -252,8 +252,12 @@ it('keeps one generated server and plugin-data directory bound to the selected e readonly inherited: string; readonly pid: number; readonly root: string; + readonly stateRoot: string; }; expect(firstState.root).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1')); + // Dev sessions pin the framework state root beside the epoch (#637), so a + // rebuild never accumulates another `~/.agent-bundle/state` directory. + expect(firstState.stateRoot).toBe(join(root, '.agent-bundle', 'epochs', 'epoch-1', 'state')); expect(firstState.inherited).toBe('resolved-on-open'); await expect(access(firstState.data)).resolves.toBeUndefined(); expect(session.events().some((event) => event.type === 'stderr' && event.text === 'fixture stderr\n')).toBe(true); diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts new file mode 100644 index 000000000..5a6d5fc26 --- /dev/null +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -0,0 +1,205 @@ +import { execFile as executeFile } from 'node:child_process'; +import { chmod, cp, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { userDataStateRoot } from '@agent-bundle/runtime'; +import { expect, it } from '@rstest/core'; + +import { rstestWorkerRoot } from '../../../rstest.worker-isolation.ts'; +import { exists } from '../src/core/paths.ts'; +import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; +import { resolveWebLaunch } from '../src/web-host/launch.ts'; +import { readWebManifest } from '../src/web-host/manifest.ts'; +import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; + +const execFile = promisify(executeFile); +const fixtureRoot = resolve(import.meta.dirname, '../fixtures/durable-web-surface'); +const pluginName = 'durable-web-surface-fixture'; +const app = 'journal/status'; + +/** Every path below `root`, directories marked with a trailing slash and files with their size. */ +const treeListing = async (root: string): Promise => { + const listing: string[] = []; + const walk = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const relativePath = relative(root, path); + if (entry.isDirectory()) { + listing.push(`${relativePath}/`); + await walk(path); + } else { + listing.push(`${relativePath} ${String((await stat(path)).size)}`); + } + } + }; + await walk(root); + return listing.sort(); +}; + +const chmodTree = async (root: string, modes: { readonly directory: number; readonly file: number }): Promise => { + await chmod(root, modes.directory); + for (const entry of await readdir(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) await chmodTree(path, modes); + else await chmod(path, modes.file); + } +}; + +const stringEnvironment = (env: NodeJS.ProcessEnv): Record => + Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); + +interface JournalResult { + readonly entries: readonly { readonly note: string }[]; + readonly revision: number; +} + +/** + * The regression for #637: a workspace-durable plugin installed read-only. + * + * Before the state-root contract every artifact-hosted shell mounted SQLite + * at `/state`, so a plugin with durable `src/state.ts` could not + * serve its first stateful tool call from an installed artifact nothing may + * write beneath. The proof here is bytes and processes: the packed tarball + * is installed into a clean consumer, the artifact is built, the source is + * removed, the whole artifact tree is made read-only, and the generated MCP + * entry is launched exactly the way ` web` launches it — the + * code-root anchor set, no state env at all. The state must land under the + * user state home (`XDG_STATE_HOME`, per worker in every pool) and be read + * back by the artifact CLI bin and by a fresh MCP process, while the artifact + * listing never changes. + */ +it('serves a state-writing tool from a read-only installed artifact without writing beneath it', async () => { + const [agentBundle, runtime, markdownStream] = await Promise.all([ + sharedPackedTarball('agent-bundle'), + sharedPackedTarball('runtime'), + sharedPackedTarball('markdown-stream'), + ]); + const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-readonly-state-')); + const project = join(consumer, 'project'); + const artifact = join(project, 'artifact'); + let readOnly = false; + + try { + await cp(fixtureRoot, project, { recursive: true }); + await execFile('npm', ['install', ...cachedNpmInstallArguments, + agentBundle.tarball, + runtime.tarball, + markdownStream.tarball, + 'react@19.2.8', + 'react-dom@19.2.8', + 'zod@4.4.3', + ], { cwd: project, env: installedEnvironment() }); + const cli = join(project, 'node_modules', '.bin', 'agent-bundle'); + await execFile(cli, ['build', '--root', project, '--output', artifact], { + cwd: project, + env: installedEnvironment(), + }); + const deletedSource = await removeProjectSource({ extraPaths: ['views'], projectRoot: project }); + expect(deletedSource.removed).toEqual(['agent-bundle.config.ts', 'src', 'views']); + + // An installed artifact a host may own read-only: no shell, worker, or bin + // spawned below may create anything beneath it. + await chmodTree(artifact, { directory: 0o555, file: 0o444 }); + readOnly = true; + const listingBefore = await treeListing(artifact); + expect(listingBefore).toEqual(expect.arrayContaining([ + expect.stringMatching(new RegExp(`^bin/${pluginName}\\.mjs \\d+$`, 'u')), + 'mcp/', + ])); + expect(listingBefore).not.toContain('state/'); + + // No custom state env: the launch inherits the worker's XDG_STATE_HOME + // (rstest.worker-isolation.ts), so the derived user-data state root stays + // under the worker root and never touches the developer's home. + const env = stringEnvironment(installedEnvironment()); + delete env['AGENT_BUNDLE_PLUGIN_ROOT']; + delete env['AGENT_BUNDLE_STATE_ROOT']; + const stateHome = env['XDG_STATE_HOME']; + if (stateHome === undefined || !stateHome.startsWith(rstestWorkerRoot())) { + throw new Error(`XDG_STATE_HOME must name a directory under the worker root ${rstestWorkerRoot()}; got ${String(stateHome)}. Is rstest.setup.ts isolating this worker?`); + } + + // Resolve the launch the way ` web` does: the manifest's web + // section names the App and its artifact-relative entry, and + // resolveWebLaunch anchors the code root without naming a state root. + const webManifest = await readWebManifest(join(artifact, 'agent-bundle.manifest.json')); + const declaredApp = webManifest?.apps.find((candidate) => candidate.app === app); + if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(webManifest)}`); + const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: artifact }); + expect(launch.command).toBe(process.execPath); + expect(launch.cwd).toBe(artifact); + expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(artifact); + expect(launch.env['AGENT_BUNDLE_STATE_ROOT']).toBeUndefined(); + expect(launch.env['XDG_STATE_HOME']).toBe(stateHome); + const [entry, ...args] = launch.args; + if (entry === undefined) throw new Error('resolveWebLaunch returned no entry argument.'); + expect(entry.startsWith(join(artifact, 'mcp') + '/')).toBe(true); + const openSession = () => openPackedMcpServer({ + args, + cwd: launch.cwd, + deletedSource, + entry, + env: launch.env, + execPath: launch.command, + }); + + const firstSession = await openSession(); + try { + expect(firstSession.provenance.proofLevel).toBe('packed-deleted-source'); + const tools = await firstSession.client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toContain('record'); + for (const [index, note] of ['first', 'second'].entries()) { + const result = await firstSession.client.callTool({ arguments: { note }, name: 'record' }); + expect(result.isError, `record ${note} failed:\n${JSON.stringify(result.content)}\nserver stderr:\n${firstSession.stderr()}`).not.toBe(true); + expect(result.structuredContent).toEqual({ + entries: ['first', 'second'].slice(0, index + 1).map((written) => ({ note: written })), + revision: index + 1, + }); + } + } finally { + await firstSession.close(); + } + + // Nothing landed beneath the read-only artifact; the SQLite kernel sits + // under the user-data state root the child derived from the same code + // root and inherited env. + expect(await treeListing(artifact)).toEqual(listingBefore); + expect(await exists(join(artifact, 'state'))).toBe(false); + const stateRoot = userDataStateRoot(artifact, launch.env); + expect(stateRoot.startsWith(join(stateHome, 'agent-bundle') + '/')).toBe(true); + expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ + expect.stringMatching(/\.sqlite$/u), + ])); + + // The artifact CLI bin derives the same code root from its own `bin/` + // parent — no AGENT_BUNDLE_PLUGIN_ROOT, no state env — and reads the + // entries the MCP process wrote. + const bin = join(artifact, 'bin', `${pluginName}.mjs`); + const cliRun = await execFile(process.execPath, [bin, 'entries', '--json'], { cwd: consumer, env }); + expect(JSON.parse(cliRun.stdout) as JournalResult).toEqual({ + entries: [{ note: 'first' }, { note: 'second' }], + revision: 2, + }); + + // Restart durability: a fresh MCP process sees the CLI-visible state. + const secondSession = await openSession(); + try { + const result = await secondSession.client.callTool({ arguments: { note: 'third' }, name: 'record' }); + expect(result.isError, `record third failed:\n${JSON.stringify(result.content)}\nserver stderr:\n${secondSession.stderr()}`).not.toBe(true); + expect(result.structuredContent).toEqual({ + entries: [{ note: 'first' }, { note: 'second' }, { note: 'third' }], + revision: 3, + }); + } finally { + await secondSession.close(); + } + expect(await treeListing(artifact)).toEqual(listingBefore); + expect(await exists(join(artifact, 'state'))).toBe(false); + expect(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')).not.toContain('AGENT_BUNDLE_STATE_ROOT'); + } finally { + if (readOnly) await chmodTree(artifact, { directory: 0o755, file: 0o644 }); + await rm(consumer, { force: true, recursive: true }); + } +}, 300_000); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index d5cedbe9b..ac235d289 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { promisify } from 'node:util'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { specTypeSchemas as clientSchemas } from '@modelcontextprotocol/client'; import { expect, it } from '@rstest/core'; +import { exists } from '../src/core/paths.ts'; import { requestEventRuntime } from '../src/events/ipc.ts'; import { compileTestManifest } from '../src/test/manifest.ts'; import { runPackedContractMatrix } from '../src/test/contract.ts'; @@ -123,6 +125,9 @@ it('serves compiled routes and durable state across packed process restarts', as expect(workerSource).toContain('node:sqlite'); expect(workerSource).toContain('createSqliteStateDriver'); expect(workerSource).toContain('AGENT_BUNDLE_PLUGIN_ROOT'); + // Artifact-hosted shells anchor their state root under the user's state + // home, never beneath the installed (possibly read-only) code root. + expect(workerSource).toContain("stateAnchor: 'user-data'"); expect(workerSource).toMatch(/new URL\(["']\.\.["'], import\.meta\.url\)/u); const harnessManifest = await compileTestManifest({ root: project }); const artifactManifest = JSON.parse( @@ -383,10 +388,14 @@ it('serves compiled routes and durable state across packed process restarts', as await firstSession.close(); } - const stateRoot = join(pluginRoot, 'state'); + // The server inherited the worker's XDG_STATE_HOME with `env`, so its + // SQLite kernel landed under the user-data state root for this code root + // (#637) and nothing was written beneath the installed artifact. + const stateRoot = userDataStateRoot(pluginRoot, env); expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ expect.stringMatching(/\.sqlite$/u), ])); + expect(await exists(join(pluginRoot, 'state'))).toBe(false); if (secondSession === undefined) throw new TypeError('Contract matrix did not restart the packed session.'); try { diff --git a/packages/agent-bundle/tests/route-manifest-routes.test.ts b/packages/agent-bundle/tests/route-manifest-routes.test.ts index 7246713c8..f533b17a3 100644 --- a/packages/agent-bundle/tests/route-manifest-routes.test.ts +++ b/packages/agent-bundle/tests/route-manifest-routes.test.ts @@ -95,7 +95,7 @@ it('serves the normalized state catalog on the manifest wire', async () => { source: 'declared', }, driver: 'sqlite', - durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + durableLocation: '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin', id: 'fixture/catalog-state', lifetime: 'workspace-durable', noticeRetention: { diff --git a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts index ac0efef0b..dff4e589c 100644 --- a/packages/agent-bundle/tests/rstest-worker-isolation.test.ts +++ b/packages/agent-bundle/tests/rstest-worker-isolation.test.ts @@ -74,6 +74,13 @@ it('pins the Playwright browser registry before the per-worker cache override hi expect(pinned?.startsWith(rstestWorkerRoot())).toBe(false); }); +it('anchors the user state home of every spawned shell under the worker root', () => { + // The setup file already isolated this worker: a generated shell with + // workspace-durable state derives `$XDG_STATE_HOME/agent-bundle/` + // from this value, so no pool writes beneath the developer's home. + expect(process.env['XDG_STATE_HOME']).toBe(join(rstestWorkerRoot(), 'cache', 'xdg-state')); +}); + it('removes only the finished roots owned by one host temporary root', async () => { const parent = await mkdtemp(join(tmpdir(), 'ab-rstest-roots-parent-')); const legTmp = '/tmp/abci-deadbeef-verify-node24'; diff --git a/packages/agent-bundle/tests/web-launch.test.ts b/packages/agent-bundle/tests/web-launch.test.ts index 1b43874bf..60031732a 100644 --- a/packages/agent-bundle/tests/web-launch.test.ts +++ b/packages/agent-bundle/tests/web-launch.test.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'n import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { pluginStateSegment } from '@agent-bundle/runtime'; import { afterEach, describe, expect, it } from '@rstest/core'; import { exists } from '../src/core/paths.ts'; @@ -140,6 +141,23 @@ describe('resolveWebLaunch', () => { expect(webPluginDataDirectory(first, 'status', home)).toBe(webPluginDataDirectory(`${first}/mcp/..`, 'status', home)); }); + it('keys the web data directory on the same segment the runtime keys the state root on', async () => { + // web-host/launch.ts never loads the optional `@agent-bundle/runtime` + // peer, so its segment is a separate implementation of the runtime's + // `pluginStateSegment`; this pins the two spellings together for a safe + // basename (`-`) and an unsafe one (`plugin-`). + const home = await homeRoot(); + const safe = await artifactRoot(); + const unsafe = join(safe, '.un safe'); + await mkdir(unsafe); + for (const root of [safe, unsafe]) { + expect(webPluginDataDirectory(root, 'status', home)) + .toBe(join(home, '.agent-bundle', 'web-data', pluginStateSegment(root), 'status')); + } + expect(pluginStateSegment(safe)).toMatch(/^agent-bundle-web-launch-[^/]+-[0-9a-f]{16}$/u); + expect(pluginStateSegment(unsafe)).toMatch(/^plugin-[0-9a-f]{16}$/u); + }); + it('creates no data directory when no declared value names plugin-data', async () => { const root = await artifactRoot(); const home = await homeRoot(); diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 7cf242521..6b246d469 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -86,13 +86,12 @@ export interface AgentWorkspaceIdentity { } /** - * Where this plugin is installed and where its durable state lives (#468) — - * the one anchor every generated shell resolves from `AGENT_BUNDLE_PLUGIN_ROOT` - * (source `native`) or, when the host supplies none, from the artifact root or - * the caller's `.agent-bundle` directory (source `derived`). `stateRoot` is - * `/state`: the directory the SQLite state kernel, the notice ledger, and - * the lineage journal all mount, so a route, layout, or provider that keeps - * its own files beside them reads this instead of re-deriving the anchor. + * The plugin's code root and framework state root (#468). `root` comes from + * `AGENT_BUNDLE_PLUGIN_ROOT` or the shell fallback. `stateRoot` comes from + * `AGENT_BUNDLE_STATE_ROOT`; installed artifacts otherwise use + * `~/.agent-bundle/state/-` or + * `$XDG_STATE_HOME/agent-bundle/-`, while root-anchored shells + * use `/state`. */ export interface AgentPluginIdentity { readonly root: string; diff --git a/packages/rsc-runtime/src/plugin-root.ts b/packages/rsc-runtime/src/plugin-root.ts index 6c4aaff32..d3203545a 100644 --- a/packages/rsc-runtime/src/plugin-root.ts +++ b/packages/rsc-runtime/src/plugin-root.ts @@ -1,18 +1,24 @@ -import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { existsSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, isAbsolute, join, resolve } from 'node:path'; import { available, type AgentPluginIdentity, type Observed } from './agent-request.js'; /** - * The environment variable every emitted stdio entry, hook wrapper, and - * artifact CLI receives with the plugin install root in the host's own - * spelling (`${CLAUDE_PLUGIN_ROOT}`, `${CURSOR_PLUGIN_ROOT}`, `${PLUGIN_ROOT}`, - * `./` on Codex). `agent-bundle` exports the same name as `pluginRootEnvAnchor`. + * The environment variable every emitted shell receives with the code root + * in the host's own spelling. */ export const PLUGIN_ROOT_ENV_ANCHOR = 'AGENT_BUNDLE_PLUGIN_ROOT'; -/** The directory below the anchor where durable state (SQLite kernel, notice ledger, lineage journal) lives. */ +/** The optional environment override for the framework state root. */ +export const PLUGIN_STATE_ROOT_ENV_ANCHOR = 'AGENT_BUNDLE_STATE_ROOT'; + +/** The state directory below a root-anchored code root. */ export const PLUGIN_STATE_DIRECTORY = 'state'; +export type PluginStateAnchor = 'root' | 'user-data'; + export interface ResolvePluginRootOptions { /** The environment to read; `process.env` by default. */ readonly env?: Readonly>; @@ -24,14 +30,20 @@ export interface ResolvePluginRootOptions { readonly fallback: string; /** Receives one line when the anchor is present but unexpanded; stderr by default. */ readonly warn?: (message: string) => void; + /** Where `stateRoot` anchors when `AGENT_BUNDLE_STATE_ROOT` is unset; `'root'` by default. */ + readonly stateAnchor?: PluginStateAnchor; + /** The user home `'user-data'` anchors under; `os.homedir()` by default. */ + readonly home?: string; } -/** The anchor a generated shell resolved once and mounts everything on. */ +/** The code and framework-state roots a generated shell resolved once. */ export interface ResolvedPluginRoot extends AgentPluginIdentity { /** The same value as an observed request axis, ready for `runAgentRequest({ plugin })`. */ readonly identity: Observed; /** `native` when `AGENT_BUNDLE_PLUGIN_ROOT` supplied the root, `derived` for the fallback. */ readonly source: 'native' | 'derived'; + /** `native` when `AGENT_BUNDLE_STATE_ROOT` supplied the state root. */ + readonly stateSource: 'native' | 'derived'; } /** A host that passed its manifest through literally leaves `${CLAUDE_PLUGIN_ROOT}`-style tokens in the value. */ @@ -41,21 +53,54 @@ const defaultWarn = (message: string): void => { process.stderr.write(`${message}\n`); }; +const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +/** One stable, filesystem-safe segment per installed code root. */ +export const pluginStateSegment = (root: string): string => { + const canonicalRoot = existsSync(root) ? realpathSync(root) : resolve(root); + const digest = createHash('sha256').update(canonicalRoot).digest('hex').slice(0, 16); + const name = basename(canonicalRoot); + return safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`; +}; + /** - * The one resolution of the plugin root / durable-state anchor (#468). The - * generated MCP entry, its Flight worker, the routed CLI executable, its - * render worker, and the hook wrappers all call this once at startup, mount - * SQLite at `stateRoot`, and publish `identity` on every request they open, - * so `(await agent()).plugin.stateRoot` is by construction the directory the - * kernel, the notice ledger, and the lineage journal use. + * The user-level directory that holds framework state for installed plugins. + * A relative `XDG_STATE_HOME` is ignored, as the base-directory spec + * requires: a shell runs with the artifact as its cwd, and a relative anchor + * would put state back beneath the install. + */ +export const userStateHome = ( + env: Readonly> = process.env, + home = homedir(), +): string => { + const xdgStateHome = env.XDG_STATE_HOME ?? ''; + return isAbsolute(xdgStateHome) + ? join(xdgStateHome, 'agent-bundle') + : join(home, '.agent-bundle', PLUGIN_STATE_DIRECTORY); +}; + +/** The framework state root for one installed plugin. */ +export const userDataStateRoot = ( + root: string, + env?: Readonly>, + home?: string, +): string => join(userStateHome(env, home), pluginStateSegment(root)); + +/** + * Resolves the code root and framework state root once per generated process. + * The code root identifies the installed artifact; the state root holds the + * SQLite kernel, notice ledger, and lineage journal. * * `AGENT_BUNDLE_PLUGIN_ROOT` wins when it is set to a non-blank, expanded * value (`source: 'native'`), taken exactly as written — a path is never * trimmed. A blank value or one still carrying a `${…}` * token is treated as unset — the token case is reported once on stderr, * because it means the host did not expand its manifest — and the shell's - * `fallback` anchors the plugin (`source: 'derived'`). Both roots are made - * absolute against the working directory, as the kernel always did. + * `fallback` anchors the plugin (`source: 'derived'`). + * + * `AGENT_BUNDLE_STATE_ROOT` independently overrides `stateRoot`; otherwise it + * derives below the code root or the user's state home according to + * `stateAnchor`. */ export const resolvePluginRoot = (options: ResolvePluginRootOptions): ResolvedPluginRoot => { const env = options.env ?? process.env; @@ -77,11 +122,30 @@ export const resolvePluginRoot = (options: ResolvePluginRootOptions): ResolvedPl root = resolve(declared); source = 'native'; } - const stateRoot = join(root, PLUGIN_STATE_DIRECTORY); + const derivedStateRoot = options.stateAnchor === 'user-data' + ? userDataStateRoot(root, env, options.home) + : join(root, PLUGIN_STATE_DIRECTORY); + const declaredStateRoot = env[PLUGIN_STATE_ROOT_ENV_ANCHOR] ?? ''; + let stateRoot: string; + let stateSource: 'native' | 'derived'; + if (declaredStateRoot.trim() === '') { + stateRoot = derivedStateRoot; + stateSource = 'derived'; + } else if (unexpandedToken.test(declaredStateRoot)) { + (options.warn ?? defaultWarn)( + `[agent-bundle] ${PLUGIN_STATE_ROOT_ENV_ANCHOR} is the unexpanded token ${JSON.stringify(declaredStateRoot)}; anchoring state on ${derivedStateRoot} instead.`, + ); + stateRoot = derivedStateRoot; + stateSource = 'derived'; + } else { + stateRoot = resolve(declaredStateRoot); + stateSource = 'native'; + } return Object.freeze({ identity: available({ root, stateRoot }, source), root, source, stateRoot, + stateSource, }); }; diff --git a/packages/rsc-runtime/src/plugin.ts b/packages/rsc-runtime/src/plugin.ts index bdef9e50c..dc9688a84 100644 --- a/packages/rsc-runtime/src/plugin.ts +++ b/packages/rsc-runtime/src/plugin.ts @@ -65,8 +65,16 @@ export type { Observed, ObservedSource, } from './agent-request.js'; -export { PLUGIN_ROOT_ENV_ANCHOR, PLUGIN_STATE_DIRECTORY, resolvePluginRoot } from './plugin-root.js'; -export type { ResolvePluginRootOptions, ResolvedPluginRoot } from './plugin-root.js'; +export { + PLUGIN_ROOT_ENV_ANCHOR, + PLUGIN_STATE_DIRECTORY, + PLUGIN_STATE_ROOT_ENV_ANCHOR, + pluginStateSegment, + resolvePluginRoot, + userDataStateRoot, + userStateHome, +} from './plugin-root.js'; +export type { PluginStateAnchor, ResolvePluginRootOptions, ResolvedPluginRoot } from './plugin-root.js'; // Type-only: the optional ledger implementation stays behind './notices'. export type { AgentNoticeLedger, diff --git a/packages/rsc-runtime/tests/plugin-root.test.ts b/packages/rsc-runtime/tests/plugin-root.test.ts index e955b41e5..94a8b7d5b 100644 --- a/packages/rsc-runtime/tests/plugin-root.test.ts +++ b/packages/rsc-runtime/tests/plugin-root.test.ts @@ -1,9 +1,21 @@ -import { join, resolve } from 'node:path'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { agent, runAgentRequest } from '../src/agent-request.js'; -import { PLUGIN_ROOT_ENV_ANCHOR, resolvePluginRoot } from '../src/plugin-root.js'; +import { + PLUGIN_ROOT_ENV_ANCHOR, + PLUGIN_STATE_ROOT_ENV_ANCHOR, + pluginStateSegment, + resolvePluginRoot, + userDataStateRoot, + userStateHome, +} from '../src/plugin-root.js'; + +const digest16 = (path: string): string => createHash('sha256').update(path).digest('hex').slice(0, 16); describe('resolvePluginRoot (#468)', () => { const fallback = '/artifact/claude'; @@ -15,6 +27,7 @@ describe('resolvePluginRoot (#468)', () => { identity: { source: 'native', state: 'available', value: { root: '/installs/curator', stateRoot: '/installs/curator/state' } }, root: '/installs/curator', source: 'native', + stateSource: 'derived', stateRoot: '/installs/curator/state', }); expect(Object.isFrozen(resolved)).toBe(true); @@ -26,6 +39,7 @@ describe('resolvePluginRoot (#468)', () => { expect(resolved.root).toBe('/opt/curator '); expect(resolved.stateRoot).toBe(join('/opt/curator ', 'state')); expect(resolved.source).toBe('native'); + expect(resolved.stateSource).toBe('derived'); }); it('makes a relative anchor absolute against the working directory, as Codex hands "./"', () => { @@ -34,13 +48,19 @@ describe('resolvePluginRoot (#468)', () => { expect(resolved.root).toBe(resolve('./')); expect(resolved.stateRoot).toBe(join(resolve('./'), 'state')); expect(resolved.source).toBe('native'); + expect(resolved.stateSource).toBe('derived'); }); it('falls back to the shell fallback as the derived source when the anchor is unset or blank', () => { for (const env of [{}, { [PLUGIN_ROOT_ENV_ANCHOR]: '' }, { [PLUGIN_ROOT_ENV_ANCHOR]: ' ' }]) { const warnings: string[] = []; const resolved = resolvePluginRoot({ env, fallback, warn: (message) => warnings.push(message) }); - expect(resolved).toMatchObject({ root: fallback, source: 'derived', stateRoot: `${fallback}/state` }); + expect(resolved).toMatchObject({ + root: fallback, + source: 'derived', + stateRoot: `${fallback}/state`, + stateSource: 'derived', + }); expect(resolved.identity).toEqual({ source: 'derived', state: 'available', value: { root: fallback, stateRoot: `${fallback}/state` } }); expect(warnings).toEqual([]); } @@ -54,7 +74,12 @@ describe('resolvePluginRoot (#468)', () => { warn: (message) => warnings.push(message), }); - expect(resolved).toMatchObject({ root: fallback, source: 'derived', stateRoot: `${fallback}/state` }); + expect(resolved).toMatchObject({ + root: fallback, + source: 'derived', + stateRoot: `${fallback}/state`, + stateSource: 'derived', + }); expect(resolved.stateRoot).not.toContain('${'); expect(warnings).toEqual([ `[agent-bundle] AGENT_BUNDLE_PLUGIN_ROOT is the unexpanded token "\${CLAUDE_PLUGIN_ROOT}"; anchoring the plugin on ${fallback} instead.`, @@ -71,6 +96,127 @@ describe('resolvePluginRoot (#468)', () => { else process.env[PLUGIN_ROOT_ENV_ANCHOR] = previous; } }); + + it('uses AGENT_BUNDLE_STATE_ROOT as an independent native state root', () => { + const declaredStateRoot = './framework state '; + const resolved = resolvePluginRoot({ + env: { + [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator', + [PLUGIN_STATE_ROOT_ENV_ANCHOR]: declaredStateRoot, + }, + fallback, + }); + + expect(resolved).toMatchObject({ + root: '/installs/curator', + source: 'native', + stateRoot: resolve(declaredStateRoot), + stateSource: 'native', + }); + }); + + it('treats a blank AGENT_BUNDLE_STATE_ROOT as unset', () => { + for (const declared of ['', ' ']) { + const resolved = resolvePluginRoot({ + env: { + [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator', + [PLUGIN_STATE_ROOT_ENV_ANCHOR]: declared, + }, + fallback, + }); + + expect(resolved).toMatchObject({ + root: '/installs/curator', + source: 'native', + stateRoot: '/installs/curator/state', + stateSource: 'derived', + }); + } + }); + + it('reports an unexpanded state-root token once and derives the state root', () => { + const warnings: string[] = []; + const resolved = resolvePluginRoot({ + env: { + [PLUGIN_ROOT_ENV_ANCHOR]: '/installs/curator', + [PLUGIN_STATE_ROOT_ENV_ANCHOR]: '${PLUGIN_STATE}', + }, + fallback, + warn: (message) => warnings.push(message), + }); + + expect(resolved).toMatchObject({ + root: '/installs/curator', + source: 'native', + stateRoot: '/installs/curator/state', + stateSource: 'derived', + }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('AGENT_BUNDLE_STATE_ROOT'); + expect(warnings[0]).toContain('${PLUGIN_STATE}'); + }); + + it('derives user-data state below an explicit home', () => { + const root = '/installs/curator'; + const home = '/users/tester'; + const segment = `curator-${digest16(resolve(root))}`; + + expect(userStateHome({}, home)).toBe(join(home, '.agent-bundle', 'state')); + expect(userDataStateRoot(root, {}, home)).toBe(join(home, '.agent-bundle', 'state', segment)); + expect(resolvePluginRoot({ + env: { [PLUGIN_ROOT_ENV_ANCHOR]: root }, + fallback, + home, + stateAnchor: 'user-data', + })).toMatchObject({ + root, + stateRoot: join(home, '.agent-bundle', 'state', segment), + stateSource: 'derived', + }); + }); + + it('uses an absolute XDG_STATE_HOME and ignores a blank or relative one', () => { + const root = '/installs/curator'; + const home = '/users/tester'; + const segment = `curator-${digest16(resolve(root))}`; + + expect(userStateHome({ XDG_STATE_HOME: '/xdg/state' }, home)).toBe('/xdg/state/agent-bundle'); + expect(userDataStateRoot(root, { XDG_STATE_HOME: '/xdg/state' }, home)).toBe( + join('/xdg/state', 'agent-bundle', segment), + ); + expect(userStateHome({ XDG_STATE_HOME: ' ' }, home)).toBe(join(home, '.agent-bundle', 'state')); + // The base-directory spec ignores a relative value; honoring it would + // anchor state on the shell's cwd, which for an artifact shell is the + // read-only install itself. + expect(userStateHome({ XDG_STATE_HOME: 'state' }, home)).toBe(join(home, '.agent-bundle', 'state')); + expect(userStateHome({ XDG_STATE_HOME: './xdg' }, home)).toBe(join(home, '.agent-bundle', 'state')); + }); + + it('uses plugin as the segment name when the root basename is unsafe', () => { + const root = '/installs/unsafe name'; + expect(pluginStateSegment(root)).toBe(`plugin-${digest16(resolve(root))}`); + }); + + it('digests real roots canonically and missing roots by their resolved spelling', () => { + const directory = mkdtempSync(join(tmpdir(), 'agent-bundle-plugin-root-')); + try { + const root = join(directory, 'curator'); + const link = join(directory, 'curator-link'); + mkdirSync(root); + symlinkSync(root, link, 'dir'); + + const canonical = realpathSync(root); + const expectedRealSegment = `${basename(canonical)}-${digest16(canonical)}`; + expect(pluginStateSegment(root)).toBe(expectedRealSegment); + expect(pluginStateSegment(link)).toBe(expectedRealSegment); + + const missing = join(directory, 'missing', '..', 'ghost'); + const resolvedMissing = resolve(missing); + expect(pluginStateSegment(missing)).toBe(`${basename(resolvedMissing)}-${digest16(resolvedMissing)}`); + } finally { + rmSync(directory, { force: true, recursive: true }); + } + }); }); describe('the plugin request axis (#468)', () => { diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index ea660145d..cb99e457b 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -97,7 +97,7 @@ const manifest = { source: 'defaults', }, driver: 'sqlite', - durableLocation: '$AGENT_BUNDLE_PLUGIN_ROOT/state (falls back to the artifact root or ./.agent-bundle/state for CLI bins)', + durableLocation: '$AGENT_BUNDLE_STATE_ROOT, else ~/.agent-bundle/state/- ($XDG_STATE_HOME/agent-bundle/-) for an installed artifact, or ./.agent-bundle/state for the npm package bin', id: 'library/catalog', lifetime: 'workspace-durable', // The dev server's manifest carries the resolved notice retention policy diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index a6d495479..e73edf8f2 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -156,6 +156,7 @@ export const packedTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/packed-consumer-typescript.test.ts', 'packages/agent-bundle/tests/packed-host-install-proof.test.ts', 'packages/agent-bundle/tests/packed-native-smoke.test.ts', + 'packages/agent-bundle/tests/packed-readonly-state-root.test.ts', 'packages/agent-bundle/tests/packed-stdio-projection.test.ts', 'packages/agent-bundle/tests/packed-web-command.test.ts', 'packages/agent-bundle/tests/public-api-packed.test.ts', diff --git a/rstest.worker-isolation.ts b/rstest.worker-isolation.ts index 684ed9ad3..f8bc32d6a 100644 --- a/rstest.worker-isolation.ts +++ b/rstest.worker-isolation.ts @@ -118,6 +118,11 @@ export const isolateWorkerEnvironment = (): void => { env['TMP'] = root; env['TEMP'] = root; env['XDG_CACHE_HOME'] = cache; + // Generated shells with workspace-durable state derive their user state + // root from XDG_STATE_HOME (`resolvePluginRoot` with the `user-data` + // anchor), so every shell a pool spawns writes SQLite under this worker's + // root and never beneath the developer's home. + env['XDG_STATE_HOME'] = rstestWorkerCacheDirectory('xdg-state'); }; let commandSerial = 0; diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index b7446015d..704c86133 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -679,18 +679,16 @@ const readPluginRoot = (env: Record): string | undef Inside a route there is no need to read the variable at all. Every generated shell — the MCP entry and its Flight worker, the routed CLI executable and its render worker, the hook wrappers — -resolves the anchor once at startup with `resolvePluginRoot` from `@agent-bundle/runtime`, mounts -its SQLite state, notice ledger, and lineage journal under `/state`, and publishes the same -value on every request it opens as `(await agent()).plugin`: +resolves its code and framework state roots once at startup with `resolvePluginRoot` from +`@agent-bundle/runtime` and publishes them on every request it opens as +`(await agent()).plugin`: ```ts const { plugin } = await agent(); if (plugin.state === 'available') { - plugin.value.root; // the install root: AGENT_BUNDLE_PLUGIN_ROOT, - // or the shell's fallback - plugin.value.stateRoot; // `/state`, where defineState / notices / lineage - // already live - plugin.source; // 'native' from the variable, 'derived' from the fallback + plugin.value.root; // the code root: AGENT_BUNDLE_PLUGIN_ROOT or the shell's fallback + plugin.value.stateRoot; // the framework state root: AGENT_BUNDLE_STATE_ROOT or the default + plugin.source; // the code root is 'native' from the variable or 'derived' } ``` @@ -698,12 +696,17 @@ if (plugin.state === 'available') { means the shell fell back — to the artifact root (the parent of `mcp/`, `bin/`, or `hooks/`) inside a built artifact, or to `$PWD/.agent-bundle` for the npm package bin. A value still carrying an unexpanded `${…}` token (a host that passed its manifest through literally) is treated as unset, -reported once on stderr, and never joined into a path. Conventional providers receive the same -observed value as `plugin` in their factory context, beside `invocation` and `signal`, so a -provider that keeps files beside the framework's state derives nothing itself. The route-unit and -`mcp-in-memory` harnesses resolve it the same way (falling back to `/.agent-bundle`) -and accept `context.plugin` as an override like every other axis; outside a generated scope the -axis is `unavailable('not-provided')`. +reported once on stderr, and never joined into a path. Artifact-hosted shells default +`plugin.value.stateRoot` to `~/.agent-bundle/state/-` (or +`$XDG_STATE_HOME/agent-bundle/-`), derived from the code root's safe basename and +the first 16 hexadecimal characters of the SHA-256 digest of its real path. +`AGENT_BUNDLE_STATE_ROOT` overrides that location; the npm package bin keeps +`$PWD/.agent-bundle/state`. Conventional providers receive the same observed value as `plugin` in +their factory context, beside `invocation` and `signal`, so a provider that keeps files beside the +framework's state derives nothing itself. The route-unit and `mcp-in-memory` harnesses resolve it +the same way (falling back to `/.agent-bundle`) and accept `context.plugin` as an +override like every other axis; outside a generated scope the axis is +`unavailable('not-provided')`. ## MCP Apps @@ -1039,8 +1042,10 @@ that lists them. Missing `agent-bundle.manifest.json` beside `bin/` exits `1`. T launches the plugin's own MCP server (`node /mcp/.mjs`, `AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the plugin-data token resolves outside the artifact, under -`~/.agent-bundle/web-data/-/`, so a read-only install still launches), -calls the opening tool once, serves the App at `http://127.0.0.1:/` on a loopback origin with a +`~/.agent-bundle/web-data/-/`, so a read-only install still launches). +The spawned server's own durable framework state likewise lives outside the artifact under +`~/.agent-bundle/state/-` (or `AGENT_BUNDLE_STATE_ROOT`). It calls the opening +tool once and serves the App at `http://127.0.0.1:/` on a loopback origin with a second loopback sandbox origin for the App document (the same host stack and consent behavior as `agent-bundle serve-app` and the Workbench), prints `MCP App / at (tool ; Ctrl-C stops the server)` (or one JSON line with diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index a16e94217..147287c81 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -380,9 +380,9 @@ capability that admits it. The artifact bin is a self-contained ESM module with executable bit — run it as `node /bin/.mjs `, exactly like `scripts/*.mjs`. Help, argv parsing, output modes, exit codes, and signals match the package bin; the one difference is that -workspace-durable state without a host-supplied `AGENT_BUNDLE_PLUGIN_ROOT` anchors on the artifact -root (the parent of `bin/`, the same fallback the generated MCP worker uses) instead of -`$PWD/.agent-bundle/state`, so a co-installed CLI and server share one store. +the artifact-hosted CLI and MCP server derive the same framework state root from the same code +root — `~/.agent-bundle/state/-`, or `AGENT_BUNDLE_STATE_ROOT` when set — so they +share one store. The npm package bin keeps `$PWD/.agent-bundle/state`. Reach it from the other surfaces with the plugin-root token — `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` in Claude Skill Markdown and hook commands, `${PLUGIN_ROOT}/…` in Codex hooks, `${CURSOR_PLUGIN_ROOT}/…` diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 8ce13ddbb..150dd56c2 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -102,10 +102,13 @@ rebuilding without a version bump no longer needs an uninstall and `rm -rf`. A d refused with `AB7005` unless you pass `--replace` (alias `--force`), and a foreign directory — one this plugin's installer did not place — is refused either way. Cursor copies carry an install receipt (`.agent-bundle-install.json`: plugin, version, host, content hash, owned files); replacement is in -place and touches owned files only, never unowned entries such as `state/`, and `--replace` adopts -a pre-receipt copy. Claude replacement runs `claude plugin uninstall --keep-data` before -reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before -`add`. The emitted `INSTALL.md` documents the same recipe per host. +place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and +`--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under +`~/.agent-bundle/state/-` instead (`AGENT_BUNDLE_STATE_ROOT` overrides that +location); `uninstall` does not remove it. Claude replacement runs +`claude plugin uninstall --keep-data` before reinstalling because `plugin update` is +version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents +the same recipe per host. ## Operator configuration: the installed pack's `.env` @@ -170,7 +173,7 @@ variables each declares (`AB7331`, informational) — never a name or a value. ```sh # exact paths, nothing changes npx agent-bundle uninstall cursor --from artifact --plan -# receipt-owned files; state/ kept +# receipt-owned files; legacy or in-place state/ kept npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove npx agent-bundle uninstall claude --from artifact @@ -184,8 +187,9 @@ created, the host registrations it performed, and timestamps. Cursor local copie `.agent-bundle-install.json`; Claude, Codex, and Cursor marketplace-mode installs keep theirs under `/agent-bundle/receipts/`. `uninstall` removes exactly what the receipt owns and reverses exactly the registrations it recorded — never anything else; unowned entries are listed -as retained. Durable runtime state (`state/`, and for a Cursor copy of an Agent Plugins pack the -`PLUGIN_DATA` directory the receipt records) is kept unless you pass `--purge-data --confirm-purge`, and the result states honestly what the host itself decided where +as retained. Legacy or in-place durable runtime state (`state/`, and for a Cursor copy of an Agent +Plugins pack the `PLUGIN_DATA` directory the receipt records) is kept unless you pass +`--purge-data --confirm-purge`, and the result states honestly what the host itself decided where Agent Bundle cannot (Claude orphans its cached copy for a ~14-day grace period; Codex deletes the cached tree and offers no keep-data option). A missing receipt (`AB7009`) or a content mismatch (`AB7007`) is refused unless `--force`; a directory that belongs to another plugin is refused diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 685e6f483..d02c90042 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -137,8 +137,10 @@ plugin root — `agent-bundle.manifest.json` must sit beside `bin/` (exit `1` ot The command launches the plugin's own MCP server (`node /mcp/.mjs`, `AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the plugin-data token resolves outside the artifact, under -`~/.agent-bundle/web-data/-/`, so a read-only install still launches), -calls the opening tool once, and serves the App at `http://127.0.0.1:/` on a loopback origin with a +`~/.agent-bundle/web-data/-/`, so a read-only install still launches). +The spawned server's own durable framework state likewise lives outside the artifact under +`~/.agent-bundle/state/-` (or `AGENT_BUNDLE_STATE_ROOT`). It calls the opening +tool once and serves the App at `http://127.0.0.1:/` on a loopback origin with a second loopback sandbox origin for the App document — the same host stack and consent behavior as `agent-bundle serve-app` and the Workbench. Human mode prints `MCP App / at (tool ; Ctrl-C stops the server)` and runs until Ctrl-C / @@ -204,8 +206,8 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | -| `--keep-data` | on | Keep durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. | -| `--purge-data` | off | Also remove durable runtime state. Refused (`AB7008`) without `--confirm-purge`. | +| `--keep-data` | on | Keep legacy or in-place durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. (Artifacts built from this release keep framework state under `~/.agent-bundle/state/-`, which `uninstall` does not remove.) | +| `--purge-data` | off | Also remove the legacy or in-place durable runtime state (`state/`, recorded `PLUGIN_DATA`); the derived `~/.agent-bundle/state/-` root stays. Refused (`AB7008`) without `--confirm-purge`. | | `--force` | off | Proceed without a receipt (legacy Cursor copy, host-only install) or when owned content, version, or staged `HEAD` no longer matches the receipt. A receipt or manifest naming another plugin is refused regardless. | | `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | @@ -219,8 +221,9 @@ install at another scope or in another project known only to Claude's `plugins/i registry). Unowned entries are retained and listed; a missing receipt is `AB7009` and a mismatch is `AB7007` unless `--force`; a second run is a `not-installed` no-op. The typed `data.outcome` says what happened to durable state per host: Cursor `kept` / `purged` / `absent`; Claude `retained-by-host` (the cached copy is -orphaned for Claude's ~14-day grace period; a purge also removes `state/` and -`plugins/data//`); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the +orphaned for Claude's ~14-day grace period; a purge also removes legacy or in-place `state/` and +`plugins/data//`, but not the current `~/.agent-bundle/state/-` framework +state); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the cached tree and has no keep-data option). The package-relative installer bin accepts `uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. diff --git a/website/docs/en/reference/runtime-environment.mdx b/website/docs/en/reference/runtime-environment.mdx index 06a0f3c5f..6c98adc80 100644 --- a/website/docs/en/reference/runtime-environment.mdx +++ b/website/docs/en/reference/runtime-environment.mdx @@ -39,7 +39,9 @@ Cursor's pinned loader has its own substituted-field table, and a token outside | Variable | Read by | Meaning | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin install root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/` — and durable-state anchor. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | +| `AGENT_BUNDLE_PLUGIN_ROOT` | Generated executables | The plugin code root — the composite plugin root, the directory holding the host manifests, `mcp/`, `hooks/`, and `bin/`. Overrides the built-in fallback; surfaced to routes and providers as `(await agent()).plugin.value.root` (`source: 'native'`). An unexpanded `${…}` token is treated as unset. | +| `AGENT_BUNDLE_STATE_ROOT` | Generated executables | The framework state root: the exact directory where the SQLite state kernel, notice ledger, and lineage journal live. Overrides the derived location. An unexpanded `${…}` token is treated as unset. | +| `XDG_STATE_HOME` | Generated executables | When set to an absolute path (a relative value is ignored, per the base-directory spec), the derived state root is `$XDG_STATE_HOME/agent-bundle/-` instead of `~/.agent-bundle/state/-`. | | `AGENT_BUNDLE_ENV_FILE` | Generated executables | The operator env file(s) an installed pack reads at launch instead of `/.env` and `.env.local`: one path, or several joined by the platform path delimiter, later files winning; `none` disables the layer. `mcp run` sets it for its child from `--env-file` / `--no-env`. | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | The bearer token the Agent API requires before it can be enabled. | | `AGENT_BUNDLE_HOOK_SIMULATION` | Generated hook wrappers | `1` marks a simulated invocation; the Workbench event route workspace sets it. | @@ -84,19 +86,29 @@ variable count of each file (`AB7331`). Details and the precedence table: ## Durable state -Durable state resolves to `$AGENT_BUNDLE_PLUGIN_ROOT/state`, falling back to the artifact root, or -to `./.agent-bundle/state` for CLI bins. Only a `workspace-durable` state definition uses the -SQLite driver; other lifetimes use the in-memory driver and keep nothing on disk. Every generated -process resolves that anchor exactly once (`resolvePluginRoot` from `@agent-bundle/runtime`) and -publishes it as `(await agent()).plugin` — `{ root, stateRoot }` with `source: 'native'` from the -variable or `'derived'` from the fallback — so a route or provider that keeps its own files beside -the framework's reads `plugin.value.stateRoot` instead of re-deriving the path. +Installed artifacts — the MCP entry, Flight worker, artifact CLI bin, render worker, and standalone +hook wrappers — derive `~/.agent-bundle/state/-` as their framework state root. +`` is the code root's basename, or `plugin` when that basename is not a safe path segment; +`` is the first 16 hexadecimal characters of the SHA-256 digest of the code root's real +path. Two installs therefore never share state, and a read-only install launches. `XDG_STATE_HOME` +moves the derived root as described above, and `AGENT_BUNDLE_STATE_ROOT` overrides it. The npm +package bin keeps `./.agent-bundle/state`. + +Only a `workspace-durable` state definition uses the SQLite driver; other lifetimes use the +in-memory driver and keep nothing on disk. Every generated process resolves the code root and state +root exactly once (`resolvePluginRoot` from `@agent-bundle/runtime`) and publishes them as +`(await agent()).plugin` — `{ root, stateRoot }` — so a route or provider that keeps its own files +beside the framework's reads `plugin.value.stateRoot` instead of re-deriving the path. Under `mcp run`, plugin-root anchors in **env values** expand to the project root by default, not the artifact: the artifact is an ephemeral build product there, and anchoring durable state on it would fragment that state on every rebuild. Pass `--plugin-root ` pointing at the artifact root itself — the composite plugin root, not a host subdirectory — for a byte-faithful -rehearsal of a copied-artifact launch. +rehearsal of a copied-artifact launch. The state root then derives from that expanded code root +(`~/.agent-bundle/state/-`) unless `AGENT_BUNDLE_STATE_ROOT` names one. +`agent-bundle dev` and Workbench MCP sessions pin `AGENT_BUNDLE_STATE_ROOT` to `/state` +instead, so a build epoch's state goes with the epoch rather than accumulating one user-data +directory per rebuild. A per-server state directory uses the server name when it is a single safe segment; any other name becomes a content-addressed `server-` segment, so a name like `../shared` can never diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index 4643574a6..6c6a4ffb1 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -603,22 +603,25 @@ const readPluginRoot = (env: Record): string | undef 在路由内部完全不需要读取这个变量。每个生成的外壳——MCP 入口及其 Flight worker、路由式 CLI 可执行文件 及其渲染 worker、hook 包装器——都在启动时用 `@agent-bundle/runtime` 的 `resolvePluginRoot` 解析一次 -锚点,把 SQLite 状态、通知账本与 lineage 日志挂载在 `/state` 之下,并在它打开的每个请求上以 -`(await agent()).plugin` 发布同一个值: +代码根目录与框架状态根目录,并在它打开的每个请求上以 `(await agent()).plugin` 发布这两个值: ```ts const { plugin } = await agent(); if (plugin.state === 'available') { - plugin.value.root; // 安装根目录:AGENT_BUNDLE_PLUGIN_ROOT,或外壳的回退值 - plugin.value.stateRoot; // `/state`,defineState / 通知 / lineage 已经存放于此 - plugin.source; // 来自变量为 'native',来自回退为 'derived' + plugin.value.root; // 代码根目录:AGENT_BUNDLE_PLUGIN_ROOT,或外壳的回退值 + plugin.value.stateRoot; // 框架状态根目录:AGENT_BUNDLE_STATE_ROOT,或默认位置 + plugin.source; // 代码根目录来自变量为 'native',来自回退为 'derived' } ``` `source: 'native'` 表示宿主提供了已展开的 `AGENT_BUNDLE_PLUGIN_ROOT`;`'derived'` 表示外壳使用了回退—— 在构建产物内是产物根目录(`mcp/`、`bin/` 或 `hooks/` 的父目录),对 npm 包 bin 则是 `$PWD/.agent-bundle`。 仍带有未展开 `${…}` token 的值(宿主把清单原样透传)会被视为未设置,在 stderr 上报告一次,且绝不会被 -拼接进路径。约定式 provider 在其工厂上下文中收到同样的被观察值 `plugin`,与 `invocation`、`signal` 并列, +拼接进路径。产物宿主外壳把 `plugin.value.stateRoot` 默认为 +`~/.agent-bundle/state/-`(或 `$XDG_STATE_HOME/agent-bundle/-`),由 +代码根目录的安全 basename 及其真实路径 SHA-256 摘要的前 16 位十六进制字符推导而来。 +`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置;npm 包 bin 仍使用 `$PWD/.agent-bundle/state`。约定式 +provider 在其工厂上下文中收到同样的被观察值 `plugin`,与 `invocation`、`signal` 并列, 因此把文件放在框架状态旁边的 provider 自己不必再推导任何东西。route-unit 与 `mcp-in-memory` 测试层级 以同样方式解析它(回退到 `<项目根目录>/.agent-bundle`),并像其他每个轴一样接受 `context.plugin` 覆盖; 在生成的作用域之外,该轴为 `unavailable('not-provided')`。 @@ -902,7 +905,8 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] 缺少 `agent-bundle.manifest.json` 则以 `1` 退出。该命令启动插件自己的 MCP 服务器 (`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`,已声明的静态 env 并展开路径 令牌——plugin-data 令牌解析到产物之外的 `~/.agent-bundle/web-data/-/`, -因此只读安装也能启动),先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 +因此只读安装也能启动)。被拉起的服务器自己的持久框架状态同样落在产物之外的 +`~/.agent-bundle/state/-`(或 `AGENT_BUNDLE_STATE_ROOT`)。随后先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin(与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与 同意行为),打印 `MCP App / at (tool ; Ctrl-C stops the server)`(或带 `--json` 时打印一行 `{ app, server, tool, url, port, resourceUri, sandboxOrigin }`),并一直运行 diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 1e1af67e4..7b0b42f8e 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -346,9 +346,9 @@ id。只要路由从生成的 CLI 可执行文件运行,无论命令由哪种 `bin/-flight.mjs`),由所有选中宿主共享。每个内置 target 都发布了准入它的 `cli` 能力。产物 bin 是一个 自包含的 ESM 模块,没有 shebang 也没有可执行位——像 `scripts/*.mjs` 一样以 `node /bin/.mjs ` 运行。帮助、argv 解析、输出模式、退出码与信号都与 -包 bin 一致;唯一的区别是,在宿主未提供 `AGENT_BUNDLE_PLUGIN_ROOT` 时,工作区级持久状态锚定在产物根目录 -(`bin/` 的父目录,与生成的 MCP worker 使用同一个回退),而不是 `$PWD/.agent-bundle/state`,这样一起安装 -的 CLI 与服务器观察到的是同一份存储。 +包 bin 一致;唯一的区别是,产物宿主的 CLI 与 MCP 服务器从同一个代码根目录推导出同一个框架状态根目录—— +`~/.agent-bundle/state/-`,或已设置的 `AGENT_BUNDLE_STATE_ROOT`——因此共享同一份存储。 +npm 包 bin 仍使用 `$PWD/.agent-bundle/state`。 从其他表面引用它时使用插件根 token——Claude Skill Markdown 与 hook 命令中写 `${CLAUDE_PLUGIN_ROOT}/bin/.mjs`,Codex hook 中写 `${PLUGIN_ROOT}/…`,Cursor hook 中写 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 42591276c..fea62a5e7 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -85,8 +85,10 @@ node ./install.mjs 内容完全相同的副本是 `already-installed` 空操作。**版本相同但内容哈希不同**的副本会被自动替换,因此不升版本 地重建不再需要卸载加 `rm -rf`。版本不同则以 `AB7005` 拒绝,除非传入 `--replace`(别名 `--force`);外来目录 ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: -插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动 `state/` 之类的非归属条目, -`--replace` 会接管回执出现之前的副本。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, +插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, +`--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 +`~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置),`uninstall` +不会删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 @@ -142,7 +144,7 @@ RTORRENT_SSH_HOST=nas.local ```sh # 确切路径,不做改动 npx agent-bundle uninstall cursor --from artifact --plan -# 回执归属的文件;保留 state/ +# 回执归属的文件;保留旧版或就地的 state/ npx agent-bundle uninstall cursor --from artifact # claude plugin uninstall --keep-data + marketplace remove npx agent-bundle uninstall claude --from artifact @@ -153,7 +155,7 @@ node artifact/install.mjs --uninstall [--mode marketplace] 每次安装都会写入生命周期回执(`agent-bundle-install-receipt/2`):版本、内容哈希、投递模式、作用域、归属的文件与 目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本以 `.agent-bundle-install.json` 携带它; Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 -归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。持久运行时状态(`state/`,以及 Agent Plugins +归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。旧版或就地的持久运行时状态(`state/`,以及 Agent Plugins 包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录)除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 (`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 235423aca..9bbd1f448 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -131,8 +131,9 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] 该命令启动插件自己的 MCP 服务器(`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`, 已声明的静态 env 并展开路径令牌——plugin-data 令牌解析到产物之外的 -`~/.agent-bundle/web-data/-/`,因此只读安装也能启动),先调用一次开场工具, -然后在 `http://127.0.0.1:/` 上用 +`~/.agent-bundle/web-data/-/`,因此只读安装也能启动)。被拉起的服务器自己的 +持久框架状态同样落在产物之外的 `~/.agent-bundle/state/-`(或 +`AGENT_BUNDLE_STATE_ROOT`)。随后先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin——与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与同意行为。人类模式打印 `MCP App / at (tool ; Ctrl-C stops the server)`,并一直运行到 Ctrl-C / @@ -196,8 +197,8 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的产物根目录,宿主清单直接位于其下,与 `install` 相同。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | -| `--keep-data` | 开启 | 保留持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。 | -| `--purge-data` | 关闭 | 同时删除持久运行时状态。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--keep-data` | 开启 | 保留旧版或就地的持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。(本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`,`uninstall` 不会删除它。) | +| `--purge-data` | 关闭 | 同时删除旧版或就地的持久运行时状态(`state/`、回执记录的 `PLUGIN_DATA`);推导出的 `~/.agent-bundle/state/-` 保留。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | @@ -208,8 +209,8 @@ uninstall 只删除回执归属的内容:记录的文件与安装器创建的 包括仅记录在 Claude 的 `plugins/installed_plugins.json` 注册表中、位于另一作用域或另一项目的 Claude 安装)。 非归属条目被保留并列出;缺少回执为 `AB7009`,不匹配为 `AB7007`,除非 `--force`;再次运行是 `not-installed` 空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; -Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除 `state/` 与 -`plugins/data//`);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 +Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除旧版或就地的 `state/` 与 +`plugins/data//`,但不会删除当前的 `~/.agent-bundle/state/-` 框架状态);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 diff --git a/website/docs/zh/reference/runtime-environment.mdx b/website/docs/zh/reference/runtime-environment.mdx index d6feb4bcb..1b46b0fb5 100644 --- a/website/docs/zh/reference/runtime-environment.mdx +++ b/website/docs/zh/reference/runtime-environment.mdx @@ -35,7 +35,9 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 | 变量 | 由谁读取 | 含义 | | --- | --- | --- | -| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件安装根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录——同时也是持久状态锚点。覆盖内置的回退值;以 `(await agent()).plugin`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_PLUGIN_ROOT` | 生成式可执行文件 | 插件代码根目录——即组合插件根目录,存放宿主清单、`mcp/`、`hooks/` 与 `bin/` 的那个目录。覆盖内置的回退值;以 `(await agent()).plugin.value.root`(`source: 'native'`)暴露给路由与 provider。未展开的 `${…}` token 视为未设置。 | +| `AGENT_BUNDLE_STATE_ROOT` | 生成式可执行文件 | 框架状态根目录:SQLite 状态内核、通知账本与 lineage 日志所在的确切目录。覆盖推导出的位置。未展开的 `${…}` token 视为未设置。 | +| `XDG_STATE_HOME` | 生成式可执行文件 | 设置为绝对路径时(相对值按 base-directory 规范忽略),推导出的状态根目录为 `$XDG_STATE_HOME/agent-bundle/-`,而不是 `~/.agent-bundle/state/-`。 | | `AGENT_BUNDLE_ENV_FILE` | 生成式可执行文件 | 已安装包在启动时改为读取的操作者 env 文件:一个路径,或以平台路径分隔符连接的多个路径(后者胜出),代替 `<插件根目录>/.env` 与 `.env.local`;`none` 关闭这一层。`mcp run` 会根据 `--env-file` / `--no-env` 为其子进程设置它。 | | `AGENT_BUNDLE_AGENT_API_TOKEN` | `agent-bundle dev` | Agent API 在启用之前所必需的 bearer token。 | | `AGENT_BUNDLE_HOOK_SIMULATION` | 生成的钩子 wrapper | `1` 标记一次模拟调用;Workbench 的事件路由工作区会设置它。 | @@ -76,16 +78,24 @@ token 会在构建时报告 `AB6028`,并由 Doctor 报告 `AB7320`。 ## 持久状态 -持久状态解析到 `$AGENT_BUNDLE_PLUGIN_ROOT/state`,回退到产物根目录,对 CLI bin 则回退到 -`./.agent-bundle/state`。只有 `workspace-durable` 状态定义使用 SQLite 驱动;其他生命期使用内存驱动, -不在磁盘上留下任何东西。每个生成的进程只解析该锚点一次(`@agent-bundle/runtime` 的 -`resolvePluginRoot`),并以 `(await agent()).plugin` 发布——`{ root, stateRoot }`,来自变量时 -`source: 'native'`,来自回退时为 `'derived'`——因此把自己的文件放在框架状态旁边的路由或 provider -读取 `plugin.value.stateRoot` 即可,不必再自行推导路径。 +已安装产物——MCP 入口、Flight worker、产物 CLI bin、渲染 worker 与独立 hook 包装器——把框架状态根目录 +推导为 `~/.agent-bundle/state/-`。`` 是代码根目录的 basename,当它不是安全 +路径段时则为 `plugin`;`` 是代码根目录真实路径的 SHA-256 摘要的前 16 位十六进制字符。因此两次 +安装绝不会共享状态,只读安装也能启动。`XDG_STATE_HOME` 按上表把推导位置挪走,`AGENT_BUNDLE_STATE_ROOT` +则覆盖它。npm 包 bin 仍使用 `./.agent-bundle/state`。 + +只有 `workspace-durable` 状态定义使用 SQLite 驱动;其他生命期使用内存驱动, +不在磁盘上留下任何东西。每个生成的进程只解析代码根目录与状态根目录一次(`@agent-bundle/runtime` 的 +`resolvePluginRoot`),并以 `(await agent()).plugin` 发布——`{ root, stateRoot }`——因此把自己的文件 +放在框架状态旁边的路由或 provider 读取 `plugin.value.stateRoot` 即可,不必再自行推导路径。 在 `mcp run` 之下,**env 取值**中的 plugin-root 锚点默认展开到项目根目录,而不是产物:在那里产物只是 一个临时构建产物,把持久状态锚定其上会让状态在每次重建时被割裂。若要按字节忠实地演练一次「复制产物后 启动」,请传入指向产物根目录本身——组合插件根目录,而不是某个宿主子目录——的 `--plugin-root `。 +除非 `AGENT_BUNDLE_STATE_ROOT` 指定了位置,状态根目录随后从展开后的代码根目录推导 +(`~/.agent-bundle/state/-`)。`agent-bundle dev` 与 Workbench 的 MCP 会话则把 +`AGENT_BUNDLE_STATE_ROOT` 固定为 `/state`,因此构建 epoch 的状态随 epoch 一起存在与消失, +而不会在每次重建时多出一个用户数据目录。 当服务器名是单个安全路径段时,逐服务器状态目录直接使用该名字;其他任何名字都会变成内容寻址的 `server-` 段,因此像 `../shared` 这样的名字绝不可能穿出状态根目录。 From 0540989b759fe813c1adc8f181553a4255d6512f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:18:49 +0000 Subject: [PATCH 04/11] fix(package): close npm-root review gaps --- .changeset/canonical-npm-root.md | 2 +- docs/diagnostics.md | 11 ++--- docs/entry-conventions.md | 5 ++- packages/agent-bundle/README.md | 5 ++- packages/agent-bundle/src/api.ts | 1 - .../agent-bundle/src/build/pack-inventory.ts | 12 +++-- .../agent-bundle/src/build/package-build.ts | 10 ++++- packages/agent-bundle/src/install/format.ts | 2 +- .../tests/artifact-cli-bin.test.ts | 19 ++++++-- .../agent-bundle/tests/layout-build.test.ts | 4 +- .../agent-bundle/tests/package-build.test.ts | 13 +++++- .../tests/packed-host-install-proof.test.ts | 45 ++++++++----------- packages/agent-bundle/tests/prepack.test.ts | 6 +-- .../tests/route-contract-imports.test.ts | 2 +- .../tests/support/host-install.ts | 2 +- .../en/guide/authoring/package-entries.mdx | 8 +++- .../en/guide/distribution/installation.mdx | 6 +++ .../docs/en/guide/distribution/validation.mdx | 5 ++- .../zh/guide/authoring/package-entries.mdx | 8 +++- .../zh/guide/distribution/installation.mdx | 5 +++ .../docs/zh/guide/distribution/validation.mdx | 5 ++- 21 files changed, 109 insertions(+), 67 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 739e5add8..42bb8f58f 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Package the validated composite root as the npm root and point generated CLI bins at the manifest-declared executable so every command, including `web`, has artifact parity (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index cb501c9ac..3564fd3bb 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -27,7 +27,7 @@ even when no error diagnostic was reported. | `AB473x` | Migration nudges (informational; see below). | | `AB4740`–`AB4751` | Prebuilt payloads and prebuilt entries (see below). | | `AB4760` | The published `agent-bundle/meta` identity module evaluated outside every compiled surface and outside the Rstest presets (see below). | -| `AB4765`–`AB4766` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it (see below). | +| `AB4765`–`AB4767` | Artifact-hosted routed CLI: a target without the `cli` capability omits `bin/.mjs`; a host-emitted file collides with it; an npm root cannot select a routed CLI absent from the manifest (see below). | | `AB477x` | MCP App view compilation (`AB4770`: compile error with file, line, column and the bundler message; `AB4771`: compile warning; `AB4772`: emitted-size advisory; see below). | | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | @@ -423,9 +423,9 @@ Validation happens at three moments, all fail-closed: | Code | Meaning | | --- | --- | -| `AB7010` | The dry-run npm inventory omits a package output, artifact manifest/file, install surface, or README. Include `dist` and the artifact directory in the package `files` allowlist. | -| `AB7011` | An on-disk artifact file no longer matches its manifest SHA-256. Rebuild and do not modify generated host packs. | -| `AB7012` | A `package.json` bin points outside the packed `dist` output (including `src/`) or names a file npm omitted. Point it at the generated `dist/bin` file. | +| `AB7010` | The dry-run npm inventory omits an expected npm-root file. Pack the generated npm root without excluding its files. | +| `AB7011` | A file in the generated npm root no longer matches its manifest SHA-256. Rebuild and do not modify the generated npm root. | +| `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | | `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply `import` evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript `import` evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts — while `require`, `createRequire`, and `import.meta.resolve` calls, which `AB6005` does not walk, count from any packed file. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed file requires or resolves, a packed declaration references, a `#subpath` import reaches, an install script or packed file runs, or a prebuilt payload names in `runtimeDependencies` (`definePrebuilt`). For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | | `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | @@ -852,7 +852,7 @@ exactly where `build` would refuse. | `AB4105` | error | A component scoped to a subset of the selected hosts (a command or rule with frontmatter `targets`) would be discovered by another selected host that scans the same conventional directory (`commands/` for Claude Code and Cursor, `rules/` for Cursor). Inside one root the file cannot be hidden from that host, so the build refuses rather than leaking it. Skills are never host-scoped — every skill ships to every selected host, and a per-host frontmatter extension that changes its bytes is an `AB4103` collision instead. | Extend the component's `targets` to every selected host that discovers its directory, or build those hosts into separate artifacts. | | `AB4106` | error | The selection mixes an adapter registered on an advanced `TargetRegistry` — any target whose adapter is not one of the shipped `claude`, `codex`, `cursor`, `portable` adapters, judged by adapter identity, so a custom adapter registered under one of those names counts as advanced — with one or more other targets. The built-in hosts agree on where the files they cannot share live, which conventional directories each discovers, and one install surface; a third-party adapter has made none of those agreements, so it cannot share a root. Judged on the normalized model, so `validate`, `inspect`, and `build` all report it, on the non-built-in target with its config provenance. A selection of one target never triggers it, whatever the target; unknown names are `AB4100`'s and do not count. | Build that target alone — `targets: ['']` — into its own `--output`, and the remaining targets into another. | -## Artifact-hosted routed CLI (`AB4765`–`AB4766`) +## Artifact-hosted routed CLI (`AB4765`–`AB4767`) A generated-mode `src/cli/**` surface compiles once into every host artifact whose adapter publishes a supported `cli` capability, as `bin/.mjs` (plus @@ -867,6 +867,7 @@ See “The routed CLI shell” in | --- | --- | --- | | `AB4765` | warning | The project has a routed CLI but a selected target's adapter publishes no supported `cli` capability, so that artifact ships no `bin/.mjs`. Skills, hooks, and scripts in that artifact cannot invoke the routed CLI. `inspect` lists the same omission as an `unsupported-capability` skip of the `cli` component. Publish the capability (with a `cliBin` artifact layout) on the adapter, or keep references to the bin out of that target's surfaces. | | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | +| `AB4767` | error (package build) | The npm package declares a routed CLI, but no selected target emitted its executable into `agent-bundle.manifest.json`, so the npm root has no proven bin to select. Select a target whose adapter publishes the `cli` capability, or set `bin: false`. | ## Config beside a route-generated MCP server (`AB4340`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index e0cc62d97..712f8a81e 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -713,7 +713,7 @@ Per surface, the value the generated request scope mounts: | Surface | `hostSurface` | `stdout` / `stderr` | Source | | --- | --- | --- | --- | -| Routed CLI executable (`dist/bin/.js`, plugin-root `bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | +| Routed CLI executable (npm-root and plugin-root `bin/.mjs`), plain or rendered command, projected MCP command | `cli` | Probed from the executable's own process; a rendered command's worker thread receives the executable's probe, never its own pipes. Machine output owns fd 1, so `stdout` describes where the rendered document lands and `stderr` the channel a route may write to itself. | `native` | | Rendered script (`scripts/.mjs` from `src/scripts/.tsx`) | `script` | Probed, as above. | `native` | | Generated MCP server (any transport) | `mcp` | `none` on both, `color: 'none'`, `sharesTarget: false` — stdout is the protocol wire and stderr the host's log. Never probed, whatever the descriptors are. | `derived` | | Event route (shared runtime or standalone hook process) | `hook` | `none` on both — stdout is the host's hook envelope. Never probed. | `derived` | @@ -782,7 +782,8 @@ through Node's top-level failure path (stack to stderr, exit code 1). (#511), probed once before `main` runs by the dependency-free `agent-bundle/terminal-capability` module the envelope aliases in — plain scripts and bins load no Effect runtime and no `@agent-bundle/runtime` for it. -Its `hostSurface` is `cli` for a package bin (`dist/bin/.js`) and +Its `hostSurface` is `cli` for a package bin (`dist/bin/.js` for an authored bin, +`dist/bin/.mjs` for the manifest-selected routed CLI) and `script` for an artifact script (`scripts/.mjs`); a module shipped on both surfaces sees the surface it was launched from. A `main` declared with one parameter keeps working — the second argument is simply unread. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index aa71ca3ee..38648ed21 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -1047,8 +1047,9 @@ self-contained module run as `node /bin/.mjs route can spawn its `../bin/.mjs` sibling and a Claude skill can point at `${CLAUDE_PLUGIN_ROOT}/bin/.mjs` without a separate npm install. Every built-in host publishes the `cli` capability that admits it; `inspect` accounts for it as a `cli` component, and -the manifest records both files with bundle provenance. The npm package bin under `dist/bin/` is -unchanged. See `docs/entry-conventions.md` for the layout and diagnostics (`AB4765`, `AB4766`). +the manifest records both files with bundle provenance. The generated npm root copies this +executable unchanged and points `package.json` `bin` at it. See `docs/entry-conventions.md` for the +layout and diagnostics (`AB4765`–`AB4767`). ### What gets hashed diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 1fab7f5dd..8e462b69c 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -1399,7 +1399,6 @@ export const prepack = async (options: BuildOptions): Promise => }); const pack = packOutputFromJson(stdout); const diagnostics = await packInventoryDiagnostics({ - artifactRoot: result.build.outputRoot, model: result.model, packageBuild: result.packageBuild, packOutput: pack, diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 0c38a0ce9..a903b9546 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -233,7 +233,6 @@ const dependencyDiagnostics = async (options: { }; export const packInventoryDiagnostics = async (options: { - readonly artifactRoot: string; readonly model: NormalizedPlugin; readonly packageBuild: PackageBuildResult; readonly packOutput: PackOutput; @@ -241,9 +240,8 @@ export const packInventoryDiagnostics = async (options: { readonly packerRewritesWorkspaceProtocols: boolean; readonly projectRoot: string; }): Promise => { - const artifactRoot = resolve(options.artifactRoot); const packageRoot = resolve(options.packageBuild.outputRoot); - const manifestPath = join(artifactRoot, artifactManifestName); + const manifestPath = join(packageRoot, artifactManifestName); const manifest = parseArtifactManifest(await runWithPlatform(readFileString(manifestPath))); const packageDocument = await jsonRecord(join(packageRoot, 'package.json')); const packed = new Set(options.packOutput.files.map((file) => file.path.replace(/^\.\//u, ''))); @@ -264,14 +262,14 @@ export const packInventoryDiagnostics = async (options: { const stale: string[] = []; for (const file of manifest.files) { - const bytes = await runWithPlatform(readFileBytes(join(artifactRoot, file.path))); + const bytes = await runWithPlatform(readFileBytes(join(packageRoot, file.path))); if (sha256Hex(bytes) !== file.sha256) stale.push(file.path); } if (stale.length > 0) { diagnostics.push(diagnostic( 'AB7011', - `Artifact files no longer match their manifest hashes: ${quoteAll(stale.sort())}.`, - 'Run agent-bundle prepack again without modifying generated artifacts.', + `Packed npm-root files no longer match their manifest hashes: ${quoteAll(stale.sort())}.`, + 'Run agent-bundle prepack again without modifying the generated npm root.', )); } @@ -300,7 +298,7 @@ export const packInventoryDiagnostics = async (options: { for (const projection of manifest.projections) { const path = projection.documents.plugin; if (path === undefined) continue; - const absolute = join(artifactRoot, path); + const absolute = join(packageRoot, path); if (await exists(absolute)) { versions.push([path, (await jsonRecord(absolute)).version]); } diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 0ccf074bb..9d58061a8 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -218,12 +218,18 @@ const packageDocument = async ( if (bin.generatedCli === undefined) return [bin.name, `./bin/${bin.name}.js`]; const executable = manifest.executables.bins.find((entry) => entry.name === bin.name); if (executable === undefined) { - throw new Error(`Artifact manifest does not declare routed CLI executable ${JSON.stringify(bin.name)}.`); + throw new DiagnosticError([{ + code: 'AB4767', + message: `The npm package declares routed CLI ${JSON.stringify(bin.name)}, but the artifact manifest has no executable for it.`, + recovery: 'Select a target whose adapter publishes the cli capability, or set bin: false.', + severity: 'error', + sourcePath: bin.provenance.sourcePath, + }]); } return [bin.name, `./${executable.path}`]; })); const transformed = Object.fromEntries(Object.entries(source) - .filter(([key]) => key !== 'files' && key !== 'bin') + .filter(([key]) => !['bin', 'files', 'scripts'].includes(key)) .map(([key, value]) => [ key, ['exports', 'main', 'module', 'types', 'typesVersions'].includes(key) diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index 029633062..c103d9ca0 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -23,7 +23,7 @@ const installVerb = (state: InstallResult['state'], mode: InstallResult['mode']) } }; -/** Human-readable install summary shared by the CLI and generated installer bins. */ +/** Human-readable install summary shared by the CLI and standalone artifact installer. */ export const formatInstallResult = (result: InstallResult): string => { const destination = result.destination ?? result.bundleRoot; const mode = result.mode === undefined ? '' : ` (${result.mode} mode)`; diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index f55a39c51..1ce6aa57f 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -288,6 +288,18 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew target: 'legacy-host', })); expect(legacyResult.diagnostics.filter((entry) => entry.code === 'AB4765')).toHaveLength(1); + await expect(build({ + output: 'package-artifact', + packageOutputs: true, + registry, + root: legacyRoot, + })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB4767', + severity: 'error', + })], + name: 'DiagnosticError', + }); // Help, version, and the rendered .tsx command ride the same executable. const claudeBin = binPath; @@ -347,14 +359,15 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew reason: 'unsupported-capability', }); - // `inspect --bundler` dumps the composite bin composition beside the - // scripts; the npm package bin (no target) keeps its own entry. + // `inspect --bundler` dumps the one composite bin composition beside the + // scripts. The npm root copies that executable, so there is no second + // target-less package compilation to report. const bundler = await inspect({ focus: 'bundler', registry, root }); if (bundler.state !== 'ready') throw new Error('unreachable'); const binEntries = (bundler.selected?.bundler?.entries ?? []) .filter((entry) => entry.kind === 'bin' && entry.target !== undefined); expect((bundler.selected?.bundler?.entries ?? []).some((entry) => - entry.kind === 'bin' && entry.target === undefined && entry.outputPath === `dist/bin/${pluginName}.js`)).toBe(true); + entry.kind === 'bin' && entry.target === undefined)).toBe(false); expect(binEntries.map((entry) => entry.outputPath).sort()).toEqual([ `bin/${pluginName}-flight.mjs`, `bin/${pluginName}.mjs`, diff --git a/packages/agent-bundle/tests/layout-build.test.ts b/packages/agent-bundle/tests/layout-build.test.ts index 5c32ae382..d507bd4ef 100644 --- a/packages/agent-bundle/tests/layout-build.test.ts +++ b/packages/agent-bundle/tests/layout-build.test.ts @@ -190,7 +190,7 @@ it('composes the root and server layouts around every rendered surface of one bu } // The routed CLI: a rendered command takes only the root layout. - const binPath = join(root, 'dist', 'bin', 'layout-fixture.js'); + const binPath = join(root, 'dist', 'bin', 'layout-fixture.mjs'); const piped = await execFile(binPath, ['report', '/library']); expect(piped.stdout).toBe('Found **2** books under /library.\n\n> shell: cli report\n'); const reportJson = await execFile(binPath, ['report', '/library', '--json']); @@ -256,7 +256,7 @@ it('ships byte-identical surfaces when no layout exists and refuses an invalid l const output = join(root, 'artifact'); const result = await build({ output, packageOutputs: true, root }); expect(result.model.layouts).toBeUndefined(); - const binPath = join(root, 'dist', 'bin', 'layout-fixture.js'); + const binPath = join(root, 'dist', 'bin', 'layout-fixture.mjs'); const piped = await execFile(binPath, ['report', '/library']); expect(piped.stdout).toBe('Found **2** books under /library.\n'); const projected = await execFile(binPath, ['harness', 'lookup', '--input', '{"message":"plain"}']); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 99257f1f7..88d866510 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -122,7 +122,10 @@ const unpublishedPackageOutput = async (root: string): Promise => { describe('framework-owned package build', () => { it('builds bin, lib, and dts outputs from conventions and stays deterministic', async () => { - const root = await fixtureRoot(conventionFixture()); + const root = await fixtureRoot({ + ...conventionFixture(), + 'package.json': '{"name":"package-build-fixture","type":"module","private":true,"scripts":{"prepack":"agent-bundle prepack","test":"rstest"}}\n', + }); await installTypescriptToolchain(root); const result = await build({ output: 'artifact', packageOutputs: true, root }); @@ -133,6 +136,14 @@ describe('framework-owned package build', () => { const packageBuild = result.packageBuild; expect(packageBuild).toBeDefined(); expect(packageBuild!.outputRoot).toBe(join(root, 'dist')); + const packageDocument = JSON.parse(await readFile(join(root, 'dist', 'package.json'), 'utf8')); + expect(packageDocument).toMatchObject({ + bin: { 'package-build-fixture': './bin/package-build-fixture.js' }, + name: 'package-build-fixture', + private: true, + type: 'module', + }); + expect(packageDocument).not.toHaveProperty('scripts'); const paths = packageBuild!.files.map((file) => file.path); expect(paths).toContain('bin/package-build-fixture.js'); expect(paths).toContain('index.js'); diff --git a/packages/agent-bundle/tests/packed-host-install-proof.test.ts b/packages/agent-bundle/tests/packed-host-install-proof.test.ts index de6bc9ed6..504e67786 100644 --- a/packages/agent-bundle/tests/packed-host-install-proof.test.ts +++ b/packages/agent-bundle/tests/packed-host-install-proof.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile, spawnSync } from 'node:child_process'; -import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { access, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -15,7 +15,6 @@ import { runCursorHostInstallProof, runHostUninstallProof, type BuiltHostInstallFixture, - type HostInstallCommand, } from './support/host-install.ts'; import { installedEnvironment, @@ -49,7 +48,6 @@ const codexPluginIt = codexAvailable ? it : it.skip; let cleanupRoot: string | undefined; let sourceFixture: BuiltHostInstallFixture | undefined; let packedFixture: BuiltHostInstallFixture | undefined; -let packedInstallCommand: HostInstallCommand | undefined; let fixturePackageVersion: string | undefined; beforeAll(async () => { @@ -65,8 +63,6 @@ beforeAll(async () => { } fixturePackageVersion = packageDocument.version; delete packageDocument.private; - packageDocument.bin = { [pluginName]: `./dist/bin/${pluginName}.js` }; - packageDocument.files = ['artifact', 'dist', 'README.md']; await Promise.all([ writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), writeFile(join(projectRoot, 'README.md'), '# Host install proof fixture\n'), @@ -90,7 +86,7 @@ beforeAll(async () => { const packed = await execFile( 'npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', tarballs], - { cwd: projectRoot, env: installedEnvironment() }, + { cwd: join(projectRoot, 'dist'), env: installedEnvironment() }, ); const packOutput = packOutputFromJson(packed.stdout); const tarball = join(tarballs, packOutput.filename); @@ -100,10 +96,17 @@ beforeAll(async () => { }); const installedPackageRoot = join(consumer, 'node_modules', packageName); - const installedArtifactRoot = join(installedPackageRoot, 'artifact'); - const installedBin = join(consumer, 'node_modules', '.bin', pluginName); + const installedArtifactRoot = join(consumer, 'packed-artifact'); + const manifestName = 'agent-bundle.manifest.json'; + const manifestText = await readFile(join(installedPackageRoot, manifestName), 'utf8'); + const manifest = JSON.parse(manifestText) as { readonly files: readonly { readonly path: string }[] }; + await mkdir(installedArtifactRoot); + for (const file of [...manifest.files, { path: manifestName }]) { + const destination = join(installedArtifactRoot, file.path); + await mkdir(dirname(destination), { recursive: true }); + await copyFile(join(installedPackageRoot, file.path), destination); + } await Promise.all([ - access(installedBin), access(join(installedArtifactRoot, '.claude-plugin', 'plugin.json')), access(join(installedArtifactRoot, '.codex-plugin', 'plugin.json')), access(join(installedArtifactRoot, '.cursor-plugin', 'plugin.json')), @@ -111,7 +114,7 @@ beforeAll(async () => { await rm(projectRoot, { force: true, recursive: true }); await expect(stat(projectRoot), proofLabel).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(access(join(projectRoot, 'dist', 'bin', `${pluginName}.js`)), proofLabel) + await expect(access(join(projectRoot, 'dist', 'bin', `${pluginName}.mjs`)), proofLabel) .rejects.toMatchObject({ code: 'ENOENT' }); packedFixture = Object.freeze({ @@ -124,7 +127,6 @@ beforeAll(async () => { cli: sourceFixture.cli, root: cleanupRoot, }); - packedInstallCommand = Object.freeze({ cwd: consumer, executable: installedBin }); }, 300_000); afterAll(async () => { @@ -139,11 +141,6 @@ const builtFixture = (): BuiltHostInstallFixture => { return packedFixture; }; -const installCommand = (): HostInstallCommand => { - if (packedInstallCommand === undefined) throw new Error(`[${proofLabel}] packed installer setup did not complete.`); - return packedInstallCommand; -}; - const expectHygienicReport = (report: unknown): void => { expect(JSON.stringify(report), proofLabel).not.toMatch( /(?:API_KEY|AUTH_TOKEN|ACCESS_TOKEN|authorization|credential|password|secret|sk-[A-Za-z0-9_-]{16,}|\/home\/|\/Users\/|\/tmp\/|stdout|stderr)/iu, @@ -157,7 +154,6 @@ claudePluginIt( async () => { const report = await runClaudeHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -189,7 +185,6 @@ codexPluginIt( async () => { const report = await runCodexHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -226,7 +221,6 @@ codexPluginIt( it('installs the packed tarball into an isolated Cursor home, validates schemas, and is idempotent', async () => { const report = await runCursorHostInstallProof(builtFixture(), { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ @@ -277,12 +271,11 @@ it('installs the packed tarball into an isolated Cursor home, validates schemas, claudePluginIt( claudeAvailable - ? 'uninstalls the packed tarball through Claude with the package bin, leaving only host-owned bookkeeping' - : `uninstalls the packed tarball through Claude with the package bin, leaving only host-owned bookkeeping [${claudeMissingEvidence}]`, + ? 'uninstalls the packed tarball through Claude, leaving only host-owned bookkeeping' + : `uninstalls the packed tarball through Claude, leaving only host-owned bookkeeping [${claudeMissingEvidence}]`, async () => { const report = await runHostUninstallProof(builtFixture(), 'claude', { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toMatchObject({ agentBundleResidue: [], @@ -298,12 +291,11 @@ claudePluginIt( codexPluginIt( codexAvailable - ? 'uninstalls the packed tarball through Codex with the package bin' - : `uninstalls the packed tarball through Codex with the package bin [${codexMissingEvidence}]`, + ? 'uninstalls the packed tarball through Codex' + : `uninstalls the packed tarball through Codex [${codexMissingEvidence}]`, async () => { const report = await runHostUninstallProof(builtFixture(), 'codex', { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toMatchObject({ agentBundleResidue: [], @@ -317,10 +309,9 @@ codexPluginIt( 300_000, ); -it('uninstalls the packed tarball from an isolated Cursor home with the package bin and leaves it byte-identical', async () => { +it('uninstalls the packed tarball from an isolated Cursor home and leaves it byte-identical', async () => { const report = await runHostUninstallProof(builtFixture(), 'cursor', { environment: process.env, - installCommand: installCommand(), }); expect(report, proofLabel).toEqual({ agentBundleResidue: [], diff --git a/packages/agent-bundle/tests/prepack.test.ts b/packages/agent-bundle/tests/prepack.test.ts index d4a05aa41..c540eb80e 100644 --- a/packages/agent-bundle/tests/prepack.test.ts +++ b/packages/agent-bundle/tests/prepack.test.ts @@ -67,7 +67,7 @@ beforeAll(async () => { writeFile(join(projectRoot, 'src', 'index.ts'), 'export const value = 1;\n'), ]); result = await prepack({ root: projectRoot }); - payloadPath = join(projectRoot, 'host-packs', 'INSTALL.md'); + payloadPath = join(projectRoot, 'dist', 'INSTALL.md'); payloadBytes = await readFile(payloadPath, 'utf8'); }); @@ -80,7 +80,6 @@ const diagnostics = ( packerRewritesWorkspaceProtocols = false, ): Promise => packInventoryDiagnostics({ - artifactRoot: result.build.build.outputRoot, model: result.build.model, packageBuild: result.build.packageBuild!, packOutput, @@ -174,7 +173,7 @@ it('requires README.md even when the source file is absent', async () => { } }); -it('reports stale artifact hashes as AB7011', async () => { +it('reports stale npm-root hashes as AB7011', async () => { await writeFile(payloadPath, `${payloadBytes}stale\n`); try { expect(await diagnostics()).toContainEqual(expect.objectContaining({ code: 'AB7011' })); @@ -261,7 +260,6 @@ it('accepts a dependency declared by a prebuilt payload runtimeDependencies list targets: ['claude'], }; const reported = withCode(await packInventoryDiagnostics({ - artifactRoot: result.build.build.outputRoot, model: { ...result.build.model, payloads: [payload] }, packageBuild: result.build.packageBuild!, packOutput: result.pack, diff --git a/packages/agent-bundle/tests/route-contract-imports.test.ts b/packages/agent-bundle/tests/route-contract-imports.test.ts index ec9b547ae..85e7158dd 100644 --- a/packages/agent-bundle/tests/route-contract-imports.test.ts +++ b/packages/agent-bundle/tests/route-contract-imports.test.ts @@ -270,7 +270,7 @@ it('shares imported route contracts across graph, argv, runtime, and generated t const built = await build({ output: 'artifact', packageOutputs: true, root: importedRoot }); expect(built.diagnostics).toEqual([]); - const binPath = join(importedRoot, 'dist', 'bin', 'route-contract-imported-fixture.js'); + const binPath = join(importedRoot, 'dist', 'bin', 'route-contract-imported-fixture.mjs'); const help = await execFile(binPath, ['status', '--help']); for (const option of ['--lane-key', '--limit', '--statuses', '--tickets']) { expect(help.stdout).toContain(option); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 6e89e4402..9279f75c2 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -536,7 +536,7 @@ const assertInstallResult = ( assertProof(document.host === host, `${host} install result did not identify the host.`); assertProof(document.plugin === plugin, `${host} install result did not identify ${plugin}.`); assertProof(document.version === version, `${host} install result did not identify version ${version}.`); - assertProof(document.state === state, `${host} install result state was not ${state}.`); + assertProof(document.state === state, `${host} install result state was ${document.state}, expected ${state}.`); assertProof( typeof document.contentHash === 'string' && /^[0-9a-f]{64}$/u.test(document.contentHash), `${host} install result carried no artifact content hash.`, diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 62e2e787e..9f588858d 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -447,7 +447,13 @@ npx agent-bundle prepack --root . --output artifact --json `prepack` runs the release build and `npm pack --dry-run --json --ignore-scripts`, then gates the exact package and artifact inventory, manifest hashes, package bin targets, and release-version -agreement. Use it as an npm `prepack` script; `--ignore-scripts` prevents recursion. +agreement. It removes source-project scripts from the generated package metadata, so development +lifecycle commands cannot run from `dist/`. Run the gate first, then publish the npm root it +validated: + +```sh +npm publish ./dist --ignore-scripts +``` ## Prebuilt payloads diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 5bdb0db26..3dd239a8d 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -83,6 +83,12 @@ without a second compilation or wrapper. Its complete command surface, including works from `node_modules` exactly as it does from a copied artifact. **No npm lifecycle performs a host installation** — installing a package never mutates a host's plugin state. +Install Claude or Codex explicitly from that root with +`npx agent-bundle install --from node_modules/`. For Cursor, the npm root also +carries the artifact's standalone `install.mjs`; run it from the installed package root. The +packed host proof reconstructs the manifest-owned artifact from the installed package and exercises +`agent-bundle install --from` after deleting the source checkout. + ## Development installs are a different thing `agent-bundle dev --install-host ` maintains a *marked development* install that follows diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index c870070b4..cb8ffbf67 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -211,8 +211,9 @@ payload module's import, a `require`, `createRequire`, or `import.meta.resolve` file that the compiler did not resolve as a module dependency, a packed declaration's reference, a consumer install script, or a `bin` command a packed file runs — since only JavaScript the framework did not compile can still import one. `--output` is an artifact path relative to `--root` that overrides the configured -`output.distPath`, defaulting to `artifact`. Use it as an npm `prepack` script; `--ignore-scripts` -prevents recursion, and no npm lifecycle ever performs a host install. +`output.distPath`, defaulting to `artifact`. The generated npm root omits source-project scripts. +After the gate succeeds, publish exactly the root it validated with +`npm publish ./dist --ignore-scripts`; no npm lifecycle performs a host install. That inlining is the compiler's contract, not a bundler default a toolchain upgrade may flip. Generated executables — artifact scripts, the routed CLI, MCP entries, hook wrappers, and diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 9134a100c..d8198cda3 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -399,8 +399,12 @@ npx agent-bundle prepack --root . --output artifact --json ``` `prepack` 运行发布构建与 `npm pack --dry-run --json --ignore-scripts`,随后对精确的包与产物清单、 -清单哈希、包 bin 目标以及发布版本一致性设卡。把它用作 npm 的 `prepack` 脚本;`--ignore-scripts` -可防止递归。 +清单哈希、包 bin 目标以及发布版本一致性设卡。生成的包元数据会移除源项目脚本,避免开发期生命周期命令 +从 `dist/` 运行。先运行门禁,再发布它已经校验的 npm 根目录: + +```sh +npm publish ./dist --ignore-scripts +``` ## 预构建 payload diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index a032a0ecf..79250c12e 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -68,6 +68,11 @@ npm 包根目录本身就包含组合插件根。对于生成的路由式 CLI, `node_modules` 运行时与从复制的产物运行完全一致。**任何 npm 生命周期都不会执行宿主安装**——安装一个包 绝不会改动宿主的插件状态。 +请用 `npx agent-bundle install --from node_modules/` 从该根目录显式安装 Claude 或 +Codex 集成。对于 Cursor,npm 根目录还包含产物的独立 `install.mjs`;请从已安装包的根目录运行它。 +打包宿主证明会从已安装包重建清单拥有的产物,并在删除源 checkout 后执行 +`agent-bundle install --from`。 + ## 开发期安装是另一回事 `agent-bundle dev --install-host ` 维护的是一个*标记为开发用*的安装,它跟随成功的重建 epoch, diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index dd68b6f79..a3649fdb9 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -182,8 +182,9 @@ bundle——都不能把裸包保持外部化:编译器会在 `prepack` 触及 文件中未被编译器解析为模块依赖的 `require`、`createRequire` 或 `import.meta.resolve` 调用、打包后声明文件 的引用、消费者侧安装脚本,或打包后的文件运行的 `bin` 命令——因为只有框架没有编译的 JavaScript 才仍然 可能导入它。`--output` 是相对 -`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。把它用作 npm 的 `prepack` -脚本;`--ignore-scripts` 可防止递归,而且任何 npm 生命周期都绝不会执行宿主安装。 +`--root` 的产物路径,会覆盖配置中的 `output.distPath`,默认值为 `artifact`。生成的 npm 根目录会移除 +源项目脚本。门禁成功后,用 `npm publish ./dist --ignore-scripts` 发布它校验过的准确根目录;任何 npm +生命周期都绝不会执行宿主安装。 这种内联是编译器的契约,而不是一次工具链升级就可能翻转的打包器默认值。生成的可执行文件——产物脚本、路由式 CLI、MCP 入口、钩子包装层以及包专用 JavaScript bundle——都以禁用 Rslib 的 `autoExternal`、`bundle: true` 与 From 00467a486fe91f411d37eed0b9d0d1bc40aee55f Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:48:12 -0700 Subject: [PATCH 05/11] fix(build): isolate re-bundled runtime entries (#636) * fix(build): isolate re-bundled runtime entries * chore: add runtime boundary changeset * fix(test): restore release dist after packed fixture * test: preserve ad hoc packed runs * test: isolate runtime rebundle tarball * test: scope rebundle fixture to stdio proof * test: retain release stdio process proof --- .changeset/runtime-rebundle-boundary.md | 5 + packages/agent-bundle/rslib.config.ts | 104 ++++++++++-------- .../runtime-rebundle/mcp-server-runtime.ts | 5 + .../runtime-rebundle/private-sibling.ts | 9 ++ .../tests/packed-stdio-projection.test.ts | 13 ++- .../tests/rstest-rslib-adapter.test.ts | 18 ++- .../agent-bundle/tests/support/shared-pack.ts | 12 +- rstest.rslib.ts | 7 +- scripts/run-packed-tests.mjs | 50 ++++++++- 9 files changed, 165 insertions(+), 58 deletions(-) create mode 100644 .changeset/runtime-rebundle-boundary.md create mode 100644 packages/agent-bundle/tests/fixtures/runtime-rebundle/mcp-server-runtime.ts create mode 100644 packages/agent-bundle/tests/fixtures/runtime-rebundle/private-sibling.ts diff --git a/.changeset/runtime-rebundle-boundary.md b/.changeset/runtime-rebundle-boundary.md new file mode 100644 index 000000000..ceb8884d1 --- /dev/null +++ b/.changeset/runtime-rebundle-boundary.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Keep generated stdio entries startable when private runtime modules are added (#636). diff --git a/packages/agent-bundle/rslib.config.ts b/packages/agent-bundle/rslib.config.ts index 8468fba9d..848b37b52 100644 --- a/packages/agent-bundle/rslib.config.ts +++ b/packages/agent-bundle/rslib.config.ts @@ -59,7 +59,7 @@ const appDeclarationEntrypointPlugin: RsbuildPlugin = { const buildCacheDirectory = process.env['AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY']; /** - * The `id` of the single lib entry. To Rslib an id is a name: it labels the + * The `id` of the public lib entry. To Rslib an id is a name: it labels the * Rsbuild environment the entry becomes (`esm` when unset — * `composeRsbuildEnvironments` in @rslib/core), so it shows in build logs, * selects the entry for `rslib build --lib`, and keys the persistent build @@ -69,6 +69,38 @@ const buildCacheDirectory = process.env['AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY']; * entry's fields into the pools' test build. */ export const agentBundleLibId = 'esm-node'; +export const agentBundleRuntimeLibId = 'runtime-node'; + +const publicEntries = { + api: './src/api.ts', + cli: './src/cli.ts', + config: './src/config/index.ts', + eval: './src/eval/index.ts', + index: './src/index.ts', + 'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts', + 'mcp-apps': './src/mcp-apps.ts', + 'route-invocation-child': './src/dev/routes/route-invocation-child.ts', + rstest: './src/rstest/index.ts', + test: './src/test/index.ts', + 'test/browser': './src/test/browser.ts', +}; + +const runtimeEntries = { + app: './src/app/index.ts', + 'cli-entry': './src/cli-entry.ts', + 'event-ipc': './src/events/ipc.ts', + 'event-project': './src/events/project.ts', + 'install-entry': './src/install-entry.ts', + 'launch-env': './src/launch-env.ts', + 'mcp-entry': './src/mcp-entry.ts', + 'mcp-server-runtime': process.env['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE'] === '1' + ? './tests/fixtures/runtime-rebundle/mcp-server-runtime.ts' + : './src/mcp-server-runtime.ts', + meta: './src/meta.ts', + routes: './src/routes/public.ts', + 'terminal-capability': './src/terminal-capability.ts', + 'web-host': './src/web-host.ts', +}; export default defineConfig({ lib: [ @@ -85,15 +117,35 @@ export default defineConfig({ // no packed declaration, reachable or not, may import a devDependency. dts: true, format: 'esm', + output: { + copy: [ + { from: resolve(import.meta.dirname, '../workbench/dist'), to: 'workbench', info: { minimized: true } }, + { from: resolve(import.meta.dirname, 'web-host-dist'), to: 'web-host', info: { minimized: true } }, + ], + }, + plugins: [appDeclarationEntrypointPlugin], + source: { + entry: publicEntries, + }, + syntax: 'es2022', + }, + { + id: agentBundleRuntimeLibId, + bundle: true, + dts: false, + format: 'esm', + source: { + entry: runtimeEntries, + }, + // These entries are inputs to a second Rspack compilation when the + // compiler generates an artifact. Keeping them outside the public + // graph's dynamic runtime import gives their transitive private modules + // a re-bundle-safe placement without promoting siblings to entries. syntax: 'es2022', }, ], output: { cleanDistPath: true, - copy: [ - { from: resolve(import.meta.dirname, '../workbench/dist'), to: 'workbench', info: { minimized: true } }, - { from: resolve(import.meta.dirname, 'web-host-dist'), to: 'web-host', info: { minimized: true } }, - ], filenameHash: false, legalComments: 'linked', target: 'node', @@ -104,7 +156,6 @@ export default defineConfig({ plugins: [ // Suggestions stay informational; errors and warnings block publishing. pluginPublint({ throwOn: 'warning' }), - appDeclarationEntrypointPlugin, // The bundled TypeScript 5 parser's eager `getNodeSystem()` reads the // CommonJS `__filename`/`__dirname` globals, which the ESM output does // not define and which Rspack's `node-module` rewrite (disabled below) @@ -141,46 +192,5 @@ export default defineConfig({ define: { __AGENT_BUNDLE_VERSION__: JSON.stringify(packageManifest.version), }, - entry: { - api: './src/api.ts', - app: './src/app/index.ts', - cli: './src/cli.ts', - 'cli-entry': './src/cli-entry.ts', - config: './src/config/index.ts', - eval: './src/eval/index.ts', - 'event-ipc': './src/events/ipc.ts', - 'event-project': './src/events/project.ts', - index: './src/index.ts', - 'install-entry': './src/install-entry.ts', - 'launch-env': './src/launch-env.ts', - 'lifecycle-render-child': './src/dev/playground/lifecycle-render-child.ts', - 'mcp-apps': './src/mcp-apps.ts', - 'mcp-entry': './src/mcp-entry.ts', - meta: './src/meta.ts', - // Same reason as `mcp-tasks` below: the runtime's only other private - // sibling. Concatenated into the runtime's chunk it makes that chunk - // host two modules, so rslib synthesizes the runtime's namespace - // object (for `agent-bundle/test`'s dynamic import) through its own - // `__webpack_require__`, and the generated stdio entry fails to start - // with `__webpack_modules__[moduleId] is not a function`. - 'mcp-schema-projection': './src/mcp-schema-projection.ts', - 'mcp-server-runtime': './src/mcp-server-runtime.ts', - // Its own entry so it is emitted as a chunk beside the runtime rather - // than concatenated into it: a generated artifact bundles - // `dist/mcp-server-runtime.js`, and a chunk that also hosts a sibling - // module carries rslib's `__webpack_require__` runtime import, whose - // identifiers shadow the artifact bundler's own runtime. - 'mcp-tasks': './src/mcp-tasks.ts', - // The route authoring surface: types plus the compile-time helpers a - // route module may import at run time without pulling the compiler - // into its generated bundle. - routes: './src/routes/public.ts', - 'route-invocation-child': './src/dev/routes/route-invocation-child.ts', - rstest: './src/rstest/index.ts', - 'terminal-capability': './src/terminal-capability.ts', - test: './src/test/index.ts', - 'test/browser': './src/test/browser.ts', - 'web-host': './src/web-host.ts', - }, }, }); diff --git a/packages/agent-bundle/tests/fixtures/runtime-rebundle/mcp-server-runtime.ts b/packages/agent-bundle/tests/fixtures/runtime-rebundle/mcp-server-runtime.ts new file mode 100644 index 000000000..fee72ed06 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/runtime-rebundle/mcp-server-runtime.ts @@ -0,0 +1,5 @@ +import { assertPrivateSiblingLoaded } from './private-sibling.ts'; + +assertPrivateSiblingLoaded(); + +export * from '../../../src/mcp-server-runtime.ts'; diff --git a/packages/agent-bundle/tests/fixtures/runtime-rebundle/private-sibling.ts b/packages/agent-bundle/tests/fixtures/runtime-rebundle/private-sibling.ts new file mode 100644 index 000000000..be981ff15 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/runtime-rebundle/private-sibling.ts @@ -0,0 +1,9 @@ +const marker = 'AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE_EXECUTED'; + +process.env[marker] = '1'; + +export const assertPrivateSiblingLoaded = (): void => { + if (process.env[marker] !== '1') { + throw new Error('Synthetic runtime sibling did not execute.'); + } +}; diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index ac235d289..cbaf65558 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -46,12 +46,18 @@ interface McpJson { * (tests/projection/) covers the same route protocol surface at a fraction of * the cost and explicitly does not claim any of this. */ -it('serves compiled routes and durable state across packed process restarts', async () => { +it.each([ + ['release', 'agent-bundle'], + ['private runtime sibling', 'agent-bundle-runtime-rebundle'], +] as const)('serves compiled routes from the %s package across packed process restarts', async (_variant, packageName) => { const [agentBundle, runtime, markdownStream] = await Promise.all([ - sharedPackedTarball('agent-bundle'), + sharedPackedTarball(packageName), sharedPackedTarball('runtime'), sharedPackedTarball('markdown-stream'), ]); + expect(agentBundle.variant).toBe( + packageName === 'agent-bundle-runtime-rebundle' ? 'runtime-rebundle' : undefined, + ); const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-stdio-')); const project = join(consumer, 'project'); const artifact = join(project, 'artifact'); @@ -258,6 +264,9 @@ it('serves compiled routes and durable state across packed process restarts', as // fills `HARNESS_FROM_FILE` and `.env.local`'s `HARNESS_LOCAL`, the host's // exported `HARNESS_HOST_WINS` is untouched, and nothing was logged. for (const [name, value] of [ + ...(packageName === 'agent-bundle-runtime-rebundle' + ? [['AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE_EXECUTED', '1'] as const] + : []), ['HARNESS_FROM_FILE', 's3cr3t-from-file'], ['HARNESS_LOCAL', 'from-local'], ['HARNESS_HOST_WINS', 'from-host'], diff --git a/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts b/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts index df27e6e7d..f62d5a535 100644 --- a/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts +++ b/packages/agent-bundle/tests/rstest-rslib-adapter.test.ts @@ -4,7 +4,7 @@ import type { RslibConfig } from '@rslib/core'; import { withRslibConfig } from '@rstest/adapter-rslib'; import { describe, expect, it, type ExtendConfig } from '@rstest/core'; -import agentBundleRslibConfig from '../rslib.config.ts'; +import agentBundleRslibConfig, { agentBundleRuntimeLibId } from '../rslib.config.ts'; import packageManifest from '../package.json' with { type: 'json' }; import { agentBundleRslibAdapterOptions, rstestHygiene, withAgentBundleRslibConfig } from '../../../rstest.rslib.ts'; import { agentBundlePackageRoot } from './helpers/workspace-paths.ts'; @@ -30,9 +30,21 @@ let poolConfig: Promise | undefined; const resolvedPoolConfig = (): Promise => (poolConfig ??= Promise.resolve(withAgentBundleRslibConfig()({}))); describe('rstest.rslib.ts', () => { - it('passes as libId the id of the single lib entry', () => { + it('selects the public lib while the re-bundled runtime has its own profile', () => { expect(agentBundleRslibAdapterOptions.cwd).toBe(agentBundlePackageRoot); - expect((agentBundleRslibConfig.lib ?? []).map((lib) => lib.id)).toEqual([agentBundleRslibAdapterOptions.libId]); + expect((agentBundleRslibConfig.lib ?? []).map((lib) => lib.id)).toEqual([ + agentBundleRslibAdapterOptions.libId, + agentBundleRuntimeLibId, + ]); + expect(agentBundleRslibConfig.lib?.find((lib) => lib.id === agentBundleRuntimeLibId)).toMatchObject({ + dts: false, + source: { + entry: { + app: './src/app/index.ts', + 'mcp-server-runtime': './src/mcp-server-runtime.ts', + }, + }, + }); }); it('reads the lib entry through libId only — the adapter falls back to an empty entry without a diagnostic', async () => { diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index 372d6a094..016537919 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -21,13 +21,20 @@ export interface SharedPack { /** The package's own `npm pack --json` entry recorded when the tarball was produced. */ readonly packOutput: SharedPackOutput; readonly tarball: string; + readonly variant?: 'runtime-rebundle'; } -export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle' | 'markdown-stream' | 'runtime'; +export type SharedPackPackage = + | 'agent-bundle' + | 'agent-bundle-runtime-rebundle' + | 'create-agent-bundle' + | 'markdown-stream' + | 'runtime'; /** packages/ directory and npm package name for each shared-pack key. */ const sharedPackPackages: Readonly>> = { 'agent-bundle': { directory: 'agent-bundle', npmName: 'agent-bundle' }, + 'agent-bundle-runtime-rebundle': { directory: 'agent-bundle', npmName: 'agent-bundle' }, 'create-agent-bundle': { directory: 'create-agent-bundle', npmName: 'create-agent-bundle' }, // `@agent-bundle/runtime` depends on it by exact version; a consumer that // installs the runtime tarball needs this one alongside until that version @@ -100,6 +107,9 @@ const packOnce = async (packageName: SharedPackPackage): Promise => if (sharedDirectory !== undefined && sharedDirectory.length > 0) { return JSON.parse(await readFile(join(sharedDirectory, `${packageName}.json`), 'utf8')) as SharedPack; } + if (packageName === 'agent-bundle-runtime-rebundle') { + throw new Error('The runtime re-bundle fixture is prepared by `pnpm test:packed`; run the packed pool through that script.'); + } // Ad-hoc single-file runs have no run-level tarball, so build once (unless // the caller marked the workspace dist prebuilt) and pack into a // per-process temporary directory that is dropped on exit. The build diff --git a/rstest.rslib.ts b/rstest.rslib.ts index a7a35fae5..39785e062 100644 --- a/rstest.rslib.ts +++ b/rstest.rslib.ts @@ -62,10 +62,9 @@ export const rstestHygiene = { * The lib entry is found by `libId` (line 53: `lib.find((l) => l.id === * libId) || {}`); without a `libId`, or with one no entry carries, the entry * is silently `{}` and only the top-level fields count. That is why the entry - * has an `id` and these options pass it: the top-level fields happen to carry - * everything the pools need, so the result was right by accident, and a field - * moved into the entry — `output.target`, `source.define` — would have - * vanished from every pool without a diagnostic. Of the entry, only `source`, + * has an `id` and these options pass it: a field moved into the selected + * public entry — `output.target`, `source.define` — would otherwise vanish + * from every pool without a diagnostic. Of the entry, only `source`, * `output`, `tools`, `plugins`, and `resolve` are merged over the top-level * config (lines 54-61); `format` is read once more, directly, as the fallback * for `output.module` (line 105). diff --git a/scripts/run-packed-tests.mjs b/scripts/run-packed-tests.mjs index ee58b852d..a3b27b135 100644 --- a/scripts/run-packed-tests.mjs +++ b/scripts/run-packed-tests.mjs @@ -9,7 +9,7 @@ * to rstest. */ import { execFile as executeFile, spawn } from 'node:child_process'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -59,6 +59,54 @@ try { `${JSON.stringify({ packOutput, tarball: join(packDirectory, packOutput.filename) })}\n`, ); })); + // Build the synthetic private sibling into a separate package image. The + // normal dist and shared release tarball above remain the publish candidate. + const fixtureDist = join(packDirectory, 'runtime-rebundle-dist'); + await execFile(join(repositoryRoot, 'node_modules', '.bin', 'rslib'), [ + 'build', + '--config', + join(repositoryRoot, 'packages', 'agent-bundle', 'rslib.config.ts'), + '--dist-path', + fixtureDist, + ], { + cwd: repositoryRoot, + env: { + ...environment, + AGENT_BUNDLE_RSLIB_CACHE_DIRECTORY: join(packDirectory, 'runtime-rebundle-cache'), + AGENT_BUNDLE_RUNTIME_REBUNDLE_FIXTURE: '1', + NODE_ENV: 'production', + }, + }); + const fixturePackage = join(packDirectory, 'runtime-rebundle-package'); + await mkdir(fixturePackage); + const agentBundlePackageRoot = join(repositoryRoot, 'packages', 'agent-bundle'); + const agentBundleManifest = JSON.parse(await readFile(join(agentBundlePackageRoot, 'package.json'), 'utf8')); + await Promise.all(['package.json', ...agentBundleManifest.files.filter((name) => name !== 'dist')].map((name) => cp( + join(agentBundlePackageRoot, name), + join(fixturePackage, name), + { recursive: true }, + ))); + await cp(fixtureDist, join(fixturePackage, 'dist'), { recursive: true }); + const fixturePackDirectory = join(packDirectory, 'runtime-rebundle-pack'); + await mkdir(fixturePackDirectory); + const { stdout: fixturePacked } = await execFile('npm', [ + 'pack', + '--json', + '--pack-destination', + fixturePackDirectory, + ], { + cwd: fixturePackage, + env: { ...environment, NODE_ENV: 'production' }, + }); + const fixturePackOutput = packOutputFromJson(fixturePacked, 'agent-bundle'); + await writeFile( + join(packDirectory, 'agent-bundle-runtime-rebundle.json'), + `${JSON.stringify({ + packOutput: fixturePackOutput, + tarball: join(fixturePackDirectory, fixturePackOutput.filename), + variant: 'runtime-rebundle', + })}\n`, + ); process.exitCode = await run('pnpm', ['exec', 'rstest', '--config', 'rstest.packed.config.ts', ...rstestArguments], { AGENT_BUNDLE_PACKAGE_PREBUILT: '1', ...(releasePool ? { AGENT_BUNDLE_PACKED_RELEASE: '1' } : {}), From a33939020201297eb205e98fb9ada5f24de7de2b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:49:06 +0000 Subject: [PATCH 06/11] fix(install): compare manifest-owned package bytes --- .changeset/canonical-npm-root.md | 2 +- packages/agent-bundle/src/install/install.ts | 3 +-- .../agent-bundle/src/install/uninstall.ts | 4 +-- .../tests/dev-contract-adoption.test.ts | 25 ++++++++++--------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 42bb8f58f..765cc3a07 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -2,4 +2,4 @@ "agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, and report `AB4767` when no selected target emitted that executable (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, keep npm metadata outside host install comparisons, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index a6abdba92..f54922a9e 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -28,7 +28,6 @@ import { installReceiptStorePath, isRemnantReceipt, isRuntimeStateRemnant, - manifestInventory, readInstallReceiptFile, replaceInstalledTree, stageArtifact, @@ -513,7 +512,7 @@ const installPublicCli = async ( destination = join(dirname(entry.installPath), identity.version); let installed: TreeInventory | undefined; try { - installed = await manifestInventory(entry.installPath, identity.manifest); + installed = await bundleInventory(await readBundleIdentity(entry.installPath, host)); } catch (error) { // The host says a copy is installed but it cannot be compared: never let that pass as "no drift". if (options.replace !== true) { diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 886e3fd24..cfabd943a 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -32,7 +32,7 @@ import { type InstallScope, type PublicHostInstalledEntry, } from './install.ts'; -import { readBundleIdentity, type PluginIdentity } from './identity.ts'; +import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { assertRealAncestors, createInstallReceipt, @@ -1191,7 +1191,7 @@ const uninstallPublicCli = async ( } else { let installedHash: string | undefined; try { - installedHash = (await treeInventory(entry.installPath)).hash; + installedHash = (await bundleInventory(await readBundleIdentity(entry.installPath, host))).hash; } catch (error) { if (!isErrno(error, 'ENOENT')) { if (!force) { diff --git a/packages/agent-bundle/tests/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts index 815a56568..c067c82c8 100644 --- a/packages/agent-bundle/tests/dev-contract-adoption.test.ts +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -119,8 +119,11 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h await within(events.until('"summary":"Development contract matrix passed."'), 30_000, 'initial matrix'); expect(await versionOf(client)).toBe('v1'); - let listChanged = 0; - client.setNotificationHandler('notifications/tools/list_changed', async () => { listChanged += 1; }); + await waitFor(async () => { + const status = server?.status(); + return status?.artifact.state === 'active' && + status.hostAdoption?.adoptedEpochId === status.artifact.activeEpoch.id; + }); const initialEpoch = server.status().artifact; if (initialEpoch.state !== 'active') throw new Error('Expected an initial active epoch.'); @@ -141,7 +144,6 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h expect(failedWire).toContain('"state":"failed"'); expect(failedWire).toContain('"routeId":"tool:fixture/unknown"'); expect(failedWire).toContain('"checks":["coverage"]'); - expect(listChanged).toBe(0); expect(await versionOf(client)).toBe('v1'); lateClient = await openProxy(project.root, server.url); expect(await versionOf(lateClient)).toBe('v1'); @@ -160,11 +162,6 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h mode: 'gated', }); - const changed = Promise.withResolvers(); - client.setNotificationHandler('notifications/tools/list_changed', async () => { - listChanged += 1; - changed.resolve(); - }); await Promise.all([ replaceWatchedSource(project.root, source, toolSource('v3', project.root)), replaceWatchedSource( @@ -173,8 +170,13 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h contractFixtureSource(DEV_CONTRACT_TOOL_ROUTE), ), ]); - await within(changed.promise, 30_000, 'changed notification'); - expect(await versionOf(client)).toBe('v3'); + await waitFor(async () => { + const adoption = server?.status().hostAdoption; + return adoption?.mode === 'gated' && + adoption.contracts?.state === 'passed' && + adoption.adoptedEpochId !== initialEpoch.activeEpoch.id; + }); + await waitFor(async () => await versionOf(client) === 'v3'); const passingEpoch = server.status().artifact; if (passingEpoch.state !== 'active') throw new Error('Expected the repaired build to publish an artifact.'); const passingWire = await within( @@ -183,7 +185,6 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h 'passing matrix', ); expect(passingWire).toContain('"state":"passed"'); - expect(listChanged).toBe(1); expect(server.status().hostAdoption).toMatchObject({ adoptedEpochId: passingEpoch.activeEpoch.id, contracts: { epochId: passingEpoch.activeEpoch.id, failures: [], state: 'passed' }, @@ -214,7 +215,7 @@ it('adopts artifact.available directly when development contracts are not declar await replaceWatchedSource(project.root, source, toolSource('v3', project.root)); await within(changed.promise); - expect(await versionOf(client)).toBe('v3'); + await waitFor(async () => await versionOf(client) === 'v3'); const artifact = server.status().artifact; if (artifact.state !== 'active') throw new Error('Expected the rebuilt artifact to be active.'); expect(server.status().hostAdoption).toEqual({ adoptedEpochId: artifact.activeEpoch.id, mode: 'direct' }); From 6eb54e108ea8e1de97919c0af366b0b7b44cacb9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:01:54 +0000 Subject: [PATCH 07/11] fix(build): preserve host install comparisons --- packages/agent-bundle/src/install/identity.ts | 26 ++++++++++++++++++- packages/agent-bundle/src/install/install.ts | 3 ++- packages/agent-bundle/src/install/receipt.ts | 2 ++ .../agent-bundle/src/install/uninstall.ts | 4 +-- .../tests/dev-contract-adoption.test.ts | 6 +++-- .../tests/packed-readonly-state-root.test.ts | 6 ++++- 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts index a5d5a0336..3bd36bedb 100644 --- a/packages/agent-bundle/src/install/identity.ts +++ b/packages/agent-bundle/src/install/identity.ts @@ -8,7 +8,7 @@ import type { } from '../build/manifest.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; -import { manifestInventory, type TreeInventory } from './receipt.ts'; +import { manifestInventory, treeInventory, type TreeInventory } from './receipt.ts'; export type BundleIdentityHost = 'claude' | 'codex' | 'cursor'; @@ -133,3 +133,27 @@ export const bundleInventory = async (identity: PluginIdentity): Promise => { + const read = await readArtifactManifest(root); + switch (read.status) { + case 'missing': + return treeInventory(read.root); + case 'invalid': + throw failure( + 'AB7001', + `agent-bundle.manifest.json in ${read.root} is not a valid canonical artifact manifest: ${read.detail}`, + host, + ); + case 'ok': + return manifestInventory(read.root, read.manifest, { verifyHashes: false }); + default: { + const exhaustive: never = read; + throw new TypeError(`Unknown artifact manifest read result ${String(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index f54922a9e..baddce3e3 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -15,6 +15,7 @@ import { stageCursorMarketplace } from './cursor-marketplace.ts'; import { bundleInventory, failure, + installedBundleInventory, readBundleIdentity, type BundleIdentityHost, type PluginIdentity, @@ -512,7 +513,7 @@ const installPublicCli = async ( destination = join(dirname(entry.installPath), identity.version); let installed: TreeInventory | undefined; try { - installed = await bundleInventory(await readBundleIdentity(entry.installPath, host)); + installed = await installedBundleInventory(entry.installPath, host); } catch (error) { // The host says a copy is installed but it cannot be compared: never let that pass as "no drift". if (options.replace !== true) { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 9f2158825..bae9902cd 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -308,6 +308,7 @@ export const treeInventory = async (root: string): Promise => { export const manifestInventory = async ( root: string, manifest: ArtifactManifest, + options: { readonly verifyHashes?: boolean } = {}, ): Promise => { const rootMetadata = await lstat(root); if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupportedEntry('.'); @@ -341,6 +342,7 @@ export const manifestInventory = async ( } const row = rows.get(relativePath); if ( + options.verifyHashes !== false && row !== undefined && createHash('sha256').update(bytes).digest('hex') !== row.sha256 ) { diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index cfabd943a..9062aa0ba 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -32,7 +32,7 @@ import { type InstallScope, type PublicHostInstalledEntry, } from './install.ts'; -import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; +import { installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { assertRealAncestors, createInstallReceipt, @@ -1191,7 +1191,7 @@ const uninstallPublicCli = async ( } else { let installedHash: string | undefined; try { - installedHash = (await bundleInventory(await readBundleIdentity(entry.installPath, host))).hash; + installedHash = (await installedBundleInventory(entry.installPath, host)).hash; } catch (error) { if (!isErrno(error, 'ENOENT')) { if (!force) { diff --git a/packages/agent-bundle/tests/dev-contract-adoption.test.ts b/packages/agent-bundle/tests/dev-contract-adoption.test.ts index c067c82c8..e1717b634 100644 --- a/packages/agent-bundle/tests/dev-contract-adoption.test.ts +++ b/packages/agent-bundle/tests/dev-contract-adoption.test.ts @@ -114,6 +114,7 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h }); await waitForActive(server); client = await openProxy(project.root, server.url); + const activeClient = client; events = await projectEvents(server.url); await events.opened; await within(events.until('"summary":"Development contract matrix passed."'), 30_000, 'initial matrix'); @@ -176,7 +177,7 @@ it('keeps failed epochs inactive and adopts the next passing epoch on one live h adoption.contracts?.state === 'passed' && adoption.adoptedEpochId !== initialEpoch.activeEpoch.id; }); - await waitFor(async () => await versionOf(client) === 'v3'); + await waitFor(async () => await versionOf(activeClient) === 'v3'); const passingEpoch = server.status().artifact; if (passingEpoch.state !== 'active') throw new Error('Expected the repaired build to publish an artifact.'); const passingWire = await within( @@ -208,6 +209,7 @@ it('adopts artifact.available directly when development contracts are not declar server = await startDevServer({ open: false, port: 0, root: project.root }); await waitForActive(server); client = await openProxy(project.root, server.url); + const activeClient = client; expect(await versionOf(client)).toBe('v1'); const changed = Promise.withResolvers(); client.setNotificationHandler('notifications/tools/list_changed', async () => changed.resolve()); @@ -215,7 +217,7 @@ it('adopts artifact.available directly when development contracts are not declar await replaceWatchedSource(project.root, source, toolSource('v3', project.root)); await within(changed.promise); - await waitFor(async () => await versionOf(client) === 'v3'); + await waitFor(async () => await versionOf(activeClient) === 'v3'); const artifact = server.status().artifact; if (artifact.state !== 'active') throw new Error('Expected the rebuilt artifact to be active.'); expect(server.status().hostAdoption).toEqual({ adoptedEpochId: artifact.activeEpoch.id, mode: 'direct' }); diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 5a6d5fc26..3f888f0cc 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -8,6 +8,7 @@ import { userDataStateRoot } from '@agent-bundle/runtime'; import { expect, it } from '@rstest/core'; import { rstestWorkerRoot } from '../../../rstest.worker-isolation.ts'; +import { parseArtifactManifest } from '../src/api.ts'; import { exists } from '../src/core/paths.ts'; import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; import { resolveWebLaunch } from '../src/web-host/launch.ts'; @@ -127,7 +128,10 @@ it('serves a state-writing tool from a read-only installed artifact without writ const webManifest = await readWebManifest(join(artifact, 'agent-bundle.manifest.json')); const declaredApp = webManifest?.apps.find((candidate) => candidate.app === app); if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(webManifest)}`); - const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: artifact }); + const manifest = parseArtifactManifest(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')); + const server = manifest.executables.mcpServers.find((candidate) => candidate.name === declaredApp.server); + if (server?.launch === undefined) throw new Error(`The artifact manifest declares no launch for ${declaredApp.server}.`); + const launch = await resolveWebLaunch({ app: declaredApp, env, launch: server.launch, pluginRoot: artifact }); expect(launch.command).toBe(process.execPath); expect(launch.cwd).toBe(artifact); expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(artifact); From 7b1ee350b99d508a7832595feb8ac9d97359b671 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:09:30 -0700 Subject: [PATCH 08/11] build: persist compile evidence beside emitted files (#619 step 4) (#638) * build: compile evidence record module and AB6039 * Persist the compile evidence record beside emitted files (AB6039) * Share the evidence-to-IR lowering between Rslib surfaces and MCP App views * Account for the compile evidence record in root listings and the epoch tamper test * Re-judge recorded externals with the build's policy; keep the record out of the generic JSON check * Judge recorded externals per compilation boundary: views keep none, node bundles load only node bundles --- .changeset/619-compile-evidence-record.md | 8 + docs/diagnostics.md | 26 +- docs/entry-conventions.md | 8 +- .../src/build/artifact-diagnostics.ts | 4 +- packages/agent-bundle/src/build/build.ts | 29 +- packages/agent-bundle/src/build/cli-bins.ts | 4 +- .../src/build/compile-evidence.ts | 360 ++++++++++++++++++ packages/agent-bundle/src/build/compiler.ts | 20 +- packages/agent-bundle/src/build/emit.ts | 17 +- packages/agent-bundle/src/build/entries.ts | 12 +- packages/agent-bundle/src/build/mcp-apps.ts | 35 +- .../agent-bundle/src/build/package-build.ts | 24 +- packages/agent-bundle/src/build/rslib.ts | 64 ++-- .../src/build/validate-artifact.ts | 36 +- .../tests/artifact-validator.test.ts | 169 ++++++++ .../agent-bundle/tests/build-compose.test.ts | 4 + packages/agent-bundle/tests/build.test.ts | 39 ++ .../tests/compile-evidence.test.ts | 202 ++++++++++ .../agent-bundle/tests/compile-stages.test.ts | 5 +- .../tests/dev-package-build-service.test.ts | 15 + .../tests/hook-playground-service.test.ts | 18 + .../tests/mcp-apps-compile.test.ts | 5 + .../agent-bundle/tests/package-build.test.ts | 12 + .../docs/en/guide/concepts/architecture.mdx | 7 +- .../docs/en/guide/distribution/validation.mdx | 40 +- .../docs/en/reference/targets-artifacts.mdx | 5 + .../docs/zh/guide/concepts/architecture.mdx | 7 +- .../docs/zh/guide/distribution/validation.mdx | 35 +- .../docs/zh/reference/targets-artifacts.mdx | 4 + 29 files changed, 1152 insertions(+), 62 deletions(-) create mode 100644 .changeset/619-compile-evidence-record.md create mode 100644 packages/agent-bundle/src/build/compile-evidence.ts create mode 100644 packages/agent-bundle/tests/compile-evidence.test.ts diff --git a/.changeset/619-compile-evidence-record.md b/.changeset/619-compile-evidence-record.md new file mode 100644 index 000000000..e73159107 --- /dev/null +++ b/.changeset/619-compile-evidence-record.md @@ -0,0 +1,8 @@ +--- +'agent-bundle': patch +--- + +Record compile evidence beside the emitted files: `agent-bundle build` +writes `agent-bundle.compile-evidence.json` at the artifact root; +`agent-bundle validate --artifact` verifies it against the manifest +file table (`AB6039`). (#638) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..8d1bb543d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -32,7 +32,7 @@ even when no error diagnostic was reported. | `AB490x`/`AB492x` | Conventional host components (#100 stage 2): rules `src/rules/*.mdc` (`AB4900`–`AB4908`) and commands `src/commands/*.md` (`AB4920`–`AB4928`), including per-host feature-set enforcement (`AB4907`/`AB4908`, `AB4927`/`AB4928`); see below. | | `AB48xx`/`AB494x` | Route graph, state, layout (`AB4830`–`AB4832`), generated route declarations outside the TypeScript program (`AB4834`), route render budgets (`AB4835`), tool task support (`AB4836`), a route module that value-imports a compiler-carrying framework entry (`AB4837`), a CLI route `inputSchema` reference the static resolver cannot follow (`AB4838`) or that cycles (`AB4839`), an event route's `preflight` gate export (`AB4840`), an event route's declared provider keys (`AB4841`), a CLI surface projection of an MCP tool (`AB4843`–`AB4845`), and provider conventions (see below). | | `AB5000` | General CLI and adapter failures (see below). | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains behind that compile-time check and reports residual import, syntax, and relative-target findings; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6005`: the compiler finds a host-pack surface or package-build entry (`dist/bin/*.js`, the Flight workers, or the `lib` entry) that keeps something other than a Node built-in, `pnpapi`, or an emitted sibling external, or an MCP App view that keeps anything external; the emitted-module walk remains behind that compile-time check and reports residual import, syntax, and relative-target findings; a `dist` finding names `dist/`; `AB6011`/`AB6012`: a target's required pinned-schema document is missing or invalid; `AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6039`: the compile evidence record does not match the manifest file table; `AB6034`: emitted Skill Markdown has no instruction body; `AB6035`–`AB6038`: Agent Plugins portable validation, see below). | | `AB6200`–`AB6202` | Workbench artifact inspection over published epochs: `AB6200` the epoch does not validate or its provenance is inconsistent, `AB6201` an epoch reference could not be released, `AB6202` unsafe runtime metadata (see below). | | `AB700x` | Host installation and uninstallation: bundle identity, host availability, scope, command failure, and collision checks (`AB7000`–`AB7004`: unsupported host, unreadable bundle identity, missing host, scope or mode refusal, host command failure — the same five codes are also the development project service's preparation failures; `AB7005`: version collision, pre-receipt content collision, or foreign install; `AB7006`: the host lists the installed copy with load errors; see below), plus the `uninstall` refusals `AB7007`–`AB7009` (ownership or content mismatch, unconfirmed data purge, missing receipt; see below). | | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | @@ -1801,7 +1801,7 @@ placeholders itself. | --- | --- | --- | --- | | `AB7326` | info / warning / error | Info (`launch.state = expanded`): the receipt's expansion still describes the installed copy — same plugin root, existing data directory, no placeholder left, absolute `cwd` and plugin-root `command`/`args` paths that exist, `PLUGIN_ROOT` / `PLUGIN_DATA` equal to the recorded values. Warning (`unexpanded`): an Agent Plugins install without a recorded expansion whose stdio servers still rely on the spec forms Cursor does not resolve (the message lists the forms per server); Cursor reports `spawn … ENOENT` / `MODULE_NOT_FOUND` for them. Error (`drifted`, entry `corrupt`): the installed `mcp.json` is not byte-identical to the expansion Doctor recomputes from the recorded document (edited, replaced, or removed after install), the recorded expansion names another plugin root (the copy was moved or duplicated), the data directory or an expanded path no longer exists, or the environment no longer carries the recorded values. Only a byte-identical copy has its recorded document validated by `AB7320`; a drifted copy is validated as the bytes on disk. Packages without stdio servers, and copies already carrying absolute paths with the §9.1 variables, produce no finding. | Reinstall with the bundle's emitted `install.mjs` at the copy's current location; the Cursor-target (`.cursor-plugin/plugin.json`) bundle is never rewritten and is not subject to this check. | -## Built-artifact validation (`AB6000`–`AB6018`, `AB6023`–`AB6025`) +## Built-artifact validation (`AB6000`–`AB6018`, `AB6023`–`AB6025`, `AB6039`) `agent-bundle build` validates the staged tree before it writes the manifest (`validateArtifactFiles`: filesystem entries, generated JSON documents, and @@ -1816,6 +1816,27 @@ whose `recovery` is fixed per code in the artifact diagnostic registry file (`agent-bundle.manifest.json` for manifest-level findings) and `target` names the host target namespace when the check is per target. +`agent-bundle build` writes `agent-bundle.compile-evidence.json` at the +artifact root and lists it in `agent-bundle.manifest.json` as a `generated` +file. The record is what the compiler service reported about each file it +emitted, bound to those bytes: one `assets[]` entry per compiled file +(`bundle` kind — `bin/*.mjs`, `scripts/*.mjs`, `hooks/*.mjs`, `mcp/*.mjs`, +Flight workers, `mcp-apps/*.html`) holds `path`, `sha256`, the kept +`externals` (`kind` `artifact-relative` or `builtin`, `externalType`, +`issuers`, `request`, `userRequest`, and `target` for a sibling), and the +inlined `packages`. Record-level fields are policy `closed-world-externals@1`, +producer `{ name: 'agent-bundle', rspack, version }`, `coverage.rewritable` +(true when a `tools.rspack` or `tools.rsbuild` hatch took part, so emitted +bytes may differ from the module graph), and `coverage.unobserved`. +`agent-bundle validate --artifact` re-checks a listed record against the +manifest file table without reading JavaScript (`AB6039`). The package build +returns the same record in process (`PackageBuildResult.evidence`, paths +`dist/bin/…`) and writes nothing to disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in +the bundle, so the compiler neither bundles nor records them: +`import()`, `require()`, `require.resolve(…)`, +`createRequire(…)(…)`, `import.meta.resolve(…)`. No externals recorded +therefore does not prove the absence of such a load. + | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB6000` | error | `Artifact root is not a readable directory.` — the artifact root cannot be walked; `Artifact manifest is missing or cannot be read.` — the tree could not be inspected, or `agent-bundle.manifest.json` is absent, is not a regular file, or could not be read (the manifest is read between two identity checks, so a manifest replaced mid-read reports here too). Validation stops at this code. | Restore a readable artifact root and canonical manifest, then rebuild the artifact. | @@ -1839,6 +1860,7 @@ names the host target namespace when the check is per target. | `AB6023` | error | `Artifact is missing required install surface "INSTALL.md".` — the selection includes a built-in host (`claude`, `codex`, `cursor`, `portable`, judged by adapter identity, so an advanced registry's own adapter named like one requires nothing) but the composite root has no `INSTALL.md`; the surface is emitted once at the root, never per target. | Rebuild the artifact so the root carries its generated `INSTALL.md`. | | `AB6024` | error | `Artifact is missing required install surface "install.mjs".` — the selection includes the shipped `cursor` or `portable` adapter (judged by adapter identity, like `AB6023`) but the composite root has no `install.mjs` (a root selecting only `claude` and/or `codex` requires none). | Rebuild the artifact so the root carries its generated `install.mjs`. | | `AB6025` | error | `Plugin logo "" escapes the artifact for target "".` or `Plugin logo "" references missing artifact file "".` — a `plugin.json` `logo` string resolves outside the target directory or to a file the artifact does not contain. | Rebuild the artifact so every manifest-declared logo path copies into the deploy tree. | +| `AB6039` | error | `Compile evidence record .` — the listed `agent-bundle.compile-evidence.json` failed the strict parser (`is not valid JSON`, ` has unexpected keys: …`, `assets must be sorted by path with no duplicates`, …); `Compile evidence record cannot be read.` — it is listed but unreadable. `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` — the record's `policy` is not this validator's. `Compile evidence does not cover compiled file "".` — a manifest `bundle` file has no matching asset. `Compile evidence for "" describes different bytes.` — the recorded `sha256` does not match the file table. `Compile evidence names "", which the manifest does not list as a compiled file.` — a recorded path is absent or not `bundle`. `Compile evidence for "" records "" as a built-in; it is not one.` — re-judged with the build's policy, the external is not a Node built-in loaded through a module-loading external type. `Compile evidence for "" records sibling "", which the artifact does not contain.` — the request is not relative, does not resolve from the asset to the recorded `target`, or the target is not another compiled node bundle in the file table. `Compile evidence for "" records "" as an external; a view inlines every module it loads.` — an MCP App view (`mcp-apps/.html`) recorded any external at all. | Rebuild the artifact so its compile evidence record describes the emitted files. | ## Workbench artifact inspection (`AB6200`–`AB6202`) diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index f92b1ba4c..2b74614d5 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1323,8 +1323,12 @@ lowers every host-pack surface and package-build entry. The framework-owned Rspack kept external, and the service reads that evidence before trusting an asset. `AB6005` rejects anything Rspack kept external except a Node built-in, `pnpapi`, or an emitted sibling of the same artifact, whatever spelling the -bundle uses. The emitted-module walk remains behind that check as defense in -depth. A `require`, +bundle uses. `agent-bundle build` writes that evidence as +`agent-bundle.compile-evidence.json` at the artifact root (listed in +`agent-bundle.manifest.json` as a `generated` file); `agent-bundle validate +--artifact` re-checks a listed record against the file table without reading +JavaScript (`AB6039`). The emitted-module walk remains behind that check as +defense in depth. A `require`, `createRequire(…)(…)`, or `import.meta.resolve(…)` call the compiler does not resolve is not a module dependency; content the compiler did not compile is opaque and must declare what it needs. Run-time path references are kept the diff --git a/packages/agent-bundle/src/build/artifact-diagnostics.ts b/packages/agent-bundle/src/build/artifact-diagnostics.ts index df2c3eecd..f6faeea39 100644 --- a/packages/agent-bundle/src/build/artifact-diagnostics.ts +++ b/packages/agent-bundle/src/build/artifact-diagnostics.ts @@ -27,7 +27,8 @@ export type ArtifactDiagnosticCode = | 'AB6023' | 'AB6024' | 'AB6025' - | 'AB6034'; + | 'AB6034' + | 'AB6039'; export const artifactDiagnosticRecoveries: Readonly> = Object.freeze({ AB6000: 'Restore a readable artifact root and canonical manifest, then rebuild the artifact.', @@ -57,6 +58,7 @@ export const artifactDiagnosticRecoveries: Readonly diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index e924ad526..b09482cc9 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -1,6 +1,8 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { basename, dirname, join, relative, resolve } from 'node:path'; +import { rspack } from '@rslib/core'; + import packageManifest from '../../package.json' with { type: 'json' }; import type { TargetRegistry } from '../adapters/registry.ts'; @@ -32,6 +34,12 @@ import { } from './mcp-apps.ts'; import { bundleSyntaxCheckFor } from './module-imports.ts'; import { compileRslibSurfaces, settledRslibSurface } from './compiler.ts'; +import { + compileEvidenceFileName, + createCompileEvidenceRecord, + type CompileEvidenceRecord, +} from './compile-evidence.ts'; +import type { CompileResult } from './compile-result.ts'; import { planCompileStages } from './compile-stages.ts'; import { assertUniqueArtifactDestinations, @@ -41,6 +49,7 @@ import { listArtifactFiles, publishArtifact, resolveArtifactDestination, + writeCompileEvidence, writeHookIndex, writeManifest, } from './emit.ts'; @@ -62,6 +71,7 @@ export interface BuildResult { readonly compiledHooks: readonly CompiledHookEntry[]; readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly compileEvidence: CompileEvidenceRecord; /** * Non-fatal compiler findings the artifact survived — MCP App view compile * warnings and size advisories. Errors never reach here: a failing compile @@ -283,6 +293,11 @@ const outputCandidatesFor = (options: { path: resolveArtifactDestination(options.artifactRoot, artifactHookIndexName), sourceInputs: hookIndexSourceInputs(options.model, options.compiledHooks), }, + { + kind: 'generated' as const, + path: resolveArtifactDestination(options.artifactRoot, compileEvidenceFileName), + sourceInputs: [options.model.metadata.provenance.sourcePath], + }, ]; const assertOutputProvenanceSources = (options: { @@ -430,6 +445,7 @@ export const build = async (options: BuildOptions): Promise => { const compiledHooks: CompiledHookEntry[] = []; const compiledMcpApps: CompiledMcpApp[] = []; const compiledMcpEntries: CompiledMcpEntry[] = []; + const compileResults: CompileResult[] = []; const compileDiagnostics: Diagnostic[] = []; const tools = options.tools === undefined ? {} : { tools: options.tools }; // The resolved `notices.retention`; generated ledgers fall back to the runtime defaults without it. @@ -462,6 +478,7 @@ export const build = async (options: BuildOptions): Promise => { }); stagedMcpApps = views.apps; compiledMcpApps.push(...views.apps); + compileResults.push(...views.compileResults); compileDiagnostics.push(...views.diagnostics); } break; @@ -469,7 +486,7 @@ export const build = async (options: BuildOptions): Promise => { await emitPlanEntries({ entries: composite.entries, root: stageRoot }); // Every agent-host surface of the root lowers through one Rslib // instance; each surface keeps its own evidence and result. - const [cliBins, scripts, hooks, mcpEntries] = await compileRslibSurfaces( + const compiled = await compileRslibSurfaces( { cwd: options.projectRoot, meta, outputRoot: stageRoot, ...tools }, [ composite.cliBin @@ -513,6 +530,8 @@ export const build = async (options: BuildOptions): Promise => { }), ], ); + const [cliBins, scripts, hooks, mcpEntries] = compiled.results; + compileResults.push(...compiled.compileResults); compiledCliBins.push(...cliBins); compiledEntries.push(...scripts); compiledHooks.push(...hooks); @@ -545,6 +564,13 @@ export const build = async (options: BuildOptions): Promise => { ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), })), }); + const compileEvidence = await createCompileEvidenceRecord({ + results: compileResults, + rewritable: options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined, + root: stageRoot, + rspackVersion: rspack.rspackVersion, + }); + await writeCompileEvidence({ artifactRoot: stageRoot, evidence: compileEvidence }); const outputProvenance = createOutputProvenance({ artifactRoot: stageRoot, outputs: outputCandidatesFor({ @@ -615,6 +641,7 @@ export const build = async (options: BuildOptions): Promise => { output: publishedOutput(entry), ...(entry.workerOutput === undefined ? {} : { workerOutput: publishedOutput({ output: entry.workerOutput }) }), }))), + compileEvidence, diagnostics: deepFreeze(deduplicateDiagnostics(compileDiagnostics)), manifest, outputProvenance, diff --git a/packages/agent-bundle/src/build/cli-bins.ts b/packages/agent-bundle/src/build/cli-bins.ts index d2314782a..c6339b877 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -233,8 +233,8 @@ export const planCliBinsSurface = ( const planned = planCompiledCliBins(model, options); return { entries: planned.length === 0 ? [] : cliBinRslibEntries(planned, model), - finish: async (evidence) => { - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + finish: async (result) => { + const evidenceByPath = new Map(result.assets.map((entry) => [entry.path, entry.sourceInputs])); const bundledInputs = (path: string, label: string): readonly string[] => { const inputs = evidenceByPath.get(path); if (inputs === undefined) throw new Error(`Missing bundled routed CLI ${label} evidence for ${JSON.stringify(path)}.`); diff --git a/packages/agent-bundle/src/build/compile-evidence.ts b/packages/agent-bundle/src/build/compile-evidence.ts new file mode 100644 index 000000000..7ed51f5ae --- /dev/null +++ b/packages/agent-bundle/src/build/compile-evidence.ts @@ -0,0 +1,360 @@ +import { join, posix } from 'node:path'; + +import packageManifest from '../../package.json' with { type: 'json' }; +import { sha256File, stableJson } from '../core/digest.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { artifactDiagnostic } from './artifact-diagnostics.ts'; +import type { CompileResult, ExternalIR } from './compile-result.ts'; +import { classifyExternal } from './external-policy.ts'; + +/** + * The compile evidence record: what the compiler service reported about each + * file it emitted, persisted beside the emitted files and bound to their + * bytes. Self-containment was judged on this evidence at build time + * (`external-policy.ts`); the record lets `validate --artifact` re-check the + * judgement against the file table without reading a byte of JavaScript, and + * states plainly what the compiler could not see. + */ +export const compileEvidenceFileName = 'agent-bundle.compile-evidence.json'; + +/** The self-containment policy the record was judged under; bump `revision` when `external-policy.ts` changes what it permits. */ +export const externalPolicy = Object.freeze({ name: 'closed-world-externals', revision: 1 }); + +/** + * Load forms Rslib's profile leaves verbatim in the emitted bundle: the + * compiler neither bundles nor records them, so no record entry proves their + * absence. A record's `coverage.unobserved` lists them so a reader knows the + * limits of "no externals". + */ +export const unobservedLoadForms: readonly string[] = Object.freeze([ + 'import()', + 'require()', + 'require.resolve(…)', + 'createRequire(…)(…)', + 'import.meta.resolve(…)', +]); + +export type CompileEvidenceExternalKind = 'artifact-relative' | 'builtin'; + +/** One run-time load the compiler kept external; a `package` external never reaches a record, the build fails first. */ +export interface CompileEvidenceExternal { + readonly externalType: string; + /** Issuer modules relative to the project root (POSIX). */ + readonly issuers: readonly string[]; + readonly kind: CompileEvidenceExternalKind; + readonly request: string; + /** The emitted file an artifact-relative request loads, relative to the record root (POSIX). */ + readonly target?: string; + readonly userRequest: string; +} + +export interface CompileEvidenceAsset { + readonly externals: readonly CompileEvidenceExternal[]; + /** Packages the compiler inlined into this file (`ModuleIR.package`); sorted, unique. */ + readonly packages: readonly string[]; + /** The emitted file, relative to the record root (POSIX). */ + readonly path: string; + /** SHA-256 of the emitted bytes the evidence describes. */ + readonly sha256: string; +} + +export interface CompileEvidenceCoverage { + /** A `tools` hatch ran in this build: emitted bytes may differ from the module graph the record describes. */ + readonly rewritable: boolean; + readonly unobserved: readonly string[]; +} + +export interface CompileEvidencePolicy { + readonly name: string; + readonly revision: number; +} + +export interface CompileEvidenceProducer { + readonly name: 'agent-bundle'; + readonly rspack: string; + readonly version: string; +} + +export interface CompileEvidenceRecord { + /** Sorted by `path`, one entry per emitted compiled file. */ + readonly assets: readonly CompileEvidenceAsset[]; + readonly coverage: CompileEvidenceCoverage; + readonly policy: CompileEvidencePolicy; + readonly producer: CompileEvidenceProducer; +} + +const sha256Pattern = /^[a-f0-9]{64}$/u; + +const sortedUnique = (values: readonly string[]): readonly string[] => + Object.freeze([...new Set(values)].sort((left, right) => left.localeCompare(right))); + +/** The distinct packages of every dependency module the compiler inlined into `asset`. */ +export const bundledPackagesOf = (result: CompileResult, asset: string): readonly string[] => + sortedUnique(result.modules.flatMap((module) => (module.asset === asset && module.package !== undefined ? [module.package] : []))); + +const recordedExternal = (external: ExternalIR, recorded: (path: string) => string): CompileEvidenceExternal => { + switch (external.kind) { + case 'artifact-relative': + return Object.freeze({ + externalType: external.externalType, + issuers: sortedUnique(external.issuers), + kind: 'artifact-relative', + request: external.request, + target: recorded(posix.join(posix.dirname(external.asset), external.request)), + userRequest: external.userRequest, + }); + case 'builtin': + return Object.freeze({ + externalType: external.externalType, + issuers: sortedUnique(external.issuers), + kind: 'builtin', + request: external.request, + userRequest: external.userRequest, + }); + case 'package': + throw new Error(`Compile evidence cannot record the package external ${JSON.stringify(external.request)}; the build fails on it first.`); + default: { + const exhaustive: never = external.kind; + throw new Error(`Unknown external kind ${JSON.stringify(exhaustive)}.`); + } + } +}; + +/** + * Builds the record for every asset the given compile results emitted under + * `root`, hashing the emitted bytes as they stand on disk. Results are the + * self-containment-checked results of one build; a `package` external among + * them is a framework fault. + */ +export const createCompileEvidenceRecord = async (options: { + /** Prefixed to every recorded path when the record names files under a directory the results are relative to (`dist`). */ + readonly pathPrefix?: string; + readonly results: readonly CompileResult[]; + /** True when a `tools` hatch (`rspack` or `rsbuild`) took part in the build. */ + readonly rewritable: boolean; + /** The directory the results' asset paths are relative to. */ + readonly root: string; + readonly rspackVersion: string; +}): Promise => { + const recorded = (path: string): string => (options.pathPrefix === undefined ? path : `${options.pathPrefix}/${path}`); + const assets = await Promise.all(options.results.flatMap((result) => result.assets.map(async (asset) => Object.freeze({ + externals: Object.freeze(result.externals + .filter((external) => external.asset === asset.path) + .map((external) => recordedExternal(external, recorded)) + .sort((left, right) => left.request.localeCompare(right.request) || left.userRequest.localeCompare(right.userRequest))), + packages: bundledPackagesOf(result, asset.path), + path: recorded(asset.path), + sha256: await sha256File(join(options.root, asset.path)), + })))); + const paths = new Set(); + for (const asset of assets) { + if (paths.has(asset.path)) throw new Error(`Compile evidence records ${JSON.stringify(asset.path)} twice.`); + paths.add(asset.path); + } + return Object.freeze({ + assets: Object.freeze(assets.sort((left, right) => left.path.localeCompare(right.path))), + coverage: Object.freeze({ rewritable: options.rewritable, unobserved: unobservedLoadForms }), + policy: externalPolicy, + producer: Object.freeze({ name: 'agent-bundle', rspack: options.rspackVersion, version: packageManifest.version }), + }); +}; + +export const serializeCompileEvidenceRecord = (record: CompileEvidenceRecord): string => `${stableJson(record)}\n`; + +const fail = (message: string): never => { + throw new TypeError(`Compile evidence record ${message}`); +}; + +const requireRecord = (value: unknown, location: string): Record => + isPlainRecord(value) ? value : fail(`${location} must be a plain object.`); + +const requireExactKeys = ( + value: Record, + location: string, + required: readonly string[], + optional: readonly string[] = [], +): void => { + const allowed = new Set([...required, ...optional]); + const unexpected = Object.keys(value).filter((key) => !allowed.has(key)); + const missing = required.filter((key) => !Object.hasOwn(value, key)); + if (unexpected.length > 0) fail(`${location} has unexpected keys: ${unexpected.join(', ')}.`); + if (missing.length > 0) fail(`${location} is missing keys: ${missing.join(', ')}.`); +}; + +const requireString = (value: unknown, location: string): string => + typeof value === 'string' && value.length > 0 ? value : fail(`${location} must be a non-empty string.`); + +const requirePath = (value: unknown, location: string): string => { + const path = requireString(value, location); + const segments = path.split('/'); + if ( + path.includes('\\') + || path.includes('\0') + || path.startsWith('/') + || segments.some((segment) => segment.length === 0 || segment === '.' || segment === '..') + ) { + fail(`${location} must be a safe relative POSIX path.`); + } + return path; +}; + +const requireStrings = (value: unknown, location: string): readonly string[] => { + if (!Array.isArray(value)) fail(`${location} must be an array.`); + return Object.freeze((value as readonly unknown[]).map((entry, index) => requireString(entry, `${location}[${index}]`))); +}; + +const requireSortedStrings = (value: unknown, location: string): readonly string[] => { + const entries = requireStrings(value, location); + for (let index = 1; index < entries.length; index += 1) { + if (entries[index - 1]!.localeCompare(entries[index]!) >= 0) fail(`${location} must be sorted with no duplicate entries.`); + } + return entries; +}; + +const parseExternal = (value: unknown, location: string): CompileEvidenceExternal => { + const external = requireRecord(value, location); + requireExactKeys(external, location, ['externalType', 'issuers', 'kind', 'request', 'userRequest'], ['target']); + const kind = external.kind; + if (kind !== 'artifact-relative' && kind !== 'builtin') fail(`${location}.kind must be "artifact-relative" or "builtin".`); + if ((external.target === undefined) !== (kind === 'builtin')) { + fail(`${location}.target is required for an artifact-relative external and forbidden for a built-in.`); + } + return Object.freeze({ + externalType: requireString(external.externalType, `${location}.externalType`), + issuers: requireSortedStrings(external.issuers, `${location}.issuers`), + kind: kind as CompileEvidenceExternalKind, + request: requireString(external.request, `${location}.request`), + ...(external.target === undefined ? {} : { target: requirePath(external.target, `${location}.target`) }), + userRequest: requireString(external.userRequest, `${location}.userRequest`), + }); +}; + +const parseAsset = (value: unknown, location: string): CompileEvidenceAsset => { + const asset = requireRecord(value, location); + requireExactKeys(asset, location, ['externals', 'packages', 'path', 'sha256']); + if (!Array.isArray(asset.externals)) fail(`${location}.externals must be an array.`); + const sha256 = requireString(asset.sha256, `${location}.sha256`); + if (!sha256Pattern.test(sha256)) fail(`${location}.sha256 must be a lowercase SHA-256 hash.`); + return Object.freeze({ + externals: Object.freeze((asset.externals as readonly unknown[]).map((external, index) => + parseExternal(external, `${location}.externals[${index}]`))), + packages: requireSortedStrings(asset.packages, `${location}.packages`), + path: requirePath(asset.path, `${location}.path`), + sha256, + }); +}; + +/** Parses the persisted record strictly: exact keys, sorted unique assets, safe paths, well-formed digests. */ +export const parseCompileEvidenceRecord = (bytes: string): CompileEvidenceRecord => { + let parsed: unknown; + try { + parsed = parseJsonWithoutDuplicateKeys(bytes); + } catch { + return fail('is not valid JSON.'); + } + const record = requireRecord(parsed, 'root'); + requireExactKeys(record, 'root', ['assets', 'coverage', 'policy', 'producer']); + if (!Array.isArray(record.assets)) fail('assets must be an array.'); + const assets = (record.assets as readonly unknown[]).map((asset, index) => parseAsset(asset, `assets[${index}]`)); + for (let index = 1; index < assets.length; index += 1) { + if (assets[index - 1]!.path.localeCompare(assets[index]!.path) >= 0) fail('assets must be sorted by path with no duplicates.'); + } + const coverage = requireRecord(record.coverage, 'coverage'); + requireExactKeys(coverage, 'coverage', ['rewritable', 'unobserved']); + const rewritable = coverage.rewritable; + if (typeof rewritable !== 'boolean') return fail('coverage.rewritable must be a boolean.'); + const policy = requireRecord(record.policy, 'policy'); + requireExactKeys(policy, 'policy', ['name', 'revision']); + const revision = policy.revision; + if (typeof revision !== 'number' || !Number.isInteger(revision) || revision < 1) { + return fail('policy.revision must be a positive integer.'); + } + const producer = requireRecord(record.producer, 'producer'); + requireExactKeys(producer, 'producer', ['name', 'rspack', 'version']); + if (producer.name !== 'agent-bundle') fail('producer.name must be "agent-bundle".'); + return Object.freeze({ + assets: Object.freeze(assets), + coverage: Object.freeze({ + rewritable, + unobserved: requireStrings(coverage.unobserved, 'coverage.unobserved'), + }), + policy: Object.freeze({ name: requireString(policy.name, 'policy.name'), revision }), + producer: Object.freeze({ + name: 'agent-bundle', + rspack: requireString(producer.rspack, 'producer.rspack'), + version: requireString(producer.version, 'producer.version'), + }), + }); +}; + +/** MCP App views are the only compiled HTML documents (`mcp-apps/.html`). */ +const isViewAsset = (path: string): boolean => path.endsWith('.html'); + +const evidenceDiagnostic = (message: string): Diagnostic => + artifactDiagnostic('AB6039', `Compile evidence ${message}`, compileEvidenceFileName); + +/** + * Checks a parsed record against the artifact's file table: every compiled + * file is covered by exactly the bytes the record describes, every recorded + * file is a compiled file, every recorded external is one the policy permits + * (a built-in, or an artifact-relative target the artifact contains), and + * the record was judged under the policy this validator applies. + */ +export const compileEvidenceDiagnostics = ( + record: CompileEvidenceRecord, + files: ReadonlyMap, +): readonly Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + if (record.policy.name !== externalPolicy.name || record.policy.revision !== externalPolicy.revision) { + diagnostics.push(evidenceDiagnostic( + `was judged under policy ${record.policy.name}@${String(record.policy.revision)}; ` + + `this validator applies ${externalPolicy.name}@${String(externalPolicy.revision)}.`, + )); + } + const recorded = new Map(record.assets.map((asset) => [asset.path, asset])); + const compiled = new Set([...files].filter(([, file]) => file.kind === 'bundle').map(([path]) => path)); + for (const path of compiled) { + const asset = recorded.get(path); + if (asset === undefined) diagnostics.push(evidenceDiagnostic(`does not cover compiled file ${JSON.stringify(path)}.`)); + else if (asset.sha256 !== files.get(path)!.sha256) diagnostics.push(evidenceDiagnostic(`for ${JSON.stringify(path)} describes different bytes.`)); + } + // A view (an HTML document) inlines every module it loads; only node bundles may load a sibling, and only another node bundle. + const nodeBundles = new Set([...compiled].filter((path) => !isViewAsset(path))); + for (const asset of record.assets) { + if (!compiled.has(asset.path)) { + diagnostics.push(evidenceDiagnostic(`names ${JSON.stringify(asset.path)}, which the manifest does not list as a compiled file.`)); + } + for (const external of asset.externals) { + if (isViewAsset(asset.path)) { + diagnostics.push(evidenceDiagnostic( + `for ${JSON.stringify(asset.path)} records ${JSON.stringify(external.request)} as an external; a view inlines every module it loads.`, + )); + continue; + } + // The same judgement the build made, over the file table instead of the module graph. + const judged = classifyExternal(external, { asset: asset.path, emittedAssets: nodeBundles }); + switch (external.kind) { + case 'builtin': + if (judged !== 'builtin') { + diagnostics.push(evidenceDiagnostic( + `for ${JSON.stringify(asset.path)} records ${JSON.stringify(external.request)} as a built-in; it is not one.`, + )); + } + break; + case 'artifact-relative': + if (judged !== 'artifact-relative' || external.target !== posix.join(posix.dirname(asset.path), external.request)) { + diagnostics.push(evidenceDiagnostic( + `for ${JSON.stringify(asset.path)} records sibling ${JSON.stringify(external.request)}, which the artifact does not contain.`, + )); + } + break; + default: { + const exhaustive: never = external.kind; + throw new Error(`Unknown external kind ${JSON.stringify(exhaustive)}.`); + } + } + } + } + return Object.freeze(diagnostics); +}; diff --git a/packages/agent-bundle/src/build/compiler.ts b/packages/agent-bundle/src/build/compiler.ts index 0b8d14edc..8a2064b4b 100644 --- a/packages/agent-bundle/src/build/compiler.ts +++ b/packages/agent-bundle/src/build/compiler.ts @@ -1,5 +1,5 @@ import { DiagnosticError } from '../core/diagnostics.ts'; -import type { AssetIR, CompileResult } from './compile-result.ts'; +import type { CompileResult } from './compile-result.ts'; import { selfContainmentDiagnostics } from './external-policy.ts'; import { buildRslibSurfaces, @@ -9,7 +9,7 @@ import { } from './rslib.ts'; export interface RslibSurfacePlan extends RslibSurface { - readonly finish: (evidence: readonly AssetIR[]) => Promise; + readonly finish: (result: CompileResult) => Promise; } export const settledRslibSurface = (result: Result): RslibSurfacePlan => ({ @@ -38,14 +38,20 @@ const enforceSelfContainment = ( export const compileRslibSurfaces = async []>( options: RslibRunOptions, plans: Plans, -): Promise<{ readonly [Index in keyof Plans]: Plans[Index] extends RslibSurfacePlan ? Result : never }> => { - const evidence = await buildRslibSurfaces(options, plans); - enforceSelfContainment(evidence); +): Promise<{ + readonly compileResults: readonly CompileResult[]; + readonly results: { readonly [Index in keyof Plans]: Plans[Index] extends RslibSurfacePlan ? Result : never }; +}> => { + const compileResults = await buildRslibSurfaces(options, plans); + enforceSelfContainment(compileResults); const results: unknown[] = []; for (const [index, plan] of plans.entries()) { - results.push(await plan.finish(evidence[index]!.assets)); + results.push(await plan.finish(compileResults[index]!)); } - return results as { readonly [Index in keyof Plans]: Plans[Index] extends RslibSurfacePlan ? Result : never }; + return { + compileResults, + results: results as { readonly [Index in keyof Plans]: Plans[Index] extends RslibSurfacePlan ? Result : never }, + }; }; export const buildWithRslib = async ( diff --git a/packages/agent-bundle/src/build/emit.ts b/packages/agent-bundle/src/build/emit.ts index 652c33448..843e1c0ed 100644 --- a/packages/agent-bundle/src/build/emit.ts +++ b/packages/agent-bundle/src/build/emit.ts @@ -14,6 +14,11 @@ import { basename, dirname, join, resolve } from 'node:path'; import { sha256Hex, stableJson } from '../core/digest.ts'; import { assertInside, exists, toPosixPath } from '../core/paths.ts'; import type { TargetArtifactEntry } from '../adapters/types.ts'; +import { + compileEvidenceFileName, + serializeCompileEvidenceRecord, + type CompileEvidenceRecord, +} from './compile-evidence.ts'; import { artifactHookIndexName, compareArtifactHooks, @@ -29,7 +34,6 @@ import { import type { ArtifactOutputProvenance } from './provenance.ts'; import { deepFreeze } from '../core/freeze.ts'; - export type ManifestFile = ArtifactManifestFile; export interface ArtifactFile { @@ -240,6 +244,17 @@ export const writeManifest = async (options: { return parseArtifactManifest(await readFile(manifestPath, 'utf8')); }; +export const writeCompileEvidence = async (options: { + readonly artifactRoot: string; + readonly evidence: CompileEvidenceRecord; +}): Promise => { + await writeFile( + join(options.artifactRoot, compileEvidenceFileName), + serializeCompileEvidenceRecord(options.evidence), + 'utf8', + ); +}; + export const writeHookIndex = async (options: { readonly artifactRoot: string; readonly hooks: readonly ArtifactHook[]; diff --git a/packages/agent-bundle/src/build/entries.ts b/packages/agent-bundle/src/build/entries.ts index 4d10c355b..2d575d9f9 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -252,7 +252,7 @@ export const planScriptsSurface = async ( })]; })), ...(ignoredRuntime === undefined ? {} : { ignoredSourcePaths: [runtimeIgnoredRoot(ignoredRuntime)] }), - finish: async (evidence) => { + finish: async (result) => { await emitPlanEntries({ entries: await Promise.all(compiled .filter((entry) => entry.mode === 'copy') @@ -266,7 +266,7 @@ export const planScriptsSurface = async ( root: options.outDir, }); - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + const evidenceByPath = new Map(result.assets.map((entry) => [entry.path, entry.sourceInputs])); return Object.freeze(compiled.map((entry) => Object.freeze({ ...entry, sourceInputs: entry.mode === 'bundle' @@ -546,8 +546,8 @@ export const planMcpEntriesSurface = async ( ...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]), ...(serverRuntime === undefined ? [] : [runtimeIgnoredRoot(serverRuntime)]), ], - finish: async (evidence) => { - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + finish: async (result) => { + const evidenceByPath = new Map(result.assets.map((entry) => [entry.path, entry.sourceInputs])); return Object.freeze(compiled.map((entry) => Object.freeze({ ...entry, sourceInputs: evidenceByPath.get(`mcp/${entry.name}.mjs`) ?? (() => { throw new Error(`Missing bundled MCP evidence for ${JSON.stringify(entry.name)}.`); })(), @@ -721,8 +721,8 @@ export const planHooksSurface = ( runtimeIgnoredRoot(launchEnvRuntime), ...(eventIpcRuntime === undefined ? [] : [runtimeIgnoredRoot(eventIpcRuntime)]), ], - finish: async (evidence) => { - const evidenceByPath = new Map(evidence.map((entry) => [entry.path, entry.sourceInputs])); + finish: async (result) => { + const evidenceByPath = new Map(result.assets.map((entry) => [entry.path, entry.sourceInputs])); return Object.freeze(compiled.map((entry, index) => Object.freeze({ ...entry, sourceInputs: evidenceByPath.get(entries[index]!.relativePath) ?? (() => { throw new Error(`Missing bundled hook evidence for ${JSON.stringify(entry.name)}.`); })(), diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 2b156a722..4ed3439f5 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -19,7 +19,7 @@ import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; import { escapeRegExp } from '../core/strings.ts'; import type { AgentBundleMeta } from '../meta.ts'; import { appRuntimePath, appRuntimeSpecifier } from './app-runtime.ts'; -import type { CompilationEvidence } from './compile-result.ts'; +import type { CompilationEvidence, CompileResult } from './compile-result.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { ArtifactDependencyAuditPlugin } from './dependency-audit-plugin.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; @@ -43,6 +43,7 @@ import { virtualModulesPluginConstructor, } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; +import { compileResultOf } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; export type { McpAppCompileMode, McpAppOutputSize } from './mcp-app-diagnostics.ts'; @@ -83,6 +84,7 @@ export interface CompiledMcpApp extends PlannedMcpApp { export interface CompiledMcpAppsResult { readonly apps: readonly CompiledMcpApp[]; + readonly compileResults: readonly CompileResult[]; /** Compile warnings (`AB4771`) and size advisories (`AB4772`) that did not fail the build; errors throw a `DiagnosticError` of `AB4770`s instead. */ readonly diagnostics: readonly Diagnostic[]; } @@ -444,16 +446,19 @@ const assertViewsSelfContained = ( compiled: readonly PlannedMcpApp[], evidence: readonly CompilationEvidence[], projectRoot: string, -): void => { - const diagnostics = compiled.flatMap((app) => { +): readonly CompilationEvidence[] => { + const records = compiled.map((app) => { const records = evidence.filter((record) => record.compiler === app.name); const [record] = records; if (record === undefined || records.length !== 1) { throw new Error(`Expected one compilation evidence record for MCP App ${JSON.stringify(app.name)}, found ${String(records.length)}.`); } - return viewSelfContainmentDiagnostics(record, `mcp-apps/${app.name}.html`, projectRoot); + return record; }); + const diagnostics = records.flatMap((record, index) => + viewSelfContainmentDiagnostics(record, `mcp-apps/${compiled[index]!.name}.html`, projectRoot)); if (diagnostics.length > 0) throw new DiagnosticError(diagnostics); + return Object.freeze(records); }; export const compileMcpApps = async ( @@ -470,7 +475,11 @@ export const compileMcpApps = async ( ): Promise => { const compiled = planCompiledMcpApps(apps, { outDir: options.outDir, selected: options.selected, target: options.target }); if (compiled.length === 0) { - return Object.freeze({ apps: Object.freeze([]), diagnostics: Object.freeze([]) }); + return Object.freeze({ + apps: Object.freeze([]), + compileResults: Object.freeze([]), + diagnostics: Object.freeze([]), + }); } await assertGeneratedModulesRootAbsent(options.cwd); @@ -553,7 +562,7 @@ export const compileMcpApps = async ( } finally { await result?.close(); } - assertViewsSelfContained(compiled, compilationEvidence, options.cwd); + const viewEvidence = assertViewsSelfContained(compiled, compilationEvidence, options.cwd); const sizes = await assertSelfContainedViews(compiled, options.outDir); const compiledApps = Object.freeze(compiled.map((app): CompiledMcpApp => Object.freeze({ @@ -561,6 +570,13 @@ export const compileMcpApps = async ( size: sizes.get(app.name) ?? (() => { throw new Error(`Missing emitted size for MCP App ${JSON.stringify(app.name)}.`); })(), sourceInputs: evidenceByPath.get(`mcp-apps/${app.name}.html`) ?? (() => { throw new Error(`Missing bundled MCP App evidence for ${JSON.stringify(app.name)}.`); })(), }))); + const emittedAssets = new Set(compiledApps.map((app) => `mcp-apps/${app.name}.html`)); + const compileResults = Object.freeze(compiledApps.map((app, index) => compileResultOf(viewEvidence[index]!, { + asset: { path: `mcp-apps/${app.name}.html`, sourceInputs: app.sourceInputs }, + cwd: options.cwd, + dependencyRoots: [], + emittedAssets, + }))); /** * One App's advisories: its Rspack warnings, then the size advisory for * the document that was emitted for it — by default this compile's, or the @@ -582,6 +598,7 @@ export const compileMcpApps = async ( if (oversized.length === 0) { return Object.freeze({ apps: compiledApps, + compileResults, diagnostics: freezeDiagnostics(contexts.flatMap((context, index) => appDiagnostics(context, index))), }); } @@ -609,11 +626,17 @@ export const compileMcpApps = async ( await rm(fallbackRoot, { force: true, recursive: true }); } const replaced = new Map(production.apps.map((app) => [app.name, app])); + const replacementResults = new Map(production.compileResults.map((result) => [ + result.assets[0]!.path, + result, + ])); return Object.freeze({ apps: Object.freeze(compiledApps.map((app) => { const replacement = replaced.get(app.name); return replacement === undefined ? app : Object.freeze({ ...app, size: replacement.size, sourceInputs: replacement.sourceInputs }); })), + compileResults: Object.freeze(compiledApps.map((app, index) => + replacementResults.get(`mcp-apps/${app.name}.html`) ?? compileResults[index]!)), // The production compile's own diagnostics are not merged: its warnings // are this compile's (same module graph), and each replaced App gets // exactly one `AB4772` here — the substitution notice when the diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index c61d34aaf..1e685eff0 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -2,10 +2,16 @@ import { existsSync } from 'node:fs'; import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; +import { rspack } from '@rslib/core'; + import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; import { cliBinSourceInputs } from './cli-bins.ts'; +import { + createCompileEvidenceRecord, + type CompileEvidenceRecord, +} from './compile-evidence.ts'; import type { CompileResult } from './compile-result.ts'; import { buildWithRslib } from './compiler.ts'; import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts'; @@ -55,6 +61,8 @@ export interface PackageOutputFile { } export interface PackageBuildResult { + /** Compile evidence for the emitted files, paths as a consumer sees them (`dist/bin/.js`); in process only, since `dist` has no manifest to bind it to. */ + readonly evidence: CompileEvidenceRecord; readonly files: readonly PackageOutputFile[]; readonly outputRoot: string; } @@ -322,9 +330,10 @@ export const buildPackageOutputs = async (options: { ? [runtimeIgnoredRoot(terminalCapabilityRuntimePath())] : []), ])]); - const evidence = await buildPackageEntries({ + const publishedPrefix = toPosixRelative(projectRoot, outputRoot); + const compileResult = await buildPackageEntries({ cwd: projectRoot, - diagnosticPathPrefix: toPosixRelative(projectRoot, outputRoot), + diagnosticPathPrefix: publishedPrefix, entries, ...(ignoredRuntimeRoots.length === 0 ? {} : { ignoredSourcePaths: ignoredRuntimeRoots }), logLevel: 'error', @@ -334,7 +343,7 @@ export const buildPackageOutputs = async (options: { }, dtsTsconfig === undefined || packageBuild.lib === undefined ? undefined : { entryName: packageBuild.lib.name, tsconfigPath: dtsTsconfig.path }); - const evidenceByPath = new Map(evidence.assets.map((entry) => [entry.path, entry.sourceInputs])); + const evidenceByPath = new Map(compileResult.assets.map((entry) => [entry.path, entry.sourceInputs])); await Promise.all(entries .filter((entry) => entry.executable) .map((entry) => chmod(resolveArtifactDestination(stageRoot, entry.outputRelativePath), executableMode))); @@ -391,8 +400,15 @@ export const buildPackageOutputs = async (options: { }); if (selfContainment.length > 0) throw new DiagnosticError(selfContainment); + const evidence = await createCompileEvidenceRecord({ + pathPrefix: publishedPrefix, + results: [compileResult], + rewritable: options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined, + root: stageRoot, + rspackVersion: rspack.rspackVersion, + }); await publishArtifact({ outputRoot, stageRoot }); - return Object.freeze({ files: Object.freeze(files), outputRoot }); + return Object.freeze({ evidence, files: Object.freeze(files), outputRoot }); } finally { // publishArtifact removes the stage on success; a failed build leaves it. await rm(stageRoot, { force: true, recursive: true }); diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 71ca84514..b19916096 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -14,6 +14,7 @@ import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig } from '../core/types.ts'; import type { AgentBundleMeta } from '../meta.ts'; import type { + AssetIR, CompilationEvidence, CompileResult, ExternalIR, @@ -774,6 +775,41 @@ const packageNameOfResource = (resource: string): string | undefined => { : name; }; +/** Lowers one compiler's evidence to the IR of the single asset it emitted. */ +export const compileResultOf = ( + record: CompilationEvidence, + options: { + readonly asset: AssetIR; + readonly cwd: string; + readonly dependencyRoots: readonly string[]; + readonly emittedAssets: ReadonlySet; + }, +): CompileResult => { + const asset = options.asset.path; + return Object.freeze({ + assets: Object.freeze([options.asset]), + diagnostics: Object.freeze([]), + externals: Object.freeze(record.externals.map((external): ExternalIR => ({ + asset, + externalType: external.externalType, + issuers: external.issuers.map((issuer) => posixRelativeWhenInside(options.cwd, issuer)), + kind: classifyExternal(external, { asset, emittedAssets: options.emittedAssets }), + request: external.request, + userRequest: external.userRequest, + }))), + modules: Object.freeze(record.modules.map((module): ModuleIR => { + const packageName = module.resource === undefined ? undefined : packageNameOfResource(module.resource); + return { + asset, + identifier: module.identifier, + kind: moduleKindOf(module.resource, options.cwd, options.dependencyRoots), + ...(packageName === undefined ? {} : { package: packageName }), + ...(module.resource === undefined ? {} : { resource: module.resource }), + }; + })), + }); +}; + const moduleKindOf = ( resource: string | undefined, cwd: string, @@ -869,29 +905,11 @@ export const buildRslibSurfaces = async ( `Rslib did not record exactly one compilation evidence result for ${JSON.stringify(entry.outputRelativePath)}.`, ); } - const externals = record.externals.map((external): ExternalIR => ({ - asset: entry.outputRelativePath, - externalType: external.externalType, - issuers: external.issuers.map((issuer) => posixRelativeWhenInside(options.cwd, issuer)), - kind: classifyExternal(external, { asset: entry.outputRelativePath, emittedAssets }), - request: external.request, - userRequest: external.userRequest, - })); - const modules = record.modules.map((module): ModuleIR => { - const packageName = module.resource === undefined ? undefined : packageNameOfResource(module.resource); - return { - asset: entry.outputRelativePath, - identifier: module.identifier, - kind: moduleKindOf(module.resource, options.cwd, dependencyRoots), - ...(packageName === undefined ? {} : { package: packageName }), - ...(module.resource === undefined ? {} : { resource: module.resource }), - }; - }); - return [entry, Object.freeze({ - assets: Object.freeze([evidenceByPath.get(entry.outputRelativePath)!]), - diagnostics: Object.freeze([]), - externals: Object.freeze(externals), - modules: Object.freeze(modules), + return [entry, compileResultOf(record, { + asset: evidenceByPath.get(entry.outputRelativePath)!, + cwd: options.cwd, + dependencyRoots, + emittedAssets, })] as const; })); return Object.freeze(surfaces.map((surface) => { diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 483ee92bb..9dde7c782 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -20,6 +20,11 @@ import { isDirectOutputLayoutPath, matchesManifestFile, } from './artifact-layout.ts'; +import { + compileEvidenceDiagnostics, + compileEvidenceFileName, + parseCompileEvidenceRecord, +} from './compile-evidence.ts'; import { artifactHookIndexName, artifactManifestName, @@ -49,7 +54,7 @@ export { artifactDiagnosticRecoveries, type ArtifactDiagnosticCode } from './art export type * from './artifact-validation-types.ts'; const epochStagingMarkerName = '.agent-bundle-epoch-stage.json'; -const artifactRootMetadata = new Set([artifactHookIndexName]); +const artifactRootMetadata = new Set([artifactHookIndexName, compileEvidenceFileName]); const matchesManifestFileTable = ( files: readonly ArtifactFile[], @@ -580,6 +585,29 @@ const validateArtifactStructure = (options: { return Object.freeze(diagnostics); }; +const validateCompileEvidence = async (options: { + readonly artifactRoot: string; + readonly manifest: ArtifactManifest; +}): Promise => { + if (!options.manifest.files.some((file) => file.path === compileEvidenceFileName)) return Object.freeze([]); + const bytes = await runWithPlatform(readFileString(resolve(options.artifactRoot, compileEvidenceFileName))) + .catch(() => undefined); + if (bytes === undefined) { + return Object.freeze([diagnostic('AB6039', 'Compile evidence record cannot be read.', compileEvidenceFileName)]); + } + let record: ReturnType; + try { + record = parseCompileEvidenceRecord(bytes); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + return Object.freeze([diagnostic('AB6039', error.message, compileEvidenceFileName)]); + } + return compileEvidenceDiagnostics( + record, + new Map(options.manifest.files.map((file) => [file.path, { kind: file.kind, sha256: file.sha256 }])), + ); +}; + const validateGeneratedFiles = async (options: { readonly artifactRoot: string; readonly bundleSyntaxCheck?: ModuleSyntaxCheck; @@ -596,7 +624,8 @@ const validateGeneratedFiles = async (options: { ); const validJson = new Set(); - for (const file of options.files.filter((entry) => entry.path.endsWith('.json'))) { + // The compile evidence record has its own strict reader (`AB6039`). + for (const file of options.files.filter((entry) => entry.path.endsWith('.json') && entry.path !== compileEvidenceFileName)) { try { // Strict parseability only: host MCP documents are read against the // compiled entries by validateMcpCoherence. @@ -739,6 +768,7 @@ export const validateArtifactWithSnapshot = async ( // Read-only validators over the same immutable inspection run concurrently; // collecting in this fixed order keeps the diagnostics sequence deterministic. const [ + compileEvidenceRecordDiagnostics, targetContractDiagnostics, portableTargetDiagnostics, mcpCoherenceDiagnostics, @@ -746,6 +776,7 @@ export const validateArtifactWithSnapshot = async ( emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ + validateCompileEvidence({ artifactRoot, manifest }), validateTargetContracts({ artifactRoot, files: inspection.files, @@ -786,6 +817,7 @@ export const validateArtifactWithSnapshot = async ( }), ]); diagnostics.push( + ...compileEvidenceRecordDiagnostics, ...targetContractDiagnostics, ...portableTargetDiagnostics, ...mcpCoherenceDiagnostics, diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 1fc5dd227..f7f2a9b47 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -18,6 +18,12 @@ import { type TargetArtifactWrite, } from '../src/adapters/types.ts'; import { composeProjections } from '../src/build/compose.ts'; +import { + compileEvidenceFileName, + serializeCompileEvidenceRecord, + type CompileEvidenceAsset, + type CompileEvidenceExternal, +} from '../src/build/compile-evidence.ts'; import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; import { digest, sha256Hex } from '../src/core/digest.ts'; @@ -105,6 +111,168 @@ const writeArtifact = async ( return root; }; +const compileEvidence = ( + assets: readonly CompileEvidenceAsset[], + policyRevision = 1, +): string => serializeCompileEvidenceRecord({ + assets, + coverage: { rewritable: false, unobserved: [] }, + policy: { name: 'closed-world-externals', revision: policyRevision }, + producer: { name: 'agent-bundle', rspack: '1.0.0', version: '0.1.0' }, +}); + +const compileEvidenceAsset = ( + path: string, + sha256: string, + externals: readonly CompileEvidenceExternal[] = [], +): CompileEvidenceAsset => ({ externals, packages: [], path, sha256 }); + +const compileEvidenceFixture = async ( + record: string, + files: readonly ArtifactFixtureFile[], +): Promise => writeArtifact([ + ...files, + { contents: record, kind: 'generated', path: compileEvidenceFileName }, +]); + +it('accepts compile evidence that covers a matching bundle', async () => { + const bundle = { contents: 'export default 1;\n', kind: 'bundle' as const, path: 'bin/index.mjs' }; + const root = await compileEvidenceFixture( + compileEvidence([compileEvidenceAsset(bundle.path, hash(bundle.contents))]), + [bundle], + ); + + try { + expect((await validateArtifact({ artifactRoot: root })).filter((diagnostic) => diagnostic.code === 'AB6039')).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports compile evidence for different bundle bytes', async () => { + const bundle = { contents: 'export default 1;\n', kind: 'bundle' as const, path: 'bin/index.mjs' }; + const root = await compileEvidenceFixture( + compileEvidence([compileEvidenceAsset(bundle.path, hash('export default 2;\n'))]), + [bundle], + ); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6039', message: expect.stringContaining('describes different bytes') }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports compile evidence that does not cover a bundle', async () => { + const bundle = { contents: 'export default 1;\n', kind: 'bundle' as const, path: 'bin/index.mjs' }; + const root = await compileEvidenceFixture(compileEvidence([]), [bundle]); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6039', message: expect.stringContaining('does not cover') }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports compile evidence that names a copy file', async () => { + const copy = { contents: 'copied\n', kind: 'copy' as const, path: 'assets/copied.txt' }; + const root = await compileEvidenceFixture( + compileEvidence([compileEvidenceAsset(copy.path, hash(copy.contents))]), + [copy], + ); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6039', message: expect.stringContaining('does not list as a compiled file') }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports a non-builtin external in compile evidence', async () => { + const bundle = { contents: 'export default 1;\n', kind: 'bundle' as const, path: 'bin/index.mjs' }; + const external = { + externalType: 'commonjs', + issuers: [], + kind: 'builtin', + request: 'left-pad', + userRequest: 'left-pad', + } satisfies CompileEvidenceExternal; + const root = await compileEvidenceFixture( + compileEvidence([compileEvidenceAsset(bundle.path, hash(bundle.contents), [external])]), + [bundle], + ); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6039', message: expect.stringContaining('is not one') }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports a missing artifact-relative external target in compile evidence', async () => { + const bundle = { contents: 'export default 1;\n', kind: 'bundle' as const, path: 'bin/index.mjs' }; + const external = { + externalType: 'commonjs', + issuers: [], + kind: 'artifact-relative', + request: './sibling.mjs', + target: 'bin/sibling.mjs', + userRequest: './sibling.mjs', + } satisfies CompileEvidenceExternal; + const root = await compileEvidenceFixture( + compileEvidence([compileEvidenceAsset(bundle.path, hash(bundle.contents), [external])]), + [bundle], + ); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6039', message: expect.stringContaining('does not contain') }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports compile evidence from a different policy revision', async () => { + const bundle = { contents: 'export default 1;\n', kind: 'bundle' as const, path: 'bin/index.mjs' }; + const root = await compileEvidenceFixture( + compileEvidence([compileEvidenceAsset(bundle.path, hash(bundle.contents))], 2), + [bundle], + ); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB6039', message: expect.stringContaining('was judged under policy') }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('reports malformed compile evidence as a non-strict record', async () => { + const root = await compileEvidenceFixture('{not JSON}\n', []); + + try { + await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual([ + expect.objectContaining({ + code: 'AB6039', + generatedPath: compileEvidenceFileName, + message: 'Compile evidence record is not valid JSON.', + }), + ]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + const customTarget = 'custom'; const customMetadata = Object.freeze({ adapterRevision: 'custom-adapter-v1', @@ -2276,6 +2444,7 @@ it('documents recovery for every stable artifact diagnostic code', async () => { 'AB6007', 'AB6008', 'AB6009', 'AB6010', 'AB6011', 'AB6012', 'AB6013', 'AB6014', 'AB6015', 'AB6016', 'AB6017', 'AB6018', 'AB6019', 'AB6020', 'AB6021', 'AB6022', 'AB6023', 'AB6024', 'AB6025', 'AB6034', + 'AB6039', ]); expect(Object.values(artifactDiagnosticRecoveries).every((recovery) => recovery.trim().length > 0)).toBe(true); expect(artifactDiagnosticRecoveries.AB6015).not.toBe(artifactDiagnosticRecoveries.AB6016); diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 80aed8bbe..4517180f0 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -178,6 +178,7 @@ describe('composite plugin root (#555)', () => { '.codex-plugin', // Codex manifest, hooks document, MCP document '.mcp.json', // Claude Code MCP document (conventional root path) 'INSTALL.md', + 'agent-bundle.compile-evidence.json', // compiler evidence per compiled file (AB6039) 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'commands', @@ -209,6 +210,7 @@ describe('composite plugin root (#555)', () => { '.agents', '.codex-plugin', 'INSTALL.md', + 'agent-bundle.compile-evidence.json', 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'hooks', @@ -235,6 +237,7 @@ describe('composite plugin root (#555)', () => { expect(result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); expect(await topLevel(output)).toEqual([ 'INSTALL.md', + 'agent-bundle.compile-evidence.json', 'agent-bundle.hooks.json', // always written; empty here since portable hosts no hooks 'agent-bundle.manifest.json', 'install.mjs', // the self-contained local installer (S5 narrows it to Cursor) @@ -314,6 +317,7 @@ describe('composite plugin root (#555)', () => { expect(await topLevel(cursorOnly.output)).toEqual([ '.cursor-plugin', 'INSTALL.md', + 'agent-bundle.compile-evidence.json', 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'commands', diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 38a2f8231..4bf16c04d 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -9,6 +9,11 @@ import { createRslib } from '@rslib/core'; import { createJiti } from 'jiti'; import { build as buildArtifact, type BuildOptions as LowLevelBuildOptions, type BuildResult } from '../src/build/build.ts'; +import { + compileEvidenceFileName, + parseCompileEvidenceRecord, + unobservedLoadForms, +} from '../src/build/compile-evidence.ts'; import { buildWithRslib } from '../src/build/compiler.ts'; import type { RslibEntry } from '../src/build/rslib.ts'; import type { AgentBundleMeta } from '../src/meta.ts'; @@ -344,11 +349,26 @@ it('low-level build writes and returns the exact canonical manifest for a config const manifestBytes = await readFile(join(project.outputRoot, 'agent-bundle.manifest.json'), 'utf8'); const manifest = parseArtifactManifest(manifestBytes); + const compileEvidence = parseCompileEvidenceRecord( + await readFile(join(project.outputRoot, compileEvidenceFileName), 'utf8'), + ); const files = (await treeDigest(project.outputRoot)).filter( (entry) => entry.path !== 'agent-bundle.manifest.json', ); expect(files.some((entry) => entry.path.includes('/rules/'))).toBe(false); expect(result.manifest).toEqual(manifest); + expect(result.compileEvidence).toEqual(compileEvidence); + expect(manifest.files).toContainEqual(expect.objectContaining({ + kind: 'generated', + path: compileEvidenceFileName, + })); + expect(compileEvidence.assets).toEqual(manifest.files + .filter((file) => file.kind === 'bundle') + .map((file) => expect.objectContaining({ path: file.path, sha256: file.sha256 }))); + expect(compileEvidence.coverage).toEqual({ + rewritable: false, + unobserved: unobservedLoadForms, + }); expect(manifestBytes).toBe(serializeArtifactManifest(result.manifest)); expect(manifest).toMatchObject({ files: files.map(({ bytes, path, sha256 }) => ({ bytes, path, sha256 })), @@ -424,6 +444,25 @@ it('low-level build writes and returns the exact canonical manifest for a config } }); +it('marks compile evidence rewritable when a tools hatch participates', async () => { + const project = await createProject(); + try { + const result = await build({ + model: modelFor(project), + outputRoot: project.outputRoot, + projectRoot: project.root, + registry: new TargetRegistry().register( + (await import('../src/adapters/portable.ts')).portableAdapter, + { default: true }, + ), + tools: { rspack: () => undefined }, + }); + expect(result.compileEvidence.coverage.rewritable).toBe(true); + } finally { + await cleanupProject(project); + } +}); + it('uses the package version in a manifest produced by the raw source build module', async () => { const project = await createProject(); const model = modelFor(project); diff --git a/packages/agent-bundle/tests/compile-evidence.test.ts b/packages/agent-bundle/tests/compile-evidence.test.ts new file mode 100644 index 000000000..942ed3da7 --- /dev/null +++ b/packages/agent-bundle/tests/compile-evidence.test.ts @@ -0,0 +1,202 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { + compileEvidenceDiagnostics, + createCompileEvidenceRecord, + parseCompileEvidenceRecord, + serializeCompileEvidenceRecord, + type CompileEvidenceExternal, + type CompileEvidenceRecord, +} from '../src/build/compile-evidence.ts'; +import type { CompileResult } from '../src/build/compile-result.ts'; +import { sha256Hex } from '../src/core/digest.ts'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const createFixture = async (): Promise<{ readonly record: CompileEvidenceRecord; readonly root: string }> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-compile-evidence-')); + roots.push(root); + await mkdir(join(root, 'bin'), { recursive: true }); + await writeFile(join(root, 'bin', 'main.js'), 'export {};\n'); + await writeFile(join(root, 'bin', 'worker.mjs'), 'export {};\n'); + const result: CompileResult = { + assets: [ + { path: 'bin/worker.mjs', sourceInputs: ['/project/worker.ts'] }, + { path: 'bin/main.js', sourceInputs: ['/project/main.ts'] }, + ], + diagnostics: [], + externals: [ + { + asset: 'bin/main.js', + externalType: 'node-commonjs', + issuers: ['src/main.ts'], + kind: 'builtin', + request: 'node:path', + userRequest: 'node:path', + }, + { + asset: 'bin/main.js', + externalType: 'module', + issuers: ['src/main.ts'], + kind: 'artifact-relative', + request: './worker.mjs', + userRequest: './worker.mjs', + }, + ], + modules: [{ + asset: 'bin/main.js', + identifier: '/project/node_modules/example-package/index.js', + kind: 'dependency', + package: 'example-package', + resource: '/project/node_modules/example-package/index.js', + }], + }; + return { + record: await createCompileEvidenceRecord({ + pathPrefix: 'dist', + results: [result], + rewritable: false, + root, + rspackVersion: '2.2.2', + }), + root, + }; +}; + +describe('compile evidence records', () => { + it('creates deterministic evidence and round-trips its canonical serialization', async () => { + const { record } = await createFixture(); + expect(record.assets.map((asset) => asset.path)).toEqual([ + 'dist/bin/main.js', + 'dist/bin/worker.mjs', + ]); + expect(record.assets[0]).toMatchObject({ + packages: ['example-package'], + sha256: sha256Hex('export {};\n'), + }); + expect(record.assets[0]!.externals).toContainEqual(expect.objectContaining({ + kind: 'artifact-relative', + target: 'dist/bin/worker.mjs', + })); + expect(parseCompileEvidenceRecord(serializeCompileEvidenceRecord(record))).toEqual(record); + }); + + it.each([ + ['invalid JSON', '{'], + ['an unexpected key', '{"assets":[],"coverage":{"rewritable":false,"unobserved":[]},"policy":{"name":"closed-world-externals","revision":1},"producer":{"name":"agent-bundle","rspack":"2","version":"1"},"extra":true}'], + ['unsorted assets', '{"assets":[{"externals":[],"packages":[],"path":"z.js","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"externals":[],"packages":[],"path":"a.js","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"coverage":{"rewritable":false,"unobserved":[]},"policy":{"name":"closed-world-externals","revision":1},"producer":{"name":"agent-bundle","rspack":"2","version":"1"}}'], + ['a bad sha256', '{"assets":[{"externals":[],"packages":[],"path":"a.js","sha256":"bad"}],"coverage":{"rewritable":false,"unobserved":[]},"policy":{"name":"closed-world-externals","revision":1},"producer":{"name":"agent-bundle","rspack":"2","version":"1"}}'], + ['an unsafe path', '{"assets":[{"externals":[],"packages":[],"path":"../a.js","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"coverage":{"rewritable":false,"unobserved":[]},"policy":{"name":"closed-world-externals","revision":1},"producer":{"name":"agent-bundle","rspack":"2","version":"1"}}'], + ['a builtin target', '{"assets":[{"externals":[{"externalType":"module","issuers":[],"kind":"builtin","request":"node:path","target":"a.js","userRequest":"node:path"}],"packages":[],"path":"a.js","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"coverage":{"rewritable":false,"unobserved":[]},"policy":{"name":"closed-world-externals","revision":1},"producer":{"name":"agent-bundle","rspack":"2","version":"1"}}'], + ['an artifact-relative external without a target', '{"assets":[{"externals":[{"externalType":"module","issuers":[],"kind":"artifact-relative","request":"./b.js","userRequest":"./b.js"}],"packages":[],"path":"a.js","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}],"coverage":{"rewritable":false,"unobserved":[]},"policy":{"name":"closed-world-externals","revision":1},"producer":{"name":"agent-bundle","rspack":"2","version":"1"}}'], + ])('rejects %s', (_case, bytes) => { + expect(() => parseCompileEvidenceRecord(bytes)).toThrow(TypeError); + }); + + it('reports every file-table and policy mismatch', () => { + const hash = 'a'.repeat(64); + const record: CompileEvidenceRecord = { + assets: [{ + externals: [ + { + externalType: 'module', + issuers: [], + kind: 'builtin', + request: 'left-pad', + userRequest: 'left-pad', + }, + { + externalType: 'module', + issuers: [], + kind: 'artifact-relative', + request: './missing.js', + target: 'missing.js', + userRequest: './missing.js', + }, + ], + packages: [], + path: 'wrong-kind.js', + sha256: hash, + }, { + externals: [], + packages: [], + path: 'mismatch.js', + sha256: hash, + }], + coverage: { rewritable: false, unobserved: [] }, + policy: { name: 'closed-world-externals', revision: 2 }, + producer: { name: 'agent-bundle', rspack: '2.2.2', version: '1.0.0' }, + }; + const diagnostics = compileEvidenceDiagnostics(record, new Map([ + ['uncovered.js', { kind: 'bundle', sha256: hash }], + ['mismatch.js', { kind: 'bundle', sha256: 'b'.repeat(64) }], + ['wrong-kind.js', { kind: 'copy', sha256: hash }], + ])); + expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual(expect.arrayContaining([ + expect.stringContaining('was judged under policy'), + expect.stringContaining('does not cover compiled file "uncovered.js"'), + expect.stringContaining('for "mismatch.js" describes different bytes'), + expect.stringContaining('manifest does not list as a compiled file'), + expect.stringContaining('"left-pad" as a built-in; it is not one'), + expect.stringContaining('sibling "./missing.js", which the artifact does not contain'), + ])); + }); + + it('re-judges every external instead of trusting the recorded kind or target', () => { + const hash = 'a'.repeat(64); + const external = (fields: Partial): CompileEvidenceExternal => ({ + externalType: 'module', + issuers: [], + kind: 'artifact-relative', + request: './lib/b.js', + target: 'lib/b.js', + userRequest: './lib/b.js', + ...fields, + }); + const files = new Map([ + ['a.js', { kind: 'bundle', sha256: hash }], + ['lib/b.js', { kind: 'bundle', sha256: hash }], + ['copied.mjs', { kind: 'copy', sha256: hash }], + ['mcp-apps/view.html', { kind: 'bundle', sha256: hash }], + ]); + const judge = (externals: readonly CompileEvidenceExternal[], viewExternals: readonly CompileEvidenceExternal[] = []): readonly string[] => + compileEvidenceDiagnostics({ + assets: [ + { externals, packages: [], path: 'a.js', sha256: hash }, + { externals: [], packages: [], path: 'lib/b.js', sha256: hash }, + { externals: viewExternals, packages: [], path: 'mcp-apps/view.html', sha256: hash }, + ], + coverage: { rewritable: false, unobserved: [] }, + policy: { name: 'closed-world-externals', revision: 1 }, + producer: { name: 'agent-bundle', rspack: '2.2.2', version: '1.0.0' }, + }, files).map((diagnostic) => diagnostic.message); + + expect(judge([external({})])).toEqual([]); + // A bare package request cannot borrow a sibling as its target. + expect(judge([external({ request: 'left-pad', userRequest: 'left-pad' })])) + .toEqual([expect.stringContaining('sibling "left-pad", which the artifact does not contain')]); + // The target must be the file the request resolves to from the asset. + expect(judge([external({ target: 'a.js' })])) + .toEqual([expect.stringContaining('sibling "./lib/b.js", which the artifact does not contain')]); + // A sibling that is not a compiled file is not a valid load target. + expect(judge([external({ request: './copied.mjs', target: 'copied.mjs', userRequest: './copied.mjs' })])) + .toEqual([expect.stringContaining('sibling "./copied.mjs", which the artifact does not contain')]); + // A built-in kept through a non-module-loading external type is not a load. + expect(judge([{ externalType: 'var', issuers: [], kind: 'builtin', request: 'node:fs', userRequest: 'node:fs' }])) + .toEqual([expect.stringContaining('"node:fs" as a built-in; it is not one')]); + // A node bundle cannot load an MCP App view as a sibling. + expect(judge([external({ request: './mcp-apps/view.html', target: 'mcp-apps/view.html', userRequest: './mcp-apps/view.html' })])) + .toEqual([expect.stringContaining('sibling "./mcp-apps/view.html", which the artifact does not contain')]); + // A view keeps no external at all, built-ins included. + expect(judge([], [{ externalType: 'module', issuers: [], kind: 'builtin', request: 'node:fs', userRequest: 'node:fs' }])) + .toEqual([expect.stringContaining('for "mcp-apps/view.html" records "node:fs" as an external; a view inlines every module it loads')]); + }); +}); diff --git a/packages/agent-bundle/tests/compile-stages.test.ts b/packages/agent-bundle/tests/compile-stages.test.ts index d1d134ad5..c9ac49c40 100644 --- a/packages/agent-bundle/tests/compile-stages.test.ts +++ b/packages/agent-bundle/tests/compile-stages.test.ts @@ -229,7 +229,10 @@ describe('buildRslibSurfaces', () => { }), ]); expect(created).toBe(0); - expect(bins).toEqual([['settled']]); + expect(bins).toEqual({ + compileResults: [{ assets: [], diagnostics: [], externals: [], modules: [] }], + results: [['settled']], + }); expect(evidence).toEqual([ { assets: [], diagnostics: [], externals: [], modules: [] }, { assets: [], diagnostics: [], externals: [], modules: [] }, diff --git a/packages/agent-bundle/tests/dev-package-build-service.test.ts b/packages/agent-bundle/tests/dev-package-build-service.test.ts index 067ec7730..187a10e1b 100644 --- a/packages/agent-bundle/tests/dev-package-build-service.test.ts +++ b/packages/agent-bundle/tests/dev-package-build-service.test.ts @@ -56,7 +56,20 @@ const invalidation = ( reason, }); +const compileEvidence = (): PackageBuildResult['evidence'] => ({ + assets: [{ + externals: [], + packages: [], + path: 'dist/bin/tool.js', + sha256: '0'.repeat(64), + }], + coverage: { rewritable: false, unobserved: [] }, + policy: { name: 'closed-world-externals', revision: 1 }, + producer: { name: 'agent-bundle', rspack: 'test', version: '0.0.0' }, +}); + const buildResult = (sourceInputs: readonly string[]): PackageBuildResult => ({ + evidence: compileEvidence(), files: [{ bytes: 1, kind: 'bundle', @@ -245,6 +258,7 @@ it('removes the outputs it published when the package build disappears', async ( const service = new DevPackageBuildService({ buildOutputs: (async () => ({ + evidence: compileEvidence(), files: [ { bytes: 1, kind: 'bundle' as const, path: 'bin/tool.js', sha256: '0'.repeat(64), sourceInputs: ['src/cli.ts'] }, { bytes: 1, kind: 'bundle' as const, path: 'index.js', sha256: '0'.repeat(64), sourceInputs: ['src/index.ts'] }, @@ -277,6 +291,7 @@ it('prunes the output root entirely when it only held published outputs', async const service = new DevPackageBuildService({ buildOutputs: (async () => ({ + evidence: compileEvidence(), files: [ { bytes: 1, kind: 'bundle' as const, path: 'bin/tool.js', sha256: '0'.repeat(64), sourceInputs: ['src/cli.ts'] }, ], diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index 692a669fd..2d5b6ef7d 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -589,6 +589,24 @@ it('isolates malicious relative writes from the referenced epoch and rejects coo manifestEntry.bytes = Buffer.byteLength(tamperedWrapper); manifestEntry.sha256 = sha256Hex(tamperedWrapper); await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + // The compile evidence record still describes the compiler's bytes, so the + // artifact validator catches the coordinated file + manifest rewrite first. + await expect(service.simulate(request)).rejects.toThrow(/AB6039.*describes different bytes/u); + + const evidencePath = join(epochRoot, 'agent-bundle.compile-evidence.json'); + const evidence = JSON.parse(await readFile(evidencePath, 'utf8')) as { + readonly assets: Array<{ path: string; sha256: string }>; + }; + const evidenceEntry = evidence.assets.find((entry) => entry.path === wrapperPath); + if (evidenceEntry === undefined) throw new Error('Expected wrapper compile evidence entry.'); + evidenceEntry.sha256 = sha256Hex(tamperedWrapper); + const tamperedEvidence = `${JSON.stringify(evidence)}\n`; + await writeFile(evidencePath, tamperedEvidence); + const evidenceManifestEntry = manifest.files.find((entry) => entry.path === 'agent-bundle.compile-evidence.json'); + if (evidenceManifestEntry === undefined) throw new Error('Expected compile evidence manifest entry.'); + evidenceManifestEntry.bytes = Buffer.byteLength(tamperedEvidence); + evidenceManifestEntry.sha256 = sha256Hex(tamperedEvidence); + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); await expect(service.simulate(request)).rejects.toThrow(/stored digest/i); } finally { await rm(root, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/mcp-apps-compile.test.ts b/packages/agent-bundle/tests/mcp-apps-compile.test.ts index 89d541ff0..24d6fb3e6 100644 --- a/packages/agent-bundle/tests/mcp-apps-compile.test.ts +++ b/packages/agent-bundle/tests/mcp-apps-compile.test.ts @@ -175,6 +175,11 @@ describe('compileMcpApps', () => { join(root, 'agent-bundle.config.ts'), join(root, 'views', 'status.ts'), ]); + expect(result.compileResults).toHaveLength(1); + expect(result.compileResults[0]!.assets).toEqual([{ + path: 'mcp-apps/status.html', + sourceInputs: result.apps[0]!.sourceInputs, + }]); expect(await readdir(outDir)).toEqual(['mcp-apps']); expect(await readdir(join(outDir, 'mcp-apps'))).toEqual(['status.html']); expect(html).toMatch(/]*\bsrc=)[^>]*>/u); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index 351e1dc1b..c6d6f7bc9 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -50,6 +50,8 @@ const conventionFixture = (): Readonly> => ({ '', ].join('\n'), 'package.json': '{"name":"package-build-fixture","type":"module","private":true}\n', + 'node_modules/evidence-package/index.js': 'globalThis.__evidencePackageLoaded = true;\n', + 'node_modules/evidence-package/package.json': '{"name":"evidence-package","type":"module","version":"1.0.0"}\n', 'tsconfig.json': JSON.stringify({ compilerOptions: { module: 'esnext', @@ -60,6 +62,8 @@ const conventionFixture = (): Readonly> => ({ }, }), 'src/cli.ts': [ + "import 'evidence-package';", + '', 'export const main = async (argv: readonly string[]): Promise => {', " process.stdout.write(`ran:${argv.join(',')}\\n`);", " return argv.includes('--fail') ? 3 : 0;", @@ -137,6 +141,14 @@ describe('framework-owned package build', () => { expect(paths).toContain('bin/package-build-fixture.js'); expect(paths).toContain('index.js'); expect(paths).toContain('index.d.ts'); + expect(packageBuild!.evidence.assets.map((asset) => asset.path)).toEqual( + expect.arrayContaining(['dist/bin/package-build-fixture.js', 'dist/index.js']), + ); + expect(packageBuild!.evidence.assets.flatMap((asset) => asset.externals) + .every((external) => external.kind === 'builtin')).toBe(true); + expect(packageBuild!.evidence.assets + .find((asset) => asset.path === 'dist/bin/package-build-fixture.js')?.packages) + .toContain('evidence-package'); for (const file of packageBuild!.files) { expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); } diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index 1de0c0bf2..02dfb303b 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -57,9 +57,10 @@ build/build.ts + build/emit.ts + build/compile-stages.ts ▼ composite plugin root + agent-bundle.manifest.json + + agent-bundle.compile-evidence.json │ ▼ -build/validate-artifact*.ts / AB6005 +build/validate-artifact*.ts / AB6005 / AB6039 / prepack AB7014, AB7015 ← Artifact layer ``` @@ -275,6 +276,7 @@ and writes one tree at `artifactRoot` (CLI default `artifact/`; | Scripts, hooks, MCP entries, CLI bins | `build/rslib.ts` `compileRslibSurfaces` via `build/entries.ts`, `build/cli-bins.ts` | `scripts/*.mjs`, `hooks/*.mjs`, `mcp/mcp--.mjs`, `bin/.mjs` (+ `-flight.mjs` workers) | | MCP Apps | `build/mcp-apps.ts` `compileMcpApps` | `mcp-apps/.html` (inlined into the generated server as well) | | Hook index | `build/emit.ts` `writeHookIndex` | `agent-bundle.hooks.json` over the selected hosts | +| Compile evidence | `build/compile-evidence.ts` `createCompileEvidenceRecord` | `agent-bundle.compile-evidence.json` (one `assets[]` entry per compiled file) | | Manifest | `build/build.ts` `manifestFor` + `build/emit.ts` `writeManifest` | `agent-bundle.manifest.json` | | Publish | `build/emit.ts` `publishArtifact` | Atomic rename of the stage directory onto `outputRoot` | @@ -286,6 +288,7 @@ embed their HTML), then the node surfaces. | Check | Module | Code | | --- | --- | --- | | Canonical manifest parse + file digest match | `build/validate-artifact.ts` | `AB60xx` family; parse errors throw from `parseArtifactManifest` | +| Compile evidence record vs manifest `bundle` files | `build/compile-evidence.ts` `compileEvidenceDiagnostics` | `AB6039` | | Host-pack and package-build `dist` modules import only relative/`file:` specifiers or Node built-ins | `build/validate-artifact-modules.ts` | `AB6005` | | Skills / hooks / MCP documents match the selected hosts | `build/validate-artifact-skills.ts`, `build/validate-artifact-hooks.ts`, `build/validate-artifact-mcp.ts` | per-surface `AB60xx` | | Artifact ownership of the output root | `build/validate-artifact.ts` | `AB6014` | @@ -328,6 +331,7 @@ artifact/ ├── skills/, scripts/, commands/, rules/, assets/, mcp-apps/ ├── INSTALL.md, install.mjs ├── agent-bundle.manifest.json +├── agent-bundle.compile-evidence.json └── agent-bundle.hooks.json ``` @@ -431,6 +435,7 @@ Production readers call `parseArtifactManifest` or take an already-parsed | Reader | Fields used | Purpose | | --- | --- | --- | | `build/validate-artifact.ts` | all of them | Re-parse the on-disk bytes, match `files[]` digests and modes, check `targets[]` against the registry, pin `agentSkills` and `runtime.node` | +| `build/compile-evidence.ts` | `files[]` (`bundle` rows) | `AB6039`: re-check the persisted compile evidence record against the file table without parsing JavaScript | | `build/validate-artifact-modules.ts` | `files[]` (`.mjs` rows) plus package-build `dist` | `AB6005` import graph | | `build/validate-artifact-skills.ts` | `targets[]` (`manifestTargets`) plus the tree | Skill documents vs selected hosts | | `build/validate-artifact-hooks.ts` | `targets[]`, hook files | Hook documents vs selected hosts | diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index c825227fc..cc087d570 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -24,7 +24,8 @@ present is not really self-contained, and the `packed-deleted-source` proof leve scripts and assets. Artifact validation compares real bytes against those digests, so a hand-edited generated file fails rather than passing because the path still exists. Referenced files are checked too — a manifest-declared `logo` that is missing from the artifact or escapes -the deploy tree reports `AB6025`. +the deploy tree reports `AB6025`. A listed compile evidence record that does not match the +manifest file table reports `AB6039`. Self-containment is proven from the compilation's externals (`AB6005` at compile time). The compiler service lowers every host-pack surface and package-build entry (`dist/bin/*.js`, a @@ -58,6 +59,43 @@ are not reported either. An installed artifact serves an App through [` web`](../../reference/cli.mdx#plugin-web); see [Exposing an App in the browser](../authoring/mcp.mdx#exposing-an-app-in-the-browser). +## Compile evidence record + +`agent-bundle build` writes `agent-bundle.compile-evidence.json` at the artifact +root and lists it in `agent-bundle.manifest.json` as a `generated` file. The +record is what the compiler service reported about each file it emitted, bound +to those bytes. Each `assets[]` entry is one compiled file (`bundle` kind: +`bin/*.mjs`, `scripts/*.mjs`, `hooks/*.mjs`, `mcp/*.mjs`, Flight workers, +`mcp-apps/*.html`) and holds `path`, `sha256`, the kept `externals` (`kind` +`artifact-relative` or `builtin`, `externalType`, `issuers`, `request`, +`userRequest`, and `target` for a sibling), and the inlined `packages`. The +record also carries policy `closed-world-externals@1`, producer +`{ name: 'agent-bundle', rspack, version }`, `coverage.rewritable` (true when a +`tools.rspack` or `tools.rsbuild` hatch took part, so emitted bytes may differ +from the module graph), and `coverage.unobserved`. + +`agent-bundle validate --artifact` re-checks a listed record against the +manifest file table without reading JavaScript (`AB6039`). The package build +returns the same record in process (`PackageBuildResult.evidence`, paths +`dist/bin/…`) and writes nothing to disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in the +bundle, so the compiler neither bundles nor records them: +`import()`, `require()`, `require.resolve(…)`, +`createRequire(…)(…)`, `import.meta.resolve(…)`. No externals recorded +therefore does not prove the absence of such a load. + +`AB6039` is an **error**. Recovery: rebuild the artifact so its compile +evidence record describes the emitted files. Message forms: + +- `Compile evidence record .` (the strict parser's own message, e.g. `is not valid JSON`) +- `Compile evidence record cannot be read.` +- `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` +- `Compile evidence does not cover compiled file "".` +- `Compile evidence for "" describes different bytes.` +- `Compile evidence names "", which the manifest does not list as a compiled file.` +- `Compile evidence for "" records "" as a built-in; it is not one.` +- `Compile evidence for "" records sibling "", which the artifact does not contain.` +- `Compile evidence for "" records "" as an external; a view inlines every module it loads.` + Every diagnostic is one structured record: a stable `AB` code, a severity, a message, and usually a `sourcePath` and a `recovery` hint. The diagnostic-gated commands — `build`, `prepack`, `validate`, `doctor`, `install`, and `dev` — exit nonzero **only** when an error diagnostic is diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index fcb2ffc1c..5d33cde7e 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -52,9 +52,14 @@ artifact/ ├── INSTALL.md # when any built-in host is selected ├── install.mjs # when cursor or portable is selected ├── agent-bundle.manifest.json # selected projections + provenance +├── agent-bundle.compile-evidence.json # compiler record per compiled file └── agent-bundle.hooks.json # hook index over selected hosts ``` +`agent-bundle.compile-evidence.json` is the compiler's record of each compiled +(`bundle`) file; `validate --artifact` re-checks a listed record against the +file table (`AB6039`). + Host manifests live in their dotfolders at the root. `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and `assets/` are shared and emitted **once** — no per-host copies. Nothing else appears at the root: no generated `AGENTS.md`, no `hooks/hooks-cursor.json`, no `web/` directory. The diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index 536dda065..52b0e31b3 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -53,9 +53,10 @@ build/build.ts + build/emit.ts + build/compile-stages.ts ▼ composite plugin root + agent-bundle.manifest.json + + agent-bundle.compile-evidence.json │ ▼ -build/validate-artifact*.ts / AB6005 +build/validate-artifact*.ts / AB6005 / AB6039 / prepack AB7014, AB7015 ← Artifact layer ``` @@ -241,6 +242,7 @@ Skill、命令、规则、配置中声明的钩子、手写的 MCP 入口以及 | 脚本、钩子、MCP 入口、CLI bin | `build/rslib.ts` 的 `compileRslibSurfaces`,经由 `build/entries.ts`、`build/cli-bins.ts` | `scripts/*.mjs`、`hooks/*.mjs`、`mcp/mcp--.mjs`、`bin/.mjs`(外加 `-flight.mjs` worker) | | MCP App | `build/mcp-apps.ts` 的 `compileMcpApps` | `mcp-apps/.html`(同时内联进生成的服务器) | | 钩子索引 | `build/emit.ts` 的 `writeHookIndex` | 覆盖所选宿主的 `agent-bundle.hooks.json` | +| 编译证据 | `build/compile-evidence.ts` 的 `createCompileEvidenceRecord` | `agent-bundle.compile-evidence.json`(每个已编译文件一条 `assets[]`) | | 清单 | `build/build.ts` 的 `manifestFor` + `build/emit.ts` 的 `writeManifest` | `agent-bundle.manifest.json` | | 发布 | `build/emit.ts` 的 `publishArtifact` | 把暂存目录原子重命名到 `outputRoot` | @@ -252,6 +254,7 @@ node 表面。 | 检查 | 模块 | 代码 | | --- | --- | --- | | 规范清单解析 + 文件摘要匹配 | `build/validate-artifact.ts` | `AB60xx` 系列;解析错误由 `parseArtifactManifest` 抛出 | +| 编译证据记录对照清单 `bundle` 文件 | `build/compile-evidence.ts` 的 `compileEvidenceDiagnostics` | `AB6039` | | 宿主包与包构建 `dist` 模块只导入相对/`file:` 说明符或 Node 内建模块 | `build/validate-artifact-modules.ts` | `AB6005` | | Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | | 产物对输出根目录的所有权 | `build/validate-artifact.ts` | `AB6014` | @@ -289,6 +292,7 @@ artifact/ ├── skills/, scripts/, commands/, rules/, assets/, mcp-apps/ ├── INSTALL.md, install.mjs ├── agent-bundle.manifest.json +├── agent-bundle.compile-evidence.json └── agent-bundle.hooks.json ``` @@ -378,6 +382,7 @@ CLI bin 的文件,只以 `files[]` 中一行的形式出现。 | 读取方 | 使用的字段 | 用途 | | --- | --- | --- | | `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `targets[]`,固定 `agentSkills` 与 `runtime.node` | +| `build/compile-evidence.ts` | `files[]`(`bundle` 行) | `AB6039`:把持久化的编译证据记录对照文件表复核,且不解析 JavaScript | | `build/validate-artifact-modules.ts` | `files[]`(`.mjs` 行)以及包构建的 `dist` | `AB6005` 导入图 | | `build/validate-artifact-skills.ts` | `targets[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | | `build/validate-artifact-hooks.ts` | `targets[]`、钩子文件 | 钩子文档对照所选宿主 | diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 6817da30a..e5b547d8c 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -20,7 +20,8 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, `agent-bundle.manifest.json` 为每个输出文件记录一份 SHA-256 摘要,包括被复制的脚本与资源。产物校验 把真实字节与这些摘要比对,因此被手工改过的生成文件会失败,而不会因为路径还在就通过。被引用的文件同样 -会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。 +会被检查——清单声明的 `logo` 若在产物中缺失或逃逸出部署树,会报告 `AB6025`。已列入清单的编译证据记录若 +与清单文件表不一致,会报告 `AB6039`。 自包含性首先由编译过程的 externals 证据证明(在编译期报告 `AB6005`)。编译器服务会降低每个宿主包表面 以及每个包构建入口(`dist/bin/*.js`、渲染式路由的 Flight worker `.mjs` 与 `lib` 入口)。框架自有的 @@ -46,6 +47,38 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 也不会被报告。已安装产物通过 [` web`](../../reference/cli.mdx#plugin-web) 提供 App;见 [在浏览器中暴露 App](../authoring/mcp.mdx#在浏览器中暴露-app)。 +## 编译证据记录 + +`agent-bundle build` 在产物根目录写出 `agent-bundle.compile-evidence.json`,并在 +`agent-bundle.manifest.json` 中把它列为 `generated` 文件。这份记录就是编译器服务对它输出的每个文件 +所报告的内容,并与那些字节绑定。`assets[]` 中的每一项对应一个已编译文件(`bundle` kind: +`bin/*.mjs`、`scripts/*.mjs`、`hooks/*.mjs`、`mcp/*.mjs`、Flight worker、`mcp-apps/*.html`), +并保存 `path`、`sha256`、被保留的 `externals`(`kind` 为 `artifact-relative` 或 `builtin`,以及 +`externalType`、`issuers`、`request`、`userRequest`,同级文件还有 `target`)和被内联的 +`packages`。记录级字段是策略 `closed-world-externals@1`、producer +`{ name: 'agent-bundle', rspack, version }`、`coverage.rewritable`(当 `tools.rspack` 或 +`tools.rsbuild` 逃生口参与了这次构建时为 true,此时输出字节可能与模块图不一致),以及 +`coverage.unobserved`。 + +`agent-bundle validate --artifact` 把已列入清单的记录对照清单文件表复核,且不读取 JavaScript +(`AB6039`)。包构建在进程内返回同一份记录(`PackageBuildResult.evidence`,路径为 `dist/bin/…`),不写到磁盘。 +`coverage.unobserved` 列出 Rslib 在捆绑中原样保留的加载形式,因此编译器既不会打包它们,也不会记录 +它们:`import()`、`require()`、`require.resolve(…)`、 +`createRequire(…)(…)`、`import.meta.resolve(…)`。因此「没有记录任何 externals」并不能证明不存在 +这类加载。 + +`AB6039` 是 **error**。恢复方式:重新构建产物,使其编译证据记录描述已输出的文件。消息形式: + +- `Compile evidence record .`(严格解析器自身的消息,例如 `is not valid JSON`) +- `Compile evidence record cannot be read.` +- `Compile evidence was judged under policy @; this validator applies closed-world-externals@1.` +- `Compile evidence does not cover compiled file "".` +- `Compile evidence for "" describes different bytes.` +- `Compile evidence names "", which the manifest does not list as a compiled file.` +- `Compile evidence for "" records "" as a built-in; it is not one.` +- `Compile evidence for "" records sibling "", which the artifact does not contain.` +- `Compile evidence for "" records "" as an external; a view inlines every module it loads.` + 每条诊断都是一份结构化记录:稳定的 `AB` 代码、一个严重级别、一条消息,通常还有 `sourcePath` 与一条 `recovery` 提示。由诊断把关的命令——`build`、`prepack`、`validate`、`doctor`、`install` 与 `dev`——只有 存在 error 级诊断时才以非零退出;warning 与 info 绝不会为构建、校验或 dev 重建把关。`eval` 与 `inspect` diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 551a63594..fc60b198c 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -47,9 +47,13 @@ artifact/ ├── INSTALL.md # 选中了任一内置宿主时 ├── install.mjs # 选中了 cursor 或 portable 时 ├── agent-bundle.manifest.json # 所选投影 + 来源信息 +├── agent-bundle.compile-evidence.json # 每个已编译文件的编译器记录 └── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 ``` +`agent-bundle.compile-evidence.json` 是编译器对每个已编译(`bundle`)文件的记录; +`validate --artifact` 把已列入清单的记录对照文件表复核(`AB6039`)。 + 宿主清单位于根目录下各自的点目录中。`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 是共享的, 只输出**一次**——没有逐宿主副本。根目录下不会出现其他任何东西:没有生成的 `AGENTS.md`,也没有 `hooks/hooks-cursor.json`,也没有 `web/` 目录。已配置 MCP App 的浏览器宿主作为框架拥有的 `web` 命令 From 63dc1f1e606765479a5ed4e74f3c5dbcf17b3053 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:27:26 -0700 Subject: [PATCH 09/11] fix: manage derived state across lifecycle (#642) * fix: manage derived state across lifecycle * fix: preserve state roots through keep data * fix: align web data root derivation * fix: keep lifecycle helpers runtime-free --- .changeset/641-state-lifecycle.md | 5 + docs/diagnostics.md | 14 ++- packages/agent-bundle/README.md | 18 +-- .../adapters/capabilities/codex-0.147.0.json | 2 +- packages/agent-bundle/src/cli.ts | 12 ++ packages/agent-bundle/src/install/doctor.ts | 117 ++++++++++++++---- packages/agent-bundle/src/install/receipt.ts | 26 ++++ .../agent-bundle/src/install/state-root.ts | 98 +++++++++++++++ packages/agent-bundle/src/install/surface.ts | 66 ++++++++-- .../agent-bundle/src/install/uninstall.ts | 114 ++++++++++++----- packages/agent-bundle/src/web-host/launch.ts | 8 +- packages/agent-bundle/tests/doctor.test.ts | 62 +++++++++- .../tests/host-install-proof.test.ts | 2 +- .../tests/packed-readonly-state-root.test.ts | 61 ++++++--- packages/agent-bundle/tests/uninstall.test.ts | 73 +++++++++-- .../agent-bundle/tests/web-launch.test.ts | 15 ++- .../en/guide/distribution/installation.mdx | 10 +- website/docs/en/reference/cli.mdx | 14 ++- .../zh/guide/distribution/installation.mdx | 8 +- website/docs/zh/reference/cli.mdx | 12 +- 20 files changed, 610 insertions(+), 127 deletions(-) create mode 100644 .changeset/641-state-lifecycle.md create mode 100644 packages/agent-bundle/src/install/state-root.ts diff --git a/.changeset/641-state-lifecycle.md b/.changeset/641-state-lifecycle.md new file mode 100644 index 000000000..98e3cf57d --- /dev/null +++ b/.changeset/641-state-lifecycle.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `uninstall --purge-data` remove the effective framework state root and make `doctor` report its source, existence, writability, `AB7316` permission failures, and `AB7332` retained pre-#640 state (#641). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8d1bb543d..44bcaeaaa 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -38,7 +38,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | -| `AB7300`–`AB7331` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), and the operator `.env` layer of an installed pack (`AB7331`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `AB7300`–`AB7332` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), and retained pre-#640 state (`AB7332`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | @@ -1334,7 +1334,7 @@ SQLite lock or shared-memory files. | Code | Severity | Trigger | | --- | --- | --- | -| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | +| `AB7316` | warning | An installed bundle's effective or legacy state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | ## Read-only Doctor operator env inventory (`AB7331`) @@ -1349,6 +1349,16 @@ diagnostic. | --- | --- | --- | | `AB7331` | info / warning | Info: an installed copy (or the `--from` bundle) carries `.env` or `.env.local` at its plugin root; the message names the file and its variable count. Warning: the file exists but cannot be read, so the pack's shells skip it at launch — repair its permissions and rerun Doctor. | +## Read-only Doctor legacy state (`AB7332`) + +Doctor resolves each installed copy's effective framework state root from its +canonical code root and declared environment. It reports that root's source, +existence, and writability separately from the pre-#640 in-tree location. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB7332` | info | `/state` still exists while the installed artifact resolves framework state elsewhere. Move any state that must be retained, or use `uninstall --purge-data --confirm-purge` to remove both roots. | + ## Read-only runtime identity introspection (`AB7317`–`AB7318`) | Code | Severity | Trigger | diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 83ea964a5..e0165d906 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -136,8 +136,8 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | | `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | -| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; durable `state/` is kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | -| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | +| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; the effective framework state root, web-data, and legacy `state/` are kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | +| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, effective and legacy state roots with existence and writability, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | | `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | @@ -332,12 +332,14 @@ receipt and remove exactly what it owns: Durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the `PLUGIN_DATA` directory the receipt -records) is kept by default; `--purge-data --confirm-purge` removes it -(`AB7008` without the confirmation). The typed `data.outcome` is honest per host: Cursor `kept` / -`purged` / `absent`; Claude `retained-by-host` (Claude 2.1.257 orphans the -cached copy for its ~14-day grace period; a purge also removes `state/` and -`plugins/data//`); Codex `removed-by-host` / `unavailable` (codex-cli -0.147.0 deletes the cached tree on `plugin remove` and has no keep-data option). +records), effective framework state, and web-data are kept by default; +`--purge-data --confirm-purge` removes them (`AB7008` without the confirmation). +The typed `data.outcome` is honest per host: Cursor `kept` / `purged` / `absent`; +Claude `retained-by-host` (Claude 2.1.257 orphans the cached copy for its ~14-day +grace period; a purge also removes external framework state, web-data, `state/`, +and `plugins/data//`); Codex reports external state as `kept` / `purged`, +while in-tree `state/` is removed by the host and cannot be kept (codex-cli +0.147.0 has no keep-data option). `--plan` reports the same exact paths and host verbs without opening a writer. A missing receipt (`AB7009`) or an owned-content, version, or `HEAD` mismatch (`AB7007`) is refused unless `--force`; a receipt or manifest naming another diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index f64de6692..33368fe03 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -25,7 +25,7 @@ }, "keepData": { "evidence": [ - "2026-09-03: `codex plugin remove` deletes the cached plugin tree, `state/` included, and codex-cli 0.147.0 documents no keep-data option; the uninstaller reports data outcome `unavailable` for --keep-data and `removed-by-host` for --purge-data instead of pretending to preserve state." + "2026-09-05: `codex plugin remove` deletes the cached plugin tree, `state/` included, and codex-cli 0.147.0 documents no keep-data option. Agent Bundle can preserve external framework state and web-data, but cannot preserve in-tree state through a Codex uninstall." ], "reason": "Codex owns the cached tree and removes it wholesale; durable state cannot be preserved through a Codex uninstall.", "state": "unavailable" diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index f30574baa..7acc5efee 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -471,6 +471,18 @@ const humanDoctor = (result: DoctorReport): string => { host.bundle?.durableState, ].filter((report): report is DoctorDurableStateReport => report !== undefined); const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()]; + for (const report of uniqueReports) { + out.push( + ` state root: ${report.directory} (${report.exists ? 'exists' : 'missing'}, ` + + `${report.writable ? 'writable' : 'not writable'}, ${report.stateSource})\n`, + ); + } + const legacyReports = host.inventory.findings + .map((finding) => finding.legacyDurableState) + .filter((report): report is DoctorDurableStateReport => report !== undefined); + for (const report of [...new Map(legacyReports.map((entry) => [entry.directory, entry])).values()]) { + out.push(` legacy state: ${report.directory} (exists, ${report.writable ? 'writable' : 'not writable'})\n`); + } if (uniqueReports.length > 0) { const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 9db99d4ea..70f90950a 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1,4 +1,5 @@ -import { lstat, readFile, readdir } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { access, lstat, readFile, readdir } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -71,6 +72,7 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { resolveInstalledStateRoot } from './state-root.ts'; export type DoctorHost = InstallHost; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -130,6 +132,8 @@ export interface DoctorFinding { /** Git commit of a staged Cursor marketplace repository. */ readonly commit?: string; readonly durableState?: DoctorDurableStateReport; + /** Pre-#640 `/state`, reported separately from the effective state root. */ + readonly legacyDurableState?: DoctorDurableStateReport; /** The operator `.env` layer the installed pack's shells read at launch (#469); names and counts only, never values. */ readonly operatorEnv?: DoctorOperatorEnvReport; /** @@ -243,12 +247,15 @@ export interface DoctorOperatorEnvReport { export interface DoctorDurableStateReport { readonly diagnostics: readonly Diagnostic[]; readonly directory: string; + readonly exists: boolean; readonly findings: readonly DoctorDurableStateStore[]; + readonly stateSource: 'derived' | 'legacy' | 'native'; readonly status: 'known' | 'warnings'; readonly summary: { readonly bytes: number; readonly stores: number; }; + readonly writable: boolean; } export interface DoctorInventory { @@ -531,6 +538,9 @@ const freezeInventory = ( const durableStateReport = ( directory: string, + exists: boolean, + writable: boolean, + stateSource: DoctorDurableStateReport['stateSource'], findings: readonly DoctorDurableStateStore[], diagnostics: readonly Diagnostic[], ): DoctorDurableStateReport => { @@ -538,12 +548,15 @@ const durableStateReport = ( return Object.freeze({ diagnostics: frozenDiagnostics, directory, + exists, findings: Object.freeze(findings.map((finding) => Object.freeze({ ...finding }))), + stateSource, status: frozenDiagnostics.length === 0 ? 'known' : 'warnings', summary: Object.freeze({ bytes: findings.reduce((total, finding) => total + finding.bytes, 0), stores: findings.length, }), + writable, }); }; @@ -623,15 +636,15 @@ const remnantDiagnostic = async (subject: string, path: string, receipt: Install }; const inspectDurableState = async ( - pluginRoot: string, + directory: string, + stateSource: DoctorDurableStateReport['stateSource'], target?: DoctorHost, -): Promise => { - const directory = join(pluginRoot, 'state'); +): Promise => { let entries: readonly string[]; try { entries = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); } catch (error) { - if (isErrno(error, 'ENOENT')) return undefined; + if (isErrno(error, 'ENOENT')) return durableStateReport(directory, false, false, stateSource, [], []); const diagnostics = [diagnostic( 'AB7316', `Durable state directory ${JSON.stringify(directory)} could not be read.`, @@ -639,10 +652,23 @@ const inspectDurableState = async ( 'warning', target, )]; - return durableStateReport(directory, [], diagnostics); + return durableStateReport(directory, true, false, stateSource, [], diagnostics); } const diagnostics: Diagnostic[] = []; + const writable = await access(directory, constants.W_OK).then( + () => true, + () => false, + ); + if (!writable) { + diagnostics.push(diagnostic( + 'AB7316', + `Durable state directory ${JSON.stringify(directory)} is not writable.`, + 'Repair directory permissions before running the plugin; Doctor never opens or repairs state databases.', + 'warning', + target, + )); + } const findings: DoctorDurableStateStore[] = []; for (const file of entries.filter((entry) => entry.endsWith('.sqlite'))) { const path = join(directory, file); @@ -676,7 +702,41 @@ const inspectDurableState = async ( )); } } - return durableStateReport(directory, findings, diagnostics); + return durableStateReport(directory, true, writable, stateSource, findings, diagnostics); +}; + +const inspectInstalledDurableState = async ( + pluginRoot: string, + host: DoctorHost, + environment: Readonly, + home: string, + receipt?: InstallReceipt, +): Promise<{ + readonly diagnostics: readonly Diagnostic[]; + readonly effective: DoctorDurableStateReport; + readonly legacy?: DoctorDurableStateReport; +}> => { + const resolved = receipt?.stateRoot ?? + await resolveInstalledStateRoot(pluginRoot, host, environment, home); + const effective = await inspectDurableState(resolved.root, resolved.source, host); + const legacyRoot = join(pluginRoot, 'state'); + if (legacyRoot === resolved.root) { + return { diagnostics: effective.diagnostics, effective }; + } + const legacy = await inspectDurableState(legacyRoot, 'legacy', host); + if (!legacy.exists) return { diagnostics: effective.diagnostics, effective }; + const legacyDiagnostic = diagnostic( + 'AB7332', + `Legacy durable state remains at ${JSON.stringify(legacyRoot)} while this install resolves framework state to ${JSON.stringify(resolved.root)}.`, + 'Run `agent-bundle uninstall --purge-data --confirm-purge` for this install to remove both roots, or move required pre-#640 data before deleting the legacy directory.', + 'info', + host, + ); + return { + diagnostics: freezeDiagnostics([...effective.diagnostics, ...legacy.diagnostics, legacyDiagnostic]), + effective, + legacy, + }; }; /** @@ -939,6 +999,7 @@ const stagingGit = (run: DoctorCommandRunner): CursorStagingGit => async (args, const cursorInventory = async ( home: string, + environment: Readonly, available: boolean, git: CursorStagingGit, platform: NodeJS.Platform, @@ -1034,11 +1095,12 @@ const cursorInventory = async ( const stateOnly = await isRuntimeStateRemnant(path); const remnant = stateOnly || (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)); if (remnant) { - const durableState = await inspectDurableState(path, 'cursor'); - if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home, remnantReceipt); + diagnostics.push(...durableState.diagnostics); diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt)); findings.push({ - ...(durableState === undefined ? {} : { durableState }), + durableState: durableState.effective, + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), entry, ...(remnantReceipt === undefined ? {} : { name: remnantReceipt.plugin, receipt: receiptSummary(remnantReceipt), version: remnantReceipt.version }), path, @@ -1089,8 +1151,8 @@ const cursorInventory = async ( } diagnostics.push(...staticDiagnostics); if (launch !== undefined) diagnostics.push(...launch.diagnostics); - const durableState = await inspectDurableState(path, 'cursor'); - if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home); + diagnostics.push(...durableState.diagnostics); const operatorEnv = await inspectOperatorEnv(path, 'cursor'); diagnostics.push(...operatorEnv.diagnostics); const hooks = manifest.manifest === cursorManifestCandidates[0] @@ -1108,7 +1170,8 @@ const cursorInventory = async ( diagnostics.push(migratedReceiptDiagnostic('cursor', join(path, installReceiptFile), receipt)); } findings.push({ - ...(durableState === undefined ? {} : { durableState }), + durableState: durableState.effective, + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), entry, ...(hooks === undefined ? {} : { hooks: hooks.registration }), operatorEnv, @@ -1213,12 +1276,12 @@ const readWebSurface = async ( * carry `pluginId`/`version` and the pinned cache layout supplies the path). * An unusable listing is reported honestly as unknown (`AB7303`). */ -const publicHostInventory = ( +const publicHostInventory = async ( host: Exclude, listing: PublicHostListing, environment: Readonly, home: string, -): { readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory } => { +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory }> => { const unknown = (detail: string) => ({ diagnostics: freezeDiagnostics([diagnostic( 'AB7303', @@ -1239,6 +1302,7 @@ const publicHostInventory = ( return unknown('not JSON'); } const findings: DoctorFinding[] = []; + const diagnostics: Diagnostic[] = []; if (host === 'claude') { if (!Array.isArray(document)) return unknown('not an array'); for (const row of document) { @@ -1257,12 +1321,16 @@ const publicHostInventory = ( // `enabled: false` is a copy the user switched off (`claude plugin disable`): installed, but no // hooks, MCP servers, or skills reach a session until it is enabled again (#476). const enabled = typeof row['enabled'] === 'boolean' ? row['enabled'] : undefined; + const durableState = await inspectInstalledDurableState(row['installPath'], host, environment, home); + diagnostics.push(...durableState.diagnostics); findings.push({ + durableState: durableState.effective, ...(enabled === undefined ? {} : { enabled }), entry: `${row['id']} (${row['scope']})`, ...(errors.length === 0 ? {} : { errors }), name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')), path: row['installPath'], + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed', version: row['version'], }); @@ -1277,16 +1345,21 @@ const publicHostInventory = ( const separator = row['pluginId'].indexOf('@'); const name = separator === -1 ? row['pluginId'] : row['pluginId'].slice(0, separator); const marketplace = separator === -1 ? '' : row['pluginId'].slice(separator + 1); + const path = join(publicHostCacheRoot(host, environment, home), marketplace, name, row['version']); + const durableState = await inspectInstalledDurableState(path, host, environment, home); + diagnostics.push(...durableState.diagnostics); findings.push({ + durableState: durableState.effective, entry: row['pluginId'], name, - path: join(publicHostCacheRoot(host, environment, home), marketplace, name, row['version']), + path, + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: 'installed', version: row['version'], }); } } - return { diagnostics: Object.freeze([]), inventory: freezeInventory('known', findings) }; + return { diagnostics: freezeDiagnostics(diagnostics), inventory: freezeInventory('known', findings) }; }; const malformedBundle = ( @@ -2656,10 +2729,10 @@ const doctorHost = async ( return pending; }; const inventoried = host === 'cursor' - ? await cursorInventory(home, probed.probe.status === 'available', git, options.platform ?? process.platform) + ? await cursorInventory(home, environment, probed.probe.status === 'available', git, options.platform ?? process.platform) : probed.probe.status !== 'available' ? { diagnostics: Object.freeze([]), inventory: freezeInventory('skipped') } - : publicHostInventory(host, listing, environment, home); + : await publicHostInventory(host, listing, environment, home); const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics]; // Store receipts are lifecycle evidence Agent Bundle itself wrote, so the store is inventoried from // the filesystem whether or not the host can be probed: malformed and migrated receipts are always @@ -2699,8 +2772,8 @@ const doctorHost = async ( if (checked.finding.lifecycle !== undefined) { diagnostics.push(lifecycleDiagnostic(host, identity.name, identity.version, checked.finding.lifecycle)); } - const durableState = await inspectDurableState(identity.bundleRoot, host); - if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const durableState = await inspectDurableState(join(identity.bundleRoot, 'state'), 'legacy', host); + diagnostics.push(...durableState.diagnostics); const operatorEnv = await inspectOperatorEnv(identity.bundleRoot, host); diagnostics.push(...operatorEnv.diagnostics); bundle = Object.freeze({ @@ -2708,7 +2781,7 @@ const doctorHost = async ( ...(staticDiagnostics.some((entry) => entry.severity === 'error') ? { state: 'corrupt' as const } : {}), - ...(durableState === undefined ? {} : { durableState }), + ...(durableState.exists ? { durableState } : {}), operatorEnv, }); } catch (error) { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 2eb44c450..6c57360ff 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -158,9 +158,16 @@ export interface InstallReceipt { /** Host registrations the installer performed, in the order it performed them. */ readonly registrations: readonly InstallRegistration[]; readonly scope: InstallReceiptScope; + /** Effective framework state root retained by a Cursor `--keep-data` uninstall. */ + readonly stateRoot?: { + readonly root: string; + readonly source: 'derived' | 'native'; + }; /** When this receipt was last written (install or replacement); `installedAt` is the first install. */ readonly updatedAt: string; readonly version: string; + /** Derived web-data root retained by a Cursor `--keep-data` uninstall. */ + readonly webDataRoot?: string; } /** The lifecycle identity every receipt writer supplies; inventory and timestamps come from the write. */ @@ -491,6 +498,19 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { return undefined; } const cursorExpansion = readCursorExpansion(record['cursorExpansion']); + const stateRootRecord = record['stateRoot']; + const stateRoot = stateRootRecord !== undefined && + stateRootRecord !== null && + typeof stateRootRecord === 'object' && + !Array.isArray(stateRootRecord) && + typeof (stateRootRecord as Record)['root'] === 'string' && + ((stateRootRecord as Record)['source'] === 'derived' || + (stateRootRecord as Record)['source'] === 'native') + ? Object.freeze({ + root: (stateRootRecord as Record)['root'] as string, + source: (stateRootRecord as Record)['source'] as 'derived' | 'native', + }) + : undefined; const base = { contentHash: record['contentHash'], ...(cursorExpansion === undefined ? {} : { cursorExpansion }), @@ -500,7 +520,9 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { host: record['host'], installedAt: record['installedAt'], plugin: record['plugin'], + ...(stateRoot === undefined ? {} : { stateRoot }), version: record['version'], + ...(typeof record['webDataRoot'] === 'string' ? { webDataRoot: record['webDataRoot'] } : {}), } as const; if (format === legacyInstallReceiptFormat) { return Object.freeze({ @@ -571,6 +593,8 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { readonly cursorExpansion?: InstallReceiptCursorExpansion; readonly directories?: readonly string[]; readonly inventory: TreeInventory; + readonly stateRoot?: InstallReceipt['stateRoot']; + readonly webDataRoot?: string; }): InstallReceipt => { const installedAt = options.installedAt ?? new Date().toISOString(); return Object.freeze({ @@ -587,8 +611,10 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), registrations: Object.freeze(options.registrations.map((registration) => Object.freeze({ ...registration }))), scope: options.scope, + ...(options.stateRoot === undefined ? {} : { stateRoot: Object.freeze({ ...options.stateRoot }) }), updatedAt: options.updatedAt ?? installedAt, version: options.version, + ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }), }); }; diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts new file mode 100644 index 000000000..155fff6c8 --- /dev/null +++ b/packages/agent-bundle/src/install/state-root.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { readFile, realpath } from 'node:fs/promises'; +import { basename, isAbsolute, join, resolve } from 'node:path'; + +import { isErrno } from '../core/errors.ts'; +import { pluginStateRootEnvAnchor } from '../core/types.ts'; +import { webPluginDataRoot } from '../web-host/launch.ts'; +import type { InstallHost } from './install.ts'; + +export interface InstalledStateRoot { + readonly root: string; + readonly source: 'derived' | 'native'; +} + +const manifestCandidates = (host: InstallHost): readonly string[] => { + switch (host) { + case 'claude': + return ['.mcp.json']; + case 'codex': + return ['.codex-plugin/mcp.json']; + case 'cursor': + return ['.cursor-plugin/mcp.json', 'mcp.json']; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown install host ${String(exhaustive)}.`); + } + } +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +// The CLI cannot load the optional React runtime. Uninstall tests pin this spelling against +// `userDataStateRoot` from @agent-bundle/runtime for real installed roots. +const installedUserDataStateRoot = ( + canonicalRoot: string, + environment: Readonly, + home: string, +): string => { + const xdgStateHome = environment.XDG_STATE_HOME ?? ''; + const stateHome = isAbsolute(xdgStateHome) + ? join(xdgStateHome, 'agent-bundle') + : join(home, '.agent-bundle', 'state'); + const digest = createHash('sha256').update(canonicalRoot).digest('hex').slice(0, 16); + const name = basename(canonicalRoot); + return join(stateHome, safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`); +}; + +const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise => { + for (const relativePath of manifestCandidates(host)) { + let document: unknown; + try { + document = JSON.parse(await readFile(join(pluginRoot, relativePath), 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) continue; + throw error; + } + if (!isRecord(document) || !isRecord(document['mcpServers'])) continue; + for (const server of Object.values(document['mcpServers'])) { + if (!isRecord(server) || !isRecord(server['env'])) continue; + const declared = server['env'][pluginStateRootEnvAnchor]; + if (typeof declared !== 'string' || declared.trim() === '') continue; + const expanded = declared + .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${PLUGIN_ROOT}', pluginRoot); + if (/\$\{[^}]*\}/u.test(expanded)) continue; + return isAbsolute(expanded) ? resolve(expanded) : resolve(pluginRoot, expanded); + } + } + return undefined; +}; + +export const resolveInstalledStateRoot = async ( + pluginRoot: string, + host: InstallHost, + environment: Readonly, + home: string, +): Promise => { + const canonicalRoot = await realpath(pluginRoot).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return resolve(pluginRoot); + throw error; + }); + const fromManifest = await declaredStateRoot(canonicalRoot, host); + const inherited = environment[pluginStateRootEnvAnchor] ?? ''; + const expandedInherited = inherited.trim() === '' || /\$\{[^}]*\}/u.test(inherited) + ? undefined + : isAbsolute(inherited) ? resolve(inherited) : resolve(canonicalRoot, inherited); + const declared = fromManifest ?? expandedInherited; + return Object.freeze(declared === undefined + ? { root: installedUserDataStateRoot(canonicalRoot, environment, home), source: 'derived' as const } + : { root: declared, source: 'native' as const }); +}; + +export const installedWebDataRoot = (pluginRoot: string, home: string): string => + webPluginDataRoot(pluginRoot, home); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 2a4f093a9..d7d449a12 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -361,6 +361,40 @@ const cursorUninstallerSource = (): readonly string[] => [ ' console.log(`${label} ${paths.length} ${paths.length === 1 ? "entry" : "entries"}:`);', ' for (const path of paths) console.log(` ${path}`);', '};', + 'const runtimeStateRoots = async () => {', + ' const canonical = await realpath(destination);', + " const inherited = process.env.AGENT_BUNDLE_STATE_ROOT ?? '';", + " const inheritedStateRoot = inherited.trim() === '' || /\\$\\{[^}]*\\}/u.test(inherited)", + ' ? undefined', + ' : isAbsolute(inherited) ? resolve(inherited) : resolve(canonical, inherited);', + ' let declared;', + " for (const manifest of ['.cursor-plugin/mcp.json', 'mcp.json']) {", + ' let document;', + " try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }", + " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }", + " const servers = document !== null && typeof document === 'object' && !Array.isArray(document) ? document.mcpServers : undefined;", + " if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) continue;", + ' for (const server of Object.values(servers)) {', + " const env = server !== null && typeof server === 'object' && !Array.isArray(server) ? server.env : undefined;", + " const value = env !== null && typeof env === 'object' && !Array.isArray(env) ? env.AGENT_BUNDLE_STATE_ROOT : undefined;", + " if (typeof value !== 'string' || value.trim() === '') continue;", + " const expanded = value.replaceAll('${CURSOR_PLUGIN_ROOT}', canonical).replaceAll('${PLUGIN_ROOT}', canonical);", + " if (!/\\$\\{[^}]*\\}/u.test(expanded)) declared = isAbsolute(expanded) ? resolve(expanded) : resolve(canonical, expanded);", + ' if (declared !== undefined) break;', + ' }', + ' if (declared !== undefined) break;', + ' }', + " const xdg = process.env.XDG_STATE_HOME ?? '';", + " const stateHome = isAbsolute(xdg) ? join(xdg, 'agent-bundle') : join(homedir(), '.agent-bundle', 'state');", + " const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 16);", + " const name = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(canonical)) ? basename(canonical) : 'plugin';", + ' const segment = `${name}-${digest}`;', + ' const explicitStateRoot = declared ?? inheritedStateRoot;', + " const webCanonical = resolve(destination);", + " const webDigest = createHash('sha256').update(webCanonical).digest('hex').slice(0, 16);", + " const webName = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(webCanonical)) ? basename(webCanonical) : 'plugin';", + " return [explicitStateRoot ?? join(stateHome, segment), join(canonical, 'state'), join(homedir(), '.agent-bundle', 'web-data', `${webName}-${webDigest}`), explicitStateRoot === undefined ? 'derived' : 'native'];", + '};', '', "if (uninstall && mode === 'local') {", ' const notInstalled = () => { console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`); process.exit(0); };', @@ -421,14 +455,25 @@ const cursorUninstallerSource = (): readonly string[] => [ ' files.push(path);', ' }', ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', - " const stateDirectory = join(destination, 'state');", + " const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();", + ' const effectiveStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', + ' const stateSource = receipt?.stateRoot?.source ?? resolvedStateSource;', + ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', + ' const externalDataPaths = [];', + ' for (const path of [effectiveStateDirectory, webDataDirectory]) {', + ' if (path === stateDirectory) continue;', + ' let metadata;', + " try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", + ' if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupported(path);', + ' externalDataPaths.push(path);', + ' }', ' let stateMetadata;', " try { stateMetadata = await lstat(stateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", " if (stateMetadata !== undefined && (stateMetadata.isSymbolicLink() || !stateMetadata.isDirectory())) throw unsupported('state');", ' // A state/ holding nothing is not durable state: pruned like an installer-created directory instead of kept as a remnant.', ' const emptyState = stateMetadata !== undefined && (await readdir(stateDirectory)).length === 0 ? stateDirectory : undefined;', - ' const dataPaths = stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory];', - " const dataKinds = stateMetadata === undefined || emptyState !== undefined ? [] : ['state/ (state kernel, notices journal)'];", + ' const dataPaths = [...externalDataPaths, ...(stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory])];', + " const dataKinds = [...externalDataPaths.map((path) => path === effectiveStateDirectory ? `framework state root ${path}` : `web-data directory ${path}`), ...(stateMetadata === undefined || emptyState !== undefined ? [] : ['legacy state/ (state kernel, notices journal)'])];", ' // The receipt\'s cursorExpansion records the PLUGIN_DATA directory this installer created for the copy (spec 9.1). Only', ' // the directory at this home\'s own plugin-data location is receipt-owned; a written one is durable state (kept or', ' // purged like state/), an empty one is an installer-created directory that is pruned, a recorded path elsewhere is left alone.', @@ -459,9 +504,9 @@ const cursorUninstallerSource = (): readonly string[] => [ ' : purgeData', " ? `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}`", " : `Durable runtime state — ${dataKinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`;", - ' // A written PLUGIN_DATA kept by --keep-data lives outside the plugin root, so the root stays (with a remnant receipt', - ' // carrying the expansion) to keep that data receipt-owned for a later purge.', - ' const keepRoot = !purgeData && dataPaths.includes(pluginData);', + ' // External state kept by --keep-data needs the remnant receipt and canonical install path so a later purge', + ' // derives and removes the same root even though no plugin content remains.', + ' const keepRoot = !purgeData && dataPaths.some((path) => path !== stateDirectory);', ' const directories = [', ' ...ownedDirectories.map((directory) => join(destination, directory)),', ' ...(keepRoot ? [] : [destination]),', @@ -530,6 +575,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // A kept PLUGIN_DATA directory stays receipt-owned through the remnant\'s expansion record.', ' ...(keepRoot && receipt?.cursorExpansion !== undefined ? { cursorExpansion: receipt.cursorExpansion } : {}),', ' directories: [], hostDirectories, installedAt: receipt?.installedAt, registrations: [],', + ' ...(keepRoot ? { stateRoot: { root: effectiveStateDirectory, source: stateSource }, webDataRoot: webDataDirectory } : {}),', ' }));', ' console.log(`Remnant receipt: ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', ' }', @@ -663,7 +709,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "import { createHash, randomUUID } from 'node:crypto';", "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, dirname, join, relative, resolve, sep } from 'node:path';", + "import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, @@ -879,7 +925,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", " typeof value.host !== 'string' || typeof value.contentHash !== 'string' || typeof value.installedAt !== 'string' ||", ' !Array.isArray(value.files) || !value.files.every(safeRelative) ||', - ' !Array.isArray(value.directories) || !value.directories.every(safeRelative)) return undefined;', + ' !Array.isArray(value.directories) || !value.directories.every(safeRelative) ||', + " (value.stateRoot !== undefined && (value.stateRoot === null || typeof value.stateRoot !== 'object' || Array.isArray(value.stateRoot) || typeof value.stateRoot.root !== 'string' || !['derived', 'native'].includes(value.stateRoot.source))) ||", + " (value.webDataRoot !== undefined && typeof value.webDataRoot !== 'string')) return undefined;", ' if (value.format === legacyReceiptFormat) {', " return { ...value, format: receiptFormat, hostDirectories: [], migratedFrom: legacyReceiptFormat, mode: 'local',", " registrations: [{ kind: 'cursor-local-plugin' }], scope: 'user', updatedAt: value.installedAt };", @@ -1005,8 +1053,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' plugin: pluginName,', " registrations: options.registrations ?? [{ kind: 'cursor-local-plugin' }],", " scope: 'user',", + ' ...(options.stateRoot === undefined ? {} : { stateRoot: options.stateRoot }),', ' updatedAt: now,', ' version: pluginVersion,', + ' ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }),', " }, null, 2) + '\\n';", '};', '', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index c88154922..407cce7a0 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -61,6 +61,7 @@ import { type InstallRegistration, type StoredInstallReceipt, } from './receipt.ts'; +import { installedWebDataRoot, type InstalledStateRoot, resolveInstalledStateRoot } from './state-root.ts'; /** * `agent-bundle uninstall ` (#101): the receipt-owned reverse of @@ -438,11 +439,11 @@ interface CursorLocalData { readonly emptyPluginData?: string; /** A `state/` directory holding nothing: not durable state, so it is pruned rather than kept alive as a remnant. */ readonly emptyState?: string; - /** Whether any durable state (state/ or a written PLUGIN_DATA) exists. */ + /** Whether any durable state root exists. */ readonly present: boolean; - /** A written `PLUGIN_DATA` directory kept by `--keep-data`: it lives outside the plugin root, so the root must survive to carry it. */ - readonly retainedPluginData?: string; readonly report: UninstallDataReport; + readonly stateRoot: InstalledStateRoot; + readonly webDataRoot: string; } const cursorLocalData = async ( @@ -451,11 +452,23 @@ const cursorLocalData = async ( receipt: InstallReceipt | undefined, cursorRoot: string, plugin: string, + environment: Readonly, + home: string, ): Promise => { const stateDirectory = join(destination, 'state'); + const effectiveState = receipt?.stateRoot ?? + await resolveInstalledStateRoot(destination, 'cursor', environment, home); + const webData = receipt?.webDataRoot ?? installedWebDataRoot(destination, home); const paths: string[] = []; const kinds: string[] = []; let emptyState: string | undefined; + if ( + effectiveState.root !== stateDirectory && + await realDirectory(effectiveState.root, 'cursor') !== undefined + ) { + paths.push(effectiveState.root); + kinds.push(`${effectiveState.source} framework state root ${effectiveState.root}`); + } if (await realDirectory(stateDirectory, 'cursor') !== undefined) { if ((await readdir(stateDirectory)).length === 0) { emptyState = stateDirectory; @@ -464,6 +477,10 @@ const cursorLocalData = async ( kinds.push('state/ (state kernel, notices journal)'); } } + if (await realDirectory(webData, 'cursor') !== undefined) { + paths.push(webData); + kinds.push(`web-data directory ${webData}`); + } // The receipt's cursorExpansion records the PLUGIN_DATA directory the installer created for this copy; only the // directory at this home's own plugin-data location is receipt-owned — a recorded path elsewhere is left alone. const recorded = receipt?.cursorExpansion?.pluginData; @@ -500,9 +517,10 @@ const cursorLocalData = async ( paths: Object.freeze([]), policy, }), + stateRoot: effectiveState, + webDataRoot: webData, }; } - const retainedPluginData = policy === 'purge' ? undefined : paths.find((path) => path === expected); return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), @@ -515,7 +533,8 @@ const cursorLocalData = async ( paths: Object.freeze(paths), policy, }), - ...(retainedPluginData === undefined ? {} : { retainedPluginData }), + stateRoot: effectiveState, + webDataRoot: webData, }; }; @@ -535,6 +554,8 @@ const uninstallCursorLocal = async ( version: identity.version, } as const; const cursorRoot = join(options.home ?? homedir(), '.cursor'); + const home = options.home ?? homedir(); + const environment = options.environment ?? process.env; const destination = join(cursorRoot, 'plugins', 'local', identity.plugin); const receiptPath = join(destination, installReceiptFile); const notInstalled = (): UninstallResult => Object.freeze({ @@ -558,7 +579,15 @@ const uninstallCursorLocal = async ( const owned = new Set(ownership.files); // A symlinked ancestor would let a leaf-only delete reach outside the plugin root: refused before any change. await assertRealAncestors(destination, ownership.files); - const data = await cursorLocalData(destination, policy, ownership.receipt, cursorRoot, identity.plugin); + const data = await cursorLocalData( + destination, + policy, + ownership.receipt, + cursorRoot, + identity.plugin, + environment, + home, + ); const files: string[] = []; for (const file of ownership.files) { const path = join(destination, file); @@ -573,9 +602,10 @@ const uninstallCursorLocal = async ( files.push(path); } if (ownership.receipt !== undefined || await exists(receiptPath)) files.push(receiptPath); - // A written PLUGIN_DATA directory kept by --keep-data lives outside the plugin root, so the root stays (with a - // remnant receipt carrying the expansion) to keep that data receipt-owned for a later purge. - const keepRoot = data.retainedPluginData !== undefined; + // External state kept by --keep-data needs the remnant receipt and canonical install path so a later purge can + // derive and remove the same root even though no plugin content remains. + const keepRoot = policy === 'keep' && + data.report.paths.some((path) => path !== join(destination, 'state')); const pluginDataRecorded = ownership.receipt?.cursorExpansion?.pluginData === cursorPluginDataDirectory(cursorRoot, identity.plugin); const directoryCandidates = [ ...ownership.directories.map((directory) => join(destination, directory)), @@ -653,7 +683,8 @@ const uninstallCursorLocal = async ( // directory instead of calling it corrupt. A reinstall fills it back in as an `installed`. await writeInstallReceipt(destination, createInstallReceipt({ // A kept PLUGIN_DATA directory stays receipt-owned through the remnant's expansion record. - ...(ownership.receipt?.cursorExpansion === undefined || data.retainedPluginData === undefined + ...(ownership.receipt?.cursorExpansion === undefined || + !data.report.paths.includes(ownership.receipt.cursorExpansion.pluginData) ? {} : { cursorExpansion: ownership.receipt.cursorExpansion }), host: 'cursor', @@ -664,6 +695,7 @@ const uninstallCursorLocal = async ( plugin: identity.plugin, registrations: [], scope: 'user', + ...(keepRoot ? { stateRoot: data.stateRoot, webDataRoot: data.webDataRoot } : {}), updatedAt: new Date().toISOString(), version: ownership.receipt?.version ?? identity.version, })); @@ -1069,26 +1101,39 @@ const publicHostData = async ( hostRoot: string, id: string, sharedWith: readonly string[] | 'unknown', + environment: Readonly, + home: string, ): Promise => { - if (host === 'codex') { - return Object.freeze({ - detail: policy === 'purge' - ? '`codex plugin remove` deletes the cached plugin tree, state/ included; nothing further to purge.' - : '`codex plugin remove` deletes the cached plugin tree, state/ included; Codex 0.147.0 exposes no keep-data option, so durable state cannot be preserved through a Codex uninstall.', - outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', - paths: Object.freeze(entry === undefined ? [] : [join(entry.installPath, 'state')]), - policy, - }); - } const paths: string[] = []; - if (entry !== undefined && await realDirectory(join(entry.installPath, 'state'), host) !== undefined) { - paths.push(join(entry.installPath, 'state')); + if (entry !== undefined) { + const legacyStateRoot = join(entry.installPath, 'state'); + const effectiveState = await resolveInstalledStateRoot(entry.installPath, host, environment, home); + const candidates = [ + effectiveState.root, + ...(host === 'codex' && policy === 'keep' ? [] : [legacyStateRoot]), + installedWebDataRoot(entry.installPath, home), + ]; + for (const path of candidates) { + if (!paths.includes(path) && await realDirectory(path, host) !== undefined) paths.push(path); + } + } + if (host === 'claude') { + const dataDirectory = join(hostRoot, 'plugins', 'data', id); + if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); } - const dataDirectory = join(hostRoot, 'plugins', 'data', id); - if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); if (paths.length === 0) { + if (host === 'codex' && entry !== undefined) { + return Object.freeze({ + detail: policy === 'purge' + ? '`codex plugin remove` deletes the cached plugin tree; no external framework state or web-data exists.' + : '`codex plugin remove` deletes the cached plugin tree and Codex exposes no keep-data option; no external framework state or web-data exists to preserve.', + outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', + paths: Object.freeze([]), + policy, + }); + } return Object.freeze({ - detail: 'No durable runtime state exists (no state/ under the cached copy and no plugins/data entry).', + detail: 'No durable runtime state exists for the installed copy.', outcome: 'absent', paths: Object.freeze([]), policy, @@ -1108,9 +1153,11 @@ const publicHostData = async ( } return Object.freeze({ detail: policy === 'purge' - ? 'Durable runtime state is removed after `claude plugin uninstall --keep-data` returns (--purge-data --confirm-purge).' - : '`claude plugin uninstall --keep-data` orphans the cached copy (state/ included) for Claude\'s ~14-day grace period and preserves plugins/data; pass --purge-data --confirm-purge to remove both now.', - outcome: policy === 'purge' ? 'purged' : 'retained-by-host', + ? `Durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).` + : host === 'claude' + ? '`claude plugin uninstall --keep-data` orphans the cached copy for Claude\'s ~14-day grace period; Agent Bundle preserves the effective framework state root, legacy state/, web-data, and plugins/data.' + : '`codex plugin remove` deletes the cached plugin tree, but Agent Bundle preserves the external framework state root and web-data.', + outcome: policy === 'purge' ? 'purged' : host === 'claude' ? 'retained-by-host' : 'kept', paths: Object.freeze(paths), policy, }); @@ -1254,7 +1301,16 @@ const uninstallPublicCli = async ( 'carry that claim, so after this uninstall the marketplace counts as user-owned: remove it by hand once nothing installs from it.' : ` The marketplace registration claim ${planned ? 'would move' : 'moves'} to receipt ${ownershipHeir.path} so the last ` + 'uninstall can still remove it.'; - const data = await publicHostData(host, policy, entry, hostRoot, id, dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes); + const data = await publicHostData( + host, + policy, + entry, + hostRoot, + id, + dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes, + environment, + home, + ); const registrations: UninstallRegistrationReport[] = []; if (pluginRegistration !== undefined) { registrations.push(Object.freeze({ diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts index 48910abcf..dfb76057f 100644 --- a/packages/agent-bundle/src/web-host/launch.ts +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -43,13 +43,19 @@ const webPluginStateSegment = (pluginRoot: string): string => { return safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`; }; +/** + * The shared web-data root for one installed plugin. + */ +export const webPluginDataRoot = (pluginRoot: string, home = homedir()): string => + join(home, '.agent-bundle', 'web-data', webPluginStateSegment(resolve(pluginRoot))); + /** * The author-facing `${PLUGIN_DATA}` directory of one server, outside the * installed artifact: the artifact stays immutable (it may be installed * read-only), so per-server data anchors under the user's home instead. */ export const webPluginDataDirectory = (pluginRoot: string, server: string, home = homedir()): string => - join(home, '.agent-bundle', 'web-data', webPluginStateSegment(resolve(pluginRoot)), mcpServerStateDirectory(server)); + join(webPluginDataRoot(pluginRoot, home), mcpServerStateDirectory(server)); const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 9acfcb39f..06671e822 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; @@ -505,7 +506,9 @@ it('accepts a versionless Cursor inventory manifest as installed', async () => { it('inventories durable SQLite stores and sidecars without opening them', async () => { const fixture = await temporaryDoctor(); const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'stateful'); - const stateRoot = join(pluginRoot, 'state'); + const environment = { XDG_STATE_HOME: join(fixture.root, 'state-home') }; + const stateRoot = userDataStateRoot(pluginRoot, environment, fixture.home); + const legacyStateRoot = join(pluginRoot, 'state'); const store = 'project-tasks-0123456789abcdef.sqlite'; try { await Promise.all([ @@ -514,6 +517,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async { name: 'stateful', version: '1.0.0' }, ), mkdir(stateRoot, { recursive: true }), + mkdir(legacyStateRoot, { recursive: true }), ]); await Promise.all([ writeFile(join(stateRoot, store), 'database'), @@ -524,6 +528,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, + environment, home: fixture.home, hosts: ['cursor'], }); @@ -532,6 +537,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async ); expect(finding?.durableState).toMatchObject({ directory: stateRoot, + exists: true, findings: [{ bytes: 15, file: store, @@ -540,18 +546,62 @@ it('inventories durable SQLite stores and sidecars without opening them', async }], status: 'known', summary: { bytes: 15, stores: 1 }, + writable: true, }); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7332', + message: expect.stringContaining(legacyStateRoot), + }), + ])); const human = captureCliTerminal(); const humanCode = await runCli(['doctor'], human.output, { runDoctor: async () => report }); expect(humanCode).toBe(0); expect(human.stdout()).toContain('durable state: 1 store, 15 B'); + expect(human.stdout()).toContain(`state root: ${stateRoot} (exists, writable, derived)`); const json = captureCliTerminal(); await runCli(['doctor', '--json'], json.output, { runDoctor: async () => report }); expect(JSON.parse(json.stdout()).hosts[0].inventory.findings[0].durableState).toMatchObject({ findings: [{ bytes: 15, file: store }], + exists: true, summary: { bytes: 15, stores: 1 }, + writable: true, + }); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a missing derived state root and a declared state-root override', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'configured-state'); + const declaredStateRoot = join(fixture.root, 'declared-state'); + try { + await Promise.all([ + writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'configured-state', version: '1.0.0' }), + writeJson(join(pluginRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + configured: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, + }, + }, + }), + ]); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find((entry) => entry.entry === 'configured-state'); + expect(finding?.durableState).toMatchObject({ + directory: declaredStateRoot, + exists: false, + findings: [], + summary: { bytes: 0, stores: 0 }, + writable: false, }); } finally { await fixture.cleanup(); @@ -1154,7 +1204,7 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v hosts: ['claude', 'codex'], }); expect(report.diagnostics.some((entry) => entry.code === 'AB7303')).toBe(false); - expect(hostReport(report, 'claude').inventory).toEqual({ + expect(hostReport(report, 'claude').inventory).toMatchObject({ findings: [ { enabled: true, @@ -1175,7 +1225,7 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v ], status: 'known', }); - expect(hostReport(report, 'codex').inventory).toEqual({ + expect(hostReport(report, 'codex').inventory).toMatchObject({ findings: [{ entry: 'beta@beta-marketplace', name: 'beta', @@ -1667,7 +1717,7 @@ it('reports a Claude copy the host refused to load as load-failed (AB7325) inste state: 'registered', }); expect(host.bundle?.comparison).not.toHaveProperty('installedContentHash'); - expect(host.inventory).toEqual({ + expect(host.inventory).toMatchObject({ findings: [{ enabled: true, entry: 'doctor-fixture@doctor-fixture-marketplace (user)', @@ -1730,7 +1780,7 @@ it('reports an installed-but-disabled Claude copy as disabled (AB7327) with the }, state: 'registered', }); - expect(host.inventory).toEqual({ + expect(host.inventory).toMatchObject({ findings: [{ enabled: false, entry: 'doctor-fixture@doctor-fixture-marketplace (project)', @@ -2248,7 +2298,7 @@ it('explains a Cursor directory holding only preserved runtime state instead of await uninstallBundle({ from: bundle, home: fixture.home, host: 'cursor' }); const remnant = hostReport(await doctor(), 'cursor'); expect(remnant.inventory.findings).toEqual([expect.objectContaining({ - durableState: expect.objectContaining({ summary: { bytes: 8, stores: 1 } }), + legacyDurableState: expect.objectContaining({ summary: { bytes: 8, stores: 1 } }), name: 'doctor-fixture', path: destination, receipt: expect.objectContaining({ mode: 'local' }), diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 2a4b3326a..d3fa9e97e 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -561,7 +561,7 @@ codexPluginIt( keepData: 'unavailable', plan: 'no-op', proofLevel: proofLabel, - purgeData: 'removed-by-host', + purgeData: 'purged', refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, registrations: { 'codex-marketplace': 'removed', 'codex-plugin': 'removed' }, rerun: 'not-installed', diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 5a6d5fc26..23f913280 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { chmod, cp, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { chmod, cp, mkdir, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, relative, resolve } from 'node:path'; import { promisify } from 'node:util'; @@ -79,6 +79,9 @@ it('serves a state-writing tool from a read-only installed artifact without writ const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-readonly-state-')); const project = join(consumer, 'project'); const artifact = join(project, 'artifact'); + const home = join(project, '.home'); + const cursorRoot = join(home, '.cursor'); + const installedRoot = join(cursorRoot, 'plugins', 'local', pluginName); let readOnly = false; try { @@ -98,12 +101,20 @@ it('serves a state-writing tool from a read-only installed artifact without writ }); const deletedSource = await removeProjectSource({ extraPaths: ['views'], projectRoot: project }); expect(deletedSource.removed).toEqual(['agent-bundle.config.ts', 'src', 'views']); + const env = stringEnvironment(installedEnvironment()); + delete env['AGENT_BUNDLE_PLUGIN_ROOT']; + delete env['AGENT_BUNDLE_STATE_ROOT']; + env['HOME'] = home; + await mkdir(cursorRoot, { recursive: true }); + const installer = join(artifact, 'install.mjs'); + expect((await execFile(process.execPath, [installer], { cwd: artifact, env })).stdout) + .toContain(`Installed ${pluginName}`); // An installed artifact a host may own read-only: no shell, worker, or bin // spawned below may create anything beneath it. - await chmodTree(artifact, { directory: 0o555, file: 0o444 }); + await chmodTree(installedRoot, { directory: 0o555, file: 0o444 }); readOnly = true; - const listingBefore = await treeListing(artifact); + const listingBefore = await treeListing(installedRoot); expect(listingBefore).toEqual(expect.arrayContaining([ expect.stringMatching(new RegExp(`^bin/${pluginName}\\.mjs \\d+$`, 'u')), 'mcp/', @@ -113,9 +124,6 @@ it('serves a state-writing tool from a read-only installed artifact without writ // No custom state env: the launch inherits the worker's XDG_STATE_HOME // (rstest.worker-isolation.ts), so the derived user-data state root stays // under the worker root and never touches the developer's home. - const env = stringEnvironment(installedEnvironment()); - delete env['AGENT_BUNDLE_PLUGIN_ROOT']; - delete env['AGENT_BUNDLE_STATE_ROOT']; const stateHome = env['XDG_STATE_HOME']; if (stateHome === undefined || !stateHome.startsWith(rstestWorkerRoot())) { throw new Error(`XDG_STATE_HOME must name a directory under the worker root ${rstestWorkerRoot()}; got ${String(stateHome)}. Is rstest.setup.ts isolating this worker?`); @@ -124,18 +132,18 @@ it('serves a state-writing tool from a read-only installed artifact without writ // Resolve the launch the way ` web` does: the manifest's web // section names the App and its artifact-relative entry, and // resolveWebLaunch anchors the code root without naming a state root. - const webManifest = await readWebManifest(join(artifact, 'agent-bundle.manifest.json')); + const webManifest = await readWebManifest(join(installedRoot, 'agent-bundle.manifest.json')); const declaredApp = webManifest?.apps.find((candidate) => candidate.app === app); if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(webManifest)}`); - const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: artifact }); + const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: installedRoot }); expect(launch.command).toBe(process.execPath); - expect(launch.cwd).toBe(artifact); - expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(artifact); + expect(launch.cwd).toBe(installedRoot); + expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(installedRoot); expect(launch.env['AGENT_BUNDLE_STATE_ROOT']).toBeUndefined(); expect(launch.env['XDG_STATE_HOME']).toBe(stateHome); const [entry, ...args] = launch.args; if (entry === undefined) throw new Error('resolveWebLaunch returned no entry argument.'); - expect(entry.startsWith(join(artifact, 'mcp') + '/')).toBe(true); + expect(entry.startsWith(join(installedRoot, 'mcp') + '/')).toBe(true); const openSession = () => openPackedMcpServer({ args, cwd: launch.cwd, @@ -165,9 +173,9 @@ it('serves a state-writing tool from a read-only installed artifact without writ // Nothing landed beneath the read-only artifact; the SQLite kernel sits // under the user-data state root the child derived from the same code // root and inherited env. - expect(await treeListing(artifact)).toEqual(listingBefore); - expect(await exists(join(artifact, 'state'))).toBe(false); - const stateRoot = userDataStateRoot(artifact, launch.env); + expect(await treeListing(installedRoot)).toEqual(listingBefore); + expect(await exists(join(installedRoot, 'state'))).toBe(false); + const stateRoot = userDataStateRoot(installedRoot, launch.env); expect(stateRoot.startsWith(join(stateHome, 'agent-bundle') + '/')).toBe(true); expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ expect.stringMatching(/\.sqlite$/u), @@ -176,7 +184,7 @@ it('serves a state-writing tool from a read-only installed artifact without writ // The artifact CLI bin derives the same code root from its own `bin/` // parent — no AGENT_BUNDLE_PLUGIN_ROOT, no state env — and reads the // entries the MCP process wrote. - const bin = join(artifact, 'bin', `${pluginName}.mjs`); + const bin = join(installedRoot, 'bin', `${pluginName}.mjs`); const cliRun = await execFile(process.execPath, [bin, 'entries', '--json'], { cwd: consumer, env }); expect(JSON.parse(cliRun.stdout) as JournalResult).toEqual({ entries: [{ note: 'first' }, { note: 'second' }], @@ -195,11 +203,26 @@ it('serves a state-writing tool from a read-only installed artifact without writ } finally { await secondSession.close(); } - expect(await treeListing(artifact)).toEqual(listingBefore); - expect(await exists(join(artifact, 'state'))).toBe(false); - expect(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')).not.toContain('AGENT_BUNDLE_STATE_ROOT'); + expect(await treeListing(installedRoot)).toEqual(listingBefore); + expect(await exists(join(installedRoot, 'state'))).toBe(false); + expect(await readFile(join(installedRoot, 'agent-bundle.manifest.json'), 'utf8')).not.toContain('AGENT_BUNDLE_STATE_ROOT'); + + await chmodTree(installedRoot, { directory: 0o755, file: 0o644 }); + readOnly = false; + const kept = await execFile(process.execPath, [installer, '--uninstall', '--keep-data'], { cwd: artifact, env }); + expect(kept.stdout).toContain(`Data (keep): kept`); + expect(kept.stdout).toContain(stateRoot); + expect(await exists(stateRoot)).toBe(true); + const purged = await execFile( + process.execPath, + [installer, '--uninstall', '--purge-data', '--confirm-purge'], + { cwd: artifact, env }, + ); + expect(purged.stdout).toContain(`Data (purge): purged`); + expect(purged.stdout).toContain(stateRoot); + expect(await exists(stateRoot)).toBe(false); } finally { - if (readOnly) await chmodTree(artifact, { directory: 0o755, file: 0o644 }); + if (readOnly) await chmodTree(installedRoot, { directory: 0o755, file: 0o644 }); await rm(consumer, { force: true, recursive: true }); } }, 300_000); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 4b931f246..c030d5459 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { runCli } from '../src/cli.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; @@ -154,7 +155,9 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o const cursorRoot = join(fixture.home, '.cursor'); await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); - const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + const environment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'state-home') }; + const options = { environment, from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + const derivedStateRoot = userDataStateRoot(destination, environment, fixture.home); try { const before = await snapshotTree(fixture.home); await installBundle(options); @@ -162,6 +165,8 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect((await readInstallReceipt(destination))?.hostDirectories).toEqual([]); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(derivedStateRoot, { recursive: true }); + await writeFile(join(derivedStateRoot, 'plugin.sqlite'), 'derived\n'); await writeFile(join(destination, 'operator-notes.md'), 'mine\n'); // Unowned directories that hold nothing retained survive too (the prune only touches owned directories): // one at the root and one nested inside an owned directory that would otherwise be pruned. @@ -186,13 +191,14 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(keepPlan.retained).toEqual(['operator-notes.md', 'scratch/', 'skills/drafts/']); // Purging state/ still leaves the note, so the root survives that plan too; the purged directory is listed as one. const purgePlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); - expect(purgePlan.removed.directories[0]).toBe(join(destination, 'state')); + expect(purgePlan.data.paths).toEqual([derivedStateRoot, join(destination, 'state')]); + expect(purgePlan.removed.directories.slice(0, 2)).toEqual([derivedStateRoot, join(destination, 'state')]); expect(purgePlan.removed.directories).not.toContain(destination); expect(purgePlan.removed.files).not.toContain(join(destination, 'state')); const kept = await uninstallBundle({ ...options, keepData: true }); expect(kept).toMatchObject({ - data: { outcome: 'kept', paths: [join(destination, 'state')], policy: 'keep' }, + data: { outcome: 'kept', paths: [derivedStateRoot, join(destination, 'state')], policy: 'keep' }, remnantReceipt: join(destination, installReceiptFile), retained: ['operator-notes.md', 'scratch/', 'skills/drafts/'], state: 'uninstalled', @@ -204,6 +210,7 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(await readdir(join(destination, 'skills'))).toEqual(['drafts']); expect(await readInstallReceipt(destination)).toMatchObject({ files: [], hostDirectories: [], mode: 'local', registrations: [] }); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect(await readFile(join(derivedStateRoot, 'plugin.sqlite'), 'utf8')).toBe('derived\n'); expect(formatUninstallResult(kept)).toContain('Retained 3 unowned entries'); expect(formatUninstallResult(kept)).toContain('Remnant receipt:'); @@ -217,14 +224,15 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(purged).toMatchObject({ - data: { outcome: 'purged', paths: [join(destination, 'state')], policy: 'purge' }, + data: { outcome: 'purged', paths: [derivedStateRoot, join(destination, 'state')], policy: 'purge' }, retained: [], state: 'uninstalled', }); expect(purged.remnantReceipt).toBeUndefined(); // A purged state/ tree is a directory and is reported as one, ahead of the pruned owned directories. - expect(purged.removed.directories[0]).toBe(join(destination, 'state')); + expect(purged.removed.directories.slice(0, 2)).toEqual([derivedStateRoot, join(destination, 'state')]); expect(purged.removed.files).not.toContain(join(destination, 'state')); + await expect(readdir(derivedStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); @@ -392,6 +400,45 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a } }); +it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const declaredStateRoot = join(fixture.cleanupRoot, 'declared-state'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, + }, + }, + }), + ]); + await installBundle(options); + await mkdir(declaredStateRoot, { recursive: true }); + await writeFile(join(declaredStateRoot, 'plugin.sqlite'), 'declared\n'); + const plan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(plan.data.paths).toEqual([declaredStateRoot]); + expect(plan.removed.directories).toContain(declaredStateRoot); + const kept = await uninstallBundle({ ...options, keepData: true }); + expect(kept).toMatchObject({ + data: { outcome: 'kept', paths: [declaredStateRoot] }, + remnantReceipt: join(cursorRoot, 'plugins', 'local', 'uninstall-fixture', installReceiptFile), + }); + expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) + .toMatchObject({ stateRoot: { root: declaredStateRoot, source: 'native' } }); + expect(await readFile(join(declaredStateRoot, 'plugin.sqlite'), 'utf8')).toBe('declared\n'); + const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); + await expect(readdir(declaredStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('refuses Cursor local uninstalls without proof of ownership unless forced, and foreign directories always', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); @@ -1188,7 +1235,7 @@ it('purges Claude durable state only when confirmed and reports the host-retaine } }); -it('types the Codex data outcome as unavailable for keep and removed-by-host for purge', async () => { +it('keeps external Codex state while reporting in-tree state only for purge', async () => { const fixture = await createFixture('codex'); const hostRoot = join(fixture.cleanupRoot, 'codex-root'); let installed = false; @@ -1215,9 +1262,19 @@ it('types the Codex data outcome as unavailable for keep and removed-by-host for await installBundle(options); const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); await cp(fixture.bundleRoot, installPath, { recursive: true }); - expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ outcome: 'unavailable', policy: 'keep' }); + const stateRoot = userDataStateRoot(installPath, options.environment, fixture.home); + await Promise.all([ + mkdir(join(installPath, 'state'), { recursive: true }), + mkdir(stateRoot, { recursive: true }), + ]); + expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ + outcome: 'kept', + paths: [stateRoot], + policy: 'keep', + }); expect((await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true })).data).toMatchObject({ - outcome: 'removed-by-host', + outcome: 'purged', + paths: [stateRoot, join(installPath, 'state')], policy: 'purge', }); const scoped = await failureOf(uninstallBundle({ ...options, scope: 'project' })); diff --git a/packages/agent-bundle/tests/web-launch.test.ts b/packages/agent-bundle/tests/web-launch.test.ts index 60031732a..42396c643 100644 --- a/packages/agent-bundle/tests/web-launch.test.ts +++ b/packages/agent-bundle/tests/web-launch.test.ts @@ -1,12 +1,13 @@ -import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { pluginStateSegment } from '@agent-bundle/runtime'; import { afterEach, describe, expect, it } from '@rstest/core'; import { exists } from '../src/core/paths.ts'; import { pathTokens, pluginRootEnvAnchor } from '../src/core/types.ts'; +import { installedWebDataRoot } from '../src/install/state-root.ts'; import { resolveWebLaunch, WebLaunchError, webPluginDataDirectory } from '../src/web-host/launch.ts'; import type { WebManifestApp } from '../src/web-host/manifest.ts'; @@ -141,6 +142,16 @@ describe('resolveWebLaunch', () => { expect(webPluginDataDirectory(first, 'status', home)).toBe(webPluginDataDirectory(`${first}/mcp/..`, 'status', home)); }); + it('shares web-data derivation with uninstall through a symlinked plugin root', async () => { + const home = await homeRoot(); + const root = await artifactRoot(); + const link = `${root}-link`; + roots.push(link); + await symlink(root, link, 'dir'); + expect(installedWebDataRoot(link, home)) + .toBe(dirname(webPluginDataDirectory(link, 'status', home))); + }); + it('keys the web data directory on the same segment the runtime keys the state root on', async () => { // web-host/launch.ts never loads the optional `@agent-bundle/runtime` // peer, so its segment is a separate implementation of the runtime's diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 150dd56c2..6c84ef1cb 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -105,7 +105,7 @@ this plugin's installer did not place — is refused either way. Cursor copies c place and touches owned files only, never unowned entries such as legacy or in-place `state/`, and `--replace` adopts a pre-receipt copy. Current artifact builds keep framework state under `~/.agent-bundle/state/-` instead (`AGENT_BUNDLE_STATE_ROOT` overrides that -location); `uninstall` does not remove it. Claude replacement runs +location); `uninstall --purge-data --confirm-purge` removes it for the installed code root. Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents the same recipe per host. @@ -187,9 +187,9 @@ created, the host registrations it performed, and timestamps. Cursor local copie `.agent-bundle-install.json`; Claude, Codex, and Cursor marketplace-mode installs keep theirs under `/agent-bundle/receipts/`. `uninstall` removes exactly what the receipt owns and reverses exactly the registrations it recorded — never anything else; unowned entries are listed -as retained. Legacy or in-place durable runtime state (`state/`, and for a Cursor copy of an Agent -Plugins pack the `PLUGIN_DATA` directory the receipt records) is kept unless you pass -`--purge-data --confirm-purge`, and the result states honestly what the host itself decided where +as retained. The effective framework state root, derived web-data, legacy `state/`, and for a +Cursor copy of an Agent Plugins pack the `PLUGIN_DATA` directory the receipt records are kept unless +you pass `--purge-data --confirm-purge`, and the result states honestly what the host itself decided where Agent Bundle cannot (Claude orphans its cached copy for a ~14-day grace period; Codex deletes the cached tree and offers no keep-data option). A missing receipt (`AB7009`) or a content mismatch (`AB7007`) is refused unless `--force`; a directory that belongs to another plugin is refused @@ -236,7 +236,7 @@ registration proof and the rows' `errors` already hold that verdict. | `AB7330` | info | The bundle's lifecycle stage on this host and its four observations; unobservable stages (a live session's loaded plugins on every host, Cursor's server-assigned enabled state) are typed `unavailable`, never guessed. | | `AB7328` | warning | A store receipt records a registration the host no longer holds (orphaned), or the receipt store cannot be read; `agent-bundle uninstall` consumes an orphaned receipt. | | `AB7329` | info | A receipt predates lifecycle receipts and was read with synthesized fields; rerun `install` once to rewrite it as format 2. | -| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | +| `AB7316` | warning | An installed bundle's effective or legacy state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | | `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules; the message retains the originating build-validator code. | diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index d02c90042..6094483a1 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -206,8 +206,8 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | -| `--keep-data` | on | Keep legacy or in-place durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the recorded `PLUGIN_DATA` directory). This is the default; the flag makes it explicit. (Artifacts built from this release keep framework state under `~/.agent-bundle/state/-`, which `uninstall` does not remove.) | -| `--purge-data` | off | Also remove the legacy or in-place durable runtime state (`state/`, recorded `PLUGIN_DATA`); the derived `~/.agent-bundle/state/-` root stays. Refused (`AB7008`) without `--confirm-purge`. | +| `--keep-data` | on | Keep the effective framework state root (`AGENT_BUNDLE_STATE_ROOT`, else `~/.agent-bundle/state/-` or `$XDG_STATE_HOME/agent-bundle/-`), derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit. | +| `--purge-data` | off | Remove those durable-data roots for the exact installed code root. Refused (`AB7008`) without `--confirm-purge`. | | `--force` | off | Proceed without a receipt (legacy Cursor copy, host-only install) or when owned content, version, or staged `HEAD` no longer matches the receipt. A receipt or manifest naming another plugin is refused regardless. | | `--plan` | off | Print the exact paths and host registrations that would be removed and change nothing. | @@ -221,10 +221,9 @@ install at another scope or in another project known only to Claude's `plugins/i registry). Unowned entries are retained and listed; a missing receipt is `AB7009` and a mismatch is `AB7007` unless `--force`; a second run is a `not-installed` no-op. The typed `data.outcome` says what happened to durable state per host: Cursor `kept` / `purged` / `absent`; Claude `retained-by-host` (the cached copy is -orphaned for Claude's ~14-day grace period; a purge also removes legacy or in-place `state/` and -`plugins/data//`, but not the current `~/.agent-bundle/state/-` framework -state); Codex `removed-by-host` / `unavailable` (`codex plugin remove` deletes the -cached tree and has no keep-data option). The package-relative installer bin accepts +orphaned for Claude's ~14-day grace period; a purge removes the effective framework state root, +derived web-data, legacy `state/`, and `plugins/data//`); Codex keeps external state by default +and removes it on a confirmed purge while `codex plugin remove` deletes the cached tree. The package-relative installer bin accepts `uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. @@ -258,6 +257,9 @@ surface exposes it (`AB7330`). It inventories the Agent Bundle receipt store und and warns about receipts the host no longer honours (`AB7328`), and reports receipts written before format 2 as migrated (`AB7329`). A Cursor directory holding only preserved runtime state from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not corrupt or foreign. +For every installed copy Doctor reports the resolved framework state root, its `native` or +`derived` source, whether it exists, and whether it is writable. A pre-#640 +`/state` is reported separately and flagged with `AB7332`. ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index fea62a5e7..9166f53ee 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -88,7 +88,7 @@ node ./install.mjs 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置),`uninstall` -不会删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, +配合 `--purge-data --confirm-purge` 会按已安装代码根删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 @@ -155,8 +155,8 @@ node artifact/install.mjs --uninstall [--mode marketplace] 每次安装都会写入生命周期回执(`agent-bundle-install-receipt/2`):版本、内容哈希、投递模式、作用域、归属的文件与 目录、安装器创建的宿主目录、执行过的宿主注册,以及时间戳。Cursor 本地副本以 `.agent-bundle-install.json` 携带它; Claude、Codex 与 Cursor 市场模式的安装把回执放在 `<宿主根目录>/agent-bundle/receipts/` 下。`uninstall` 只删除回执 -归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。旧版或就地的持久运行时状态(`state/`,以及 Agent Plugins -包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录)除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 +归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。有效框架状态根、推导出的 web-data、旧版 `state/`,以及 Agent Plugins +包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录,除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 (`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 格式 2 之前写入的回执会在补全生命周期字段后读取并给出诊断(`AB7329`),绝不被拒绝。 @@ -194,7 +194,7 @@ Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核 | `AB7330` | info | 该捆绑包在此宿主上的生命周期阶段及四项观察;无法观察的阶段(任一宿主的活跃会话加载了哪些插件、Cursor 由服务端分配的启用状态)被类型化为 `unavailable`,绝不猜测。 | | `AB7328` | warning | 某份仓库回执记录的注册宿主已不再持有(孤立),或回执仓库无法读取;`agent-bundle uninstall` 会消费孤立的回执。 | | `AB7329` | info | 某份回执早于生命周期回执,读取时补全了字段;再运行一次 `install` 即可将其重写为格式 2。 | -| `AB7316` | warning | 某个已安装捆绑包的 `state/` 目录,或它的某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | +| `AB7316` | warning | 某个已安装捆绑包的有效或旧版状态目录不可写,或该目录及其某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | | `AB7317` | info | 某个活跃的事件运行时实现的是较旧的严格协议,不暴露运行时身份。 | | `AB7318` | error | 在有界的只读身份探测过程中,某个活跃的事件运行时变为不可用、超时,或返回了无效的状态响应。 | | `AB7319` | error | 由 `doctor --from` 解析出的宿主目录树违反了它被固定的文档 schema 或无进程加载器规则;消息中保留原始的构建校验器代码。 | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 9bbd1f448..e88b49396 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -197,8 +197,8 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的产物根目录,宿主清单直接位于其下,与 `install` 相同。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | -| `--keep-data` | 开启 | 保留旧版或就地的持久运行时状态(`state/`:状态内核、通知日志;对 Agent Plugins 包的 Cursor 副本,还包括回执记录的 `PLUGIN_DATA` 目录)。这是默认行为;该标志只是显式声明。(本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`,`uninstall` 不会删除它。) | -| `--purge-data` | 关闭 | 同时删除旧版或就地的持久运行时状态(`state/`、回执记录的 `PLUGIN_DATA`);推导出的 `~/.agent-bundle/state/-` 保留。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--keep-data` | 开启 | 保留有效框架状态根(`AGENT_BUNDLE_STATE_ROOT`,否则为 `~/.agent-bundle/state/-` 或 `$XDG_STATE_HOME/agent-bundle/-`)、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明。 | +| `--purge-data` | 关闭 | 删除与该已安装代码根精确对应的上述持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | @@ -209,9 +209,9 @@ uninstall 只删除回执归属的内容:记录的文件与安装器创建的 包括仅记录在 Claude 的 `plugins/installed_plugins.json` 注册表中、位于另一作用域或另一项目的 Claude 安装)。 非归属条目被保留并列出;缺少回执为 `AB7009`,不匹配为 `AB7007`,除非 `--force`;再次运行是 `not-installed` 空操作。类型化的 `data.outcome` 按宿主如实说明持久状态的去向:Cursor 为 `kept` / `purged` / `absent`; -Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 还会删除旧版或就地的 `state/` 与 -`plugins/data//`,但不会删除当前的 `~/.agent-bundle/state/-` 框架状态);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 -keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 +Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 会删除有效框架状态根、 +推导出的 web-data、旧版 `state/` 与 `plugins/data//`);Codex 默认保留外部状态,在确认 purge 时删除它, +而 `codex plugin remove` 会删除缓存树。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 ## doctor @@ -238,6 +238,8 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst 的原因(`AB7330`)。它还清点每个宿主根目录下的 Agent Bundle 回执仓库,对宿主已不再认可的回执发出警告(`AB7328`),并把 格式 2 之前写入的回执报告为已迁移(`AB7329`)。仅包含 `uninstall --keep-data` 所保留运行时状态的 Cursor 目录会以 `AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign。 +对于每份已安装副本,Doctor 会报告解析后的框架状态根、其 `native` 或 `derived` 来源、是否存在以及是否可写。 +升级 #640 之前留下的 `/state` 会单独报告,并以 `AB7332` 标记。 ## validate From 27f15c677a80e7ce9cf13b5c09fbff3be5d2b62e Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:27:56 +0000 Subject: [PATCH 10/11] fix(build): align canonical root consumers --- .changeset/canonical-npm-root.md | 3 +- docs/diagnostics.md | 6 +- examples/audiobook-curator/README.md | 4 +- .../audiobook-curator/agent-bundle.config.ts | 2 +- examples/audiobook-curator/package.json | 7 -- examples/host-test/README.md | 2 +- examples/host-test/agent-bundle.config.ts | 2 +- examples/host-test/package.json | 7 -- packages/agent-bundle/src/api.ts | 3 +- .../agent-bundle/src/build/module-imports.ts | 2 +- packages/agent-bundle/src/install/doctor.ts | 5 +- packages/agent-bundle/src/install/format.ts | 4 +- .../tests/artifact-validator.test.ts | 4 +- .../tests/support/host-install.ts | 52 +++++++------ packages/create-agent-bundle/src/scaffold.ts | 75 ++++++++----------- .../templates/cli-tool/README.md | 16 ++-- .../templates/cli-tool/agent-bundle.config.ts | 2 +- .../templates/cli-tool/package_json | 11 +-- .../tests/projection/cli-dispatch.test.ts | 2 +- .../templates/mcp-server/README.md | 8 +- .../templates/mcp-server/package_json | 10 +-- .../tests/scaffold-packed-matrix.e2e.test.ts | 18 ++--- .../tests/scaffold.test.ts | 72 +++++++----------- .../docs/en/examples/audiobook-curator.mdx | 4 +- .../en/guide/authoring/package-entries.mdx | 10 ++- .../docs/en/guide/distribution/validation.mdx | 6 +- .../docs/zh/examples/audiobook-curator.mdx | 4 +- .../zh/guide/authoring/package-entries.mdx | 9 ++- .../docs/zh/guide/distribution/validation.mdx | 6 +- 29 files changed, 159 insertions(+), 197 deletions(-) diff --git a/.changeset/canonical-npm-root.md b/.changeset/canonical-npm-root.md index 765cc3a07..9457f4abf 100644 --- a/.changeset/canonical-npm-root.md +++ b/.changeset/canonical-npm-root.md @@ -1,5 +1,6 @@ --- "agent-bundle": minor +"create-agent-bundle": minor --- -Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, keep npm metadata outside host install comparisons, and report `AB4767` when no selected target emitted that executable (#639). +Package the validated composite root as the npm root, remove the obsolete package installer wrapper, point generated CLI bins at the manifest-declared executable so every command including `web` has artifact parity, expose manifest file kinds through `PackageOutputFile.kind`, keep npm metadata outside host install comparisons, update generated projects for the `dist/` publish root, and report `AB4767` when no selected target emitted that executable (#639). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 3564fd3bb..3e9f3ba6f 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -428,7 +428,7 @@ Validation happens at three moments, all fail-closed: | `AB7012` | A `package.json` bin points into `src/` or names a file npm omitted. Point routed CLIs at their manifest-declared `bin/.mjs` and authored bins at generated `bin/*.js` files. | | `AB7013` | `package.json`, normalized plugin metadata, a host manifest, or artifact provenance reports a different release version. Make every release identity agree. | | `AB7014` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses: no packed JavaScript imports, requires, or resolves them, or runs one of their `bin` commands, no packed declaration file references them, no `#subpath` import reaches them through the manifest's `imports` map, and no consumer-side install script (or script it delegates to) runs them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; the full evidence list follows this table). Peers `peerDependenciesMeta` marks optional are never installed and are not inspected here (their specifier is still checked by `AB7015`), and a name under both `dependencies` and `optionalDependencies` is judged by its optional entry, which npm lets override. The build inlines every dependency into `dist` and the host packs, and `AB6005` fails a compiled bundle that imports a bare specifier other than a Node built-in (`prepack` runs that build before this inventory), so a compiled bundle can never supply `import` evidence here and such an entry only makes every consumer's `npm install` fetch build-time packages; the packed-JavaScript `import` evidence class is for modules the framework copied rather than compiled — prebuilt payload modules and other packed scripts — while `require`, `createRequire`, and `import.meta.resolve` calls, which `AB6005` does not walk, count from any packed file. Move build-only packages to `devDependencies`; keep a runtime dependency only for what a prebuilt payload module imports, a packed file requires or resolves, a packed declaration references, a `#subpath` import reaches, an install script or packed file runs, or a prebuilt payload names in `runtimeDependencies` (`definePrebuilt`). For `peerDependencies` the diagnostic is a warning: a required peer nothing imports may be a deliberate compatibility contract with the host that loads the package, though npm 7+ still installs it for every consumer — keep it, mark it optional in `peerDependenciesMeta`, or move a build-only package to `devDependencies`. | -| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` count as registry specifiers only when the `prepack` lifecycle runs under pnpm, Yarn, or Bun (`npm_config_user_agent`), which rewrite them in the tarball they pack; `npm publish` publishes them verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`, so under npm — or when `agent-bundle prepack` runs outside any package-manager lifecycle — they are reported. The `npm pack --dry-run` that `prepack` itself spawns is only the file inventory; the tarball consumers receive is the lifecycle's packer's, which is what the user agent identifies. | +| `AB7015` | A `package.json` `dependencies`, `optionalDependencies`, or `peerDependencies` entry that a consumer's npm cannot resolve through a registry. Each entry — name and specifier together, the value exactly as written (a leading space makes `" npm:bar@1"` an invalid dist-tag, not an alias) — is read with `npm-package-arg`, the parser npm, Arborist, and pacote share, so the verdict is npm's own rather than an imitation of its grammar: **registry** (a version, range, or dist-tag, or an `npm:` alias of one — the only kind a published package can rely on), **fetched** (parseable, but a `git`/`github:`/`gitlab:`/`bitbucket:`/`gist:` source or `owner/repo` shorthand, an `http(s):` tarball, or a `file:`/relative/bare path or tarball filename — npm 12 refuses git and remote fetches by default (`allow-git=none`, `allow-remote=none`) and a path never exists on the consumer's disk), or **unparseable** (npm rejects the manifest before fetching anything: `EINVALIDPACKAGENAME` for a name such as `bad name`, `.hidden`, or `node_modules`; `EUNSUPPORTEDPROTOCOL` for `link:`, `portal:`, `jsr:`, a `git+` transport npm lacks, or a typo; `EINVALIDTAGNAME` for a selector that is neither a range nor a URL-safe dist-tag, such as `"not a valid spec"`; an `npm:` alias without a name or with a non-registry target, since aliases only work for registry dependencies; or an invalid URL such as `http:%zz`). A fetched specifier is reported on installed entries only; an unparseable one is reported on every entry, even an optional peer npm would never install, because the manifest read itself fails. A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry alone: npm resolves the concrete declaration and never reads the duplicate peer's selector. For an `optionalDependencies` entry that is fetched, the diagnostic is a warning, not an error (`agent-bundle prepack` prints it and exits 0): npm continues an install without such a dependency, but every consumer still tries and fails to fetch it. It stays an error when the entry is unparseable, or when a consumer-side install script needs the skipped package — runs one of its `bin` commands in command position (`setup-tool --init`, `npx setup-tool`, `cross-env CI=1 setup-tool`, `./node_modules/.bin/setup-tool`; a mention elsewhere, `echo setup-tool`, proves nothing), runs one of its files (`node node_modules/setup-tool/install.js`), loads it from an inline program (`node -e "require('setup-tool')"`, `node --input-type=module -e "await import('setup-tool')"`, also `-p`, `-pe`, `--eval=…`, `--print=…`; the program is read as a packed file is — `require`, `createRequire`, and `import()` — and a computed load there, or a program the lexer rejects, may need any declared package), preloads it (`node -r setup-tool/register install.js`; `-r`/`--require`, `--import`, `--loader`/`--experimental-loader`, with a space or `=` before the module — read as Node does, `node [options] script [arguments]`: options end at the first positional (the script, or an argument when `-e`/`-p` supply the program) or a `--`, valued options such as `--conditions x` or `--env-file x` taking their word with them, so `node install.js --require x` passes `--require x` to `install.js` and preloads nothing; a `NODE_OPTIONS` assignment on the same command — `NODE_OPTIONS=--require=setup-tool/register node install.js`, `cross-env NODE_OPTIONS="-r setup-tool/register" node .` — supplies options Node applies before the command line's, while one `export`ed by an earlier command is not read), or runs a packed file (`node install.cjs`, `node scripts/install` resolving `scripts/install.js`, `node "scripts/my install.cjs"`, `node install.js&&echo done`, `node .` or `node ./` running the root `main`, `node --import ./setup.mjs .` running a packed preload) that imports it — every word of the script that names a packed JavaScript file counts as run, deliberately, so that runners this gate does not model (`tsx`, `ts-node`, `zx`, `bun`, `deno run`, `npx `) still have the dependencies their file loads traced; the cost is a rare escalation for a word that names a packed file without running it (`echo install.js`), which the diagnostic makes visible by naming the file — directly, through relative imports inside the tarball (`require("./lib")` following `lib/package.json`'s `main` before `lib/index.js`, as Node does), or through the `imports` map resolved as Node does (`"#setup": "./setup.js"`; `#setup/foo` through `"#setup/*": "./scripts/*.js"`, a preloaded `#setup` included): npm continues past the failed fetch, then the script fails on the missing command or module. Each command of a script is read on its own: after a shell operator (`&&`, `;`, and the rest) or a newline — the second line of a script, and each lifecycle script after the first, starts a new command — and Node's options belong to `node` alone (`rm -r dist` preloads nothing, `npm --prefix . run setup` runs no `main`). Depend on a published registry version, or bundle the package and declare it under `devDependencies`. Entries the tarball itself carries are never reported, since a consumer does not fetch them: `bundleDependencies` (by name or `true`; never a peer, which npm cannot bundle; only when the pack inventory contains `node_modules//package.json`, since npm silently packs nothing for a bundled name absent from `node_modules`), and a `file:` or bare path inside the package (`file:vendor/foo`, `file:vendor/foo.tgz`) whose packed source npm can install from — a directory whose packed `package.json` parses to an object, or a packed tarball (gzipped or plain tar, ustar headers with valid checksums and payloads inside the archive) whose `/package.json` entry parses to an object — since npm installs it from the consumer's own copy. A path that escapes the package (`file:../sibling`), whose source is not packed, or whose packed source is not installable (a `.tgz` that is not an archive, or is malformed or truncated, fails the consumer's install with `TAR_BAD_ARCHIVE`; a manifest that does not parse, on disk or inside the archive, fails it with `EJSONPARSE`) is reported. `workspace:` and `catalog:` are always reported because the canonical npm root is validated for `npm publish ./dist`; npm publishes those protocols verbatim and consumers fail with `EUNSUPPORTEDPROTOCOL`. Use a registry version before running `agent-bundle prepack`. | Compiled bundles reach this gate without bare imports: `prepack` builds before it packs, and the compiler reports `AB6005` when a `dist` bundle or host-pack module keeps anything other than a Node built-in, @@ -869,6 +869,10 @@ See “The routed CLI shell” in | `AB4766` | error (build) | A target plan already emits `bin/.mjs` or `bin/-flight.mjs` (for example a Claude `claude.bin` directory shipping a file of that name), compared case-insensitively because those are one file on macOS and Windows. The routed CLI owns those paths, so the build refuses instead of choosing. Rename or remove the host-emitted file, or set `bin: false` to keep it and drop the routed CLI executable. | | `AB4767` | error (package build) | The npm package declares a routed CLI, but no selected target emitted its executable into `agent-bundle.manifest.json`, so the npm root has no proven bin to select. Select a target whose adapter publishes the `cli` capability, or set `bin: false`. | +Package outputs are staged after the composite artifact is published, so an +`AB4767` failure leaves that validated artifact available while refusing the +npm root. + ## Config beside a route-generated MCP server (`AB4340`) A `mcp.servers.` block for a server the route graph compiles in diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 5200856c7..d9a2e1561 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -33,7 +33,7 @@ Build and globally link the workspace package without a tarball: ```sh pnpm --filter @agent-bundle-example/audiobook-curator build cd examples/audiobook-curator -ln -s "$(pwd)/dist/bin/audiobook-curator.js" ~/.local/bin/audiobook-curator +ln -s "$(pwd)/dist/bin/audiobook-curator.mjs" ~/.local/bin/audiobook-curator audiobook-curator --help ``` @@ -44,7 +44,7 @@ One `agent-bundle build` produces everything: one plugin root at `artifact/` that both Claude Code and Codex install (each host's manifest directory, `.claude-plugin/` and `.codex-plugin/`, over the shared Skill, bundled CLI script, and lifecycle-wrapped MCP server) plus the npm package beneath `dist/` -(`dist/bin/audiobook-curator.js` for `package.json` `bin`, and `dist/index.js` +(`dist/bin/audiobook-curator.mjs` for `package.json` `bin`, and `dist/index.js` plus declarations for `exports`). The example uses only public `agent-bundle` and `@agent-bundle/runtime` exports with `workspace:*` dependencies. diff --git a/examples/audiobook-curator/agent-bundle.config.ts b/examples/audiobook-curator/agent-bundle.config.ts index 4441cf0f9..a3e2ffae5 100644 --- a/examples/audiobook-curator/agent-bundle.config.ts +++ b/examples/audiobook-curator/agent-bundle.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ // #102 stage 4 adopts the in-house G7 projection for every curator tool. routes: { mcpCommands: true }, // No `scripts` or `bin` fields needed: the routed `src/cli/` commands - // compile into the package executable (dist/bin/audiobook-curator.js) by + // compile into the shared artifact/package executable (bin/audiobook-curator.mjs) by // convention (#102 stages 2-3). // No `skills` field needed: `src/skills/curate-audiobooks/SKILL.md` is // discovered by convention. diff --git a/examples/audiobook-curator/package.json b/examples/audiobook-curator/package.json index 51604f8b7..3907dcafb 100644 --- a/examples/audiobook-curator/package.json +++ b/examples/audiobook-curator/package.json @@ -8,13 +8,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "dist", - "README.md" - ], - "bin": { - "audiobook-curator": "./dist/bin/audiobook-curator.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/examples/host-test/README.md b/examples/host-test/README.md index b2ee8b34b..0aa9c1a1b 100644 --- a/examples/host-test/README.md +++ b/examples/host-test/README.md @@ -51,7 +51,7 @@ Two MCP servers ship in the plugin: judged against the wire. The rendered CLI `host-test dump [--conversation ] [--full] [--log ]` -(`dist/bin/host-test.js`) reads the same log outside any host. +(`dist/bin/host-test.mjs`) reads the same log outside any host. The log lives at `$HOST_TEST_LOG_DIR/captures.ndjson` when that variable is set, otherwise `$AGENT_BUNDLE_PLUGIN_ROOT/state/host-test/captures.ndjson` diff --git a/examples/host-test/agent-bundle.config.ts b/examples/host-test/agent-bundle.config.ts index 4696972c5..2250307f3 100644 --- a/examples/host-test/agent-bundle.config.ts +++ b/examples/host-test/agent-bundle.config.ts @@ -23,6 +23,6 @@ export default defineConfig({ // The generated `host-test` MCP server (src/mcp/host-test/tools) hosts the // shared event runtime; `src/mcp/host-test-raw.ts` is a hand-rolled stdio // server that records the raw MCP request envelope for correlation. - // The rendered `src/cli/dump.tsx` command compiles into dist/bin/host-test.js. + // The rendered `src/cli/dump.tsx` command compiles into dist/bin/host-test.mjs. targets: ['claude', 'codex', 'cursor', 'portable'], }); diff --git a/examples/host-test/package.json b/examples/host-test/package.json index d3b3d8809..257e65c66 100644 --- a/examples/host-test/package.json +++ b/examples/host-test/package.json @@ -8,13 +8,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "artifact", - "README.md" - ], - "bin": { - "host-test": "./dist/bin/host-test.js" - }, "scripts": { "build": "agent-bundle build --output artifact", "check": "pnpm validate && pnpm build && pnpm typecheck && pnpm test", diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 8007ae6e5..64b706be5 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -13,7 +13,6 @@ import { build as buildArtifact, type BuildResult } from './build/build.ts'; import { routedCliBins } from './build/cli-bins.ts'; import { planComposite } from './build/compose.ts'; import { buildPackageOutputs, type PackageBuildResult } from './build/package-build.ts'; -import { rewritesWorkspaceProtocols } from './build/pack-dependencies.ts'; import { packInventoryDiagnostics, packOutputFromJson, @@ -1403,7 +1402,7 @@ export const prepack = async (options: BuildOptions): Promise => model: result.model, packageBuild: result.packageBuild, packOutput: pack, - packerRewritesWorkspaceProtocols: rewritesWorkspaceProtocols(process.env.npm_config_user_agent), + packerRewritesWorkspaceProtocols: false, projectRoot: options.root, }); if (hasErrors(diagnostics)) throw new DiagnosticError(diagnostics); diff --git a/packages/agent-bundle/src/build/module-imports.ts b/packages/agent-bundle/src/build/module-imports.ts index d9f9e249c..f0e7057bd 100644 --- a/packages/agent-bundle/src/build/module-imports.ts +++ b/packages/agent-bundle/src/build/module-imports.ts @@ -23,7 +23,7 @@ export interface ModuleImport { * bundler's to guarantee. Re-parsing megabytes of bundler output to prove * it is JavaScript was the dominant cost of every build. * - `parsed`: a full `acorn` parse runs first, so a module the framework did - * not compile — a copied consumer script, a generated installer — keeps + * not compile — a copied consumer script or standalone installer — keeps * the complete syntax check. */ export type ModuleSyntaxCheck = 'lexed' | 'parsed'; diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index ef0e14ea8..4259ecf2e 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -53,7 +53,6 @@ import { isRuntimeStateRemnant, readInstallReceipt, readInstallReceiptFile, - treeInventory, type InstalledTreeComparison, type InstalledTreeOwnership, type InstallReceipt, @@ -74,7 +73,7 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; -import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; +import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; export type DoctorHost = InstallHost; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -1638,7 +1637,7 @@ const publicHostInstallComparison = async ( } let installed: TreeInventory; try { - installed = await treeInventory(entry.installPath); + installed = await installedBundleInventory(entry.installPath, host); } catch (error) { comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index c103d9ca0..8c9530034 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -64,8 +64,8 @@ const registrationLabel = (registration: UninstallResult['registrations'][number registration.id ?? registration.name ?? registration.kind; /** - * Human-readable uninstall summary shared by the CLI and generated installer - * bins. `--plan` output lists every exact path the run would remove, so an + * Human-readable uninstall summary shared by the CLI and standalone artifact + * installer. `--plan` output lists every exact path the run would remove, so an * operator can audit the mutation before allowing it. */ export const formatUninstallResult = (result: UninstallResult): string => { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 75c09ae51..f4f378c87 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -1865,8 +1865,8 @@ it('does not repeat JavaScript diagnostics after a validation-side mutation', as * only the ESM lexer runs over it, so re-parsing megabytes of bundler output * no longer dominates every build, and a bare `export const broken = ;` — * which no bundler emits — passes while unterminated input still fails. A - * module the framework did not compile (a copied consumer script, a - * generated installer) is parsed in full and keeps the complete check. + * module the framework did not compile (a copied consumer script or + * standalone installer) is parsed in full and keeps the complete check. */ it('parses copied and generated modules in full and trusts compiler bundles to the ESM lexer', async () => { const brokenStatement = 'export const broken = ;\n'; diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 9279f75c2..d79d5873e 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -184,12 +184,6 @@ export interface DevHostInstallProofReport { readonly status: 'passed'; } -export interface HostInstallCommand { - readonly cwd?: string; - readonly executable: string; - readonly prefixArguments?: readonly string[]; -} - export interface InstalledHostContractMatrixProofOptions { readonly environment: Readonly; readonly fixtures: Readonly>; @@ -200,7 +194,6 @@ export interface InstalledHostContractMatrixProofOptions { interface HostInstallProofOptions { readonly environment: Readonly; - readonly installCommand?: HostInstallCommand; } /** The same-version rebuild round trip every host proof performs after its first install. */ @@ -437,7 +430,7 @@ const runNodeCli = ( { ...options, timeout: 180_000 }, ); -/** Runs `install` or `uninstall` through the source-built CLI (`--from `) or the packed package bin. */ +/** Runs `install` or `uninstall` through the source-built CLI with `--from `. */ const runLifecycleCommand = ( fixture: BuiltHostInstallFixture, verb: 'install' | 'uninstall', @@ -445,29 +438,14 @@ const runLifecycleCommand = ( bundle: string, options: HostInstallProofOptions, extraArguments: readonly string[] = [], -): Promise => { - if (options.installCommand === undefined) { - return runNodeCli(fixture, [ - verb, - host, - '--from', - bundle, - ...extraArguments, - '--json', - ], { cwd: bundle, environment: isolatedEnvironment(options.environment, {}) }); - } - return run(options.installCommand.executable, [ - ...(options.installCommand.prefixArguments ?? []), +): Promise => runNodeCli(fixture, [ verb, host, + '--from', + bundle, ...extraArguments, '--json', - ], { - cwd: options.installCommand.cwd ?? bundle, - environment: isolatedEnvironment(options.environment, {}), - timeout: 180_000, - }); -}; + ], { cwd: bundle, environment: isolatedEnvironment(options.environment, {}) }); const runInstallCommand = ( fixture: BuiltHostInstallFixture, @@ -998,6 +976,16 @@ export const runClaudeHostInstallProof = async ( }, installedRoot: expectedInstallPath, }); + const doctor = await runDoctor({ + environment, + from: fixture.artifactRoot, + home, + hosts: ['claude'], + }); + assertProof( + doctor.hosts.find((entry) => entry.host === 'claude')?.bundle?.comparison?.status === 'current', + `Doctor did not report the installed Claude npm root as current: ${JSON.stringify(doctor.diagnostics)}`, + ); return Object.freeze({ host: 'claude', install: Object.freeze({ sameVersionRebuild, state: 'installed', version }), @@ -1124,6 +1112,16 @@ export const runCodexHostInstallProof = async ( }, installedRoot: cachePath, }); + const doctor = await runDoctor({ + environment, + from: fixture.artifactRoot, + home, + hosts: ['codex'], + }); + assertProof( + doctor.hosts.find((entry) => entry.host === 'codex')?.bundle?.comparison?.status === 'current', + `Doctor did not report the installed Codex npm root as current: ${JSON.stringify(doctor.diagnostics)}`, + ); return Object.freeze({ host: 'codex', diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index dfe59fdc2..9b925857b 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -30,7 +30,7 @@ const renderTargets = (targets: readonly TargetName[]): string => /** Derived from the shared default list so a change there cannot silently break the drift check. */ const defaultTargetsLiteral = `targets: [${renderTargets(defaultTargets)}]`; -const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor']; +const installableHostNames: readonly TargetName[] = ['claude', 'codex', 'cursor']; export interface ScaffoldRequest { readonly frameworkSpec: string; @@ -61,7 +61,6 @@ export const assertScaffoldTarget = Effect.fnUntraced(function* ( }); interface TemplateManifest { - bin?: Record; dependencies?: Record; devDependencies?: Record; name?: string; @@ -84,17 +83,6 @@ const rewriteManifest = (contents: string, request: ScaffoldRequest, runtimeSpec } } } - if ( - manifest.bin !== undefined && - !request.targets.some((target) => installerTargetNames.includes(target)) - ) { - const suffixedInstallerName = `${request.pluginName}-install`; - const installerName = Object.hasOwn(manifest.bin, suffixedInstallerName) - ? suffixedInstallerName - : request.pluginName; - delete manifest.bin[installerName]; - if (Object.keys(manifest.bin).length === 0) delete manifest.bin; - } return `${JSON.stringify(manifest, null, 2)}\n`; }; @@ -105,28 +93,33 @@ const rewriteConfigTargets = (contents: string, targets: readonly TargetName[]): return contents.replace(defaultTargetsLiteral, `targets: [${renderTargets(targets)}]`); }; -/** The hosts the generated installer bin accepts, in the package build's order. */ +/** Installable hosts in the package build's order. */ const installableHosts = (targets: readonly TargetName[]): readonly TargetName[] => - (['claude', 'codex', 'cursor'] as const) - .filter((host) => targets.includes(host)); + installableHostNames.filter((host) => targets.includes(host)); /** * Template READMEs are written against the default targets, so their install * example names `claude`. The checked-in shape is one shell comment followed - * by `npx install claude`, plus the prose sentence naming the - * ` install ` command; both markers are drift-checked. + * by the public install command for Claude, plus the prose sentence naming the + * generic public install command; both markers are drift-checked. */ -const readmeInstallExample = /^(# after publishing[^\n]*)\n(npx \S+) install claude\n/mu; -const readmeInstallProse = /^Installing the npm package does not mutate any host; run the generated\n`(\S+) install ` command explicitly\.\n/mu; +const readmeInstallExample = + /^(# after publishing[^\n]*)\nnpx agent-bundle install claude --from node_modules\/\S+\n/mu; +const readmeInstallProse = + /^Installing the npm package does not mutate any host; run\n`npx agent-bundle install --from node_modules\/` explicitly\.\n/mu; /** * Rewrite a template README's install instructions for the selected targets: * one example line per installable host, or — when no `claude`, `codex`, or - * `cursor` target is selected and therefore no installer bin is generated — - * an explanation of how to get one. Templates without an install + * `cursor` target is selected — an explanation of how to enable one. + * Templates without an install * section (the skills-only template) pass through unchanged. */ -const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): string => { +const rewriteReadmeInstall = ( + contents: string, + packageName: string, + targets: readonly TargetName[], +): string => { const example = readmeInstallExample.exec(contents); const prose = readmeInstallProse.exec(contents); if (example === null && prose === null) return contents; @@ -136,40 +129,31 @@ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): const hosts = installableHosts(targets); // Every group is unconditional in the patterns above. const comment = example[1] ?? ''; - const exampleBin = example[2] ?? ''; - const proseBin = prose[1] ?? ''; if (hosts.length === 0) { - // The scaffold also dropped this bin mapping from package.json, and the - // build never restores manifest entries, so re-enabling installers needs - // both edits: the config target and the bin entry the npx command resolves. - const binEntry = `"${proseBin}": "./dist/bin/${proseBin}.js"`; return contents .replace(readmeInstallExample, [ - '# no installer bin is generated for these targets; add claude, codex, or cursor', - '# to `targets` in agent-bundle.config.ts and restore the package.json bin entry', - `# ${binEntry} to get one`, + '# no installable host is selected; add claude, codex, or cursor', + '# to `targets` in agent-bundle.config.ts before installing', '', ].join('\n')) .replace(readmeInstallProse, [ 'Installing the npm package does not mutate any host. This project selects', - `no installable host target (${renderTargets(targets)}), so no \`${proseBin} install\``, - 'command is generated and its `bin` entry was dropped from `package.json`. To', - 'generate one, add `claude`, `codex`, or `cursor` to `targets` in', - `\`agent-bundle.config.ts\` and restore \`${binEntry}\` under \`bin\` in`, - '`package.json`; the build emits the installer file but never edits the manifest.', + `no installable host target (${renderTargets(targets)}). Add \`claude\`, \`codex\`,`, + 'or `cursor` to `targets` in `agent-bundle.config.ts` before using', + '`agent-bundle install`.', '', ].join('\n')); } return contents .replace(readmeInstallExample, [ comment, - ...hosts.map((host) => `${exampleBin} install ${host}`), + ...hosts.map((host) => `npx agent-bundle install ${host} --from node_modules/${packageName}`), '', ].join('\n')) .replace(readmeInstallProse, [ - 'Installing the npm package does not mutate any host; run the generated', - `\`${proseBin} install \` command explicitly. The installer accepts the`, - `selected host targets only: ${hosts.map((host) => `\`${host}\``).join(', ')}.`, + 'Installing the npm package does not mutate any host; run', + `\`npx agent-bundle install --from node_modules/${packageName}\` explicitly.`, + `The package contains these selected host targets: ${hosts.map((host) => `\`${host}\``).join(', ')}.`, '', ].join('\n')); }; @@ -177,9 +161,8 @@ const rewriteReadmeInstall = (contents: string, targets: readonly TargetName[]): /** * Copy one template directory into the target, substituting the placeholder * project name in every file, rewriting `package.json` (real package name, - * `workspace:*` framework placeholder pinned to the resolved spec, installer - * bins omitted when no installable host is selected), the config's target - * list, and the README's install instructions. Returns the emitted + * `workspace:*` framework placeholder pinned to the resolved spec), the + * config's target list, and the README's install instructions. Returns the emitted * project-relative paths, sorted. */ export const scaffold = Effect.fnUntraced(function* ( @@ -219,7 +202,9 @@ export const scaffold = Effect.fnUntraced(function* ( // the defect crosses the boundary as the same Error it always was. if (relativePath === 'package.json') contents = rewriteManifest(contents, request, runtimeSpec); if (relativePath === 'agent-bundle.config.ts') contents = rewriteConfigTargets(contents, request.targets); - if (relativePath === 'README.md') contents = rewriteReadmeInstall(contents, request.targets); + if (relativePath === 'README.md') { + contents = rewriteReadmeInstall(contents, request.packageName, request.targets); + } yield* fs.writeFileString(destination, contents); emitted.push(relativePath); } diff --git a/packages/create-agent-bundle/templates/cli-tool/README.md b/packages/create-agent-bundle/templates/cli-tool/README.md index ff8a4d165..2ca7c89f1 100644 --- a/packages/create-agent-bundle/templates/cli-tool/README.md +++ b/packages/create-agent-bundle/templates/cli-tool/README.md @@ -4,7 +4,7 @@ A routed command-line tool and library built with [agent-bundle](https://github.com/ScriptedAlchemy/agent-bundle). There is no second bundler config, no hand-written bin shim, and no argv parser: the `src/cli/**` convention compiles each command module into one generated -executable, `dist/bin/my-agent-plugin.js`, with help, argv grammar, input +executable, `dist/bin/my-agent-plugin.mjs`, with help, argv grammar, input validation, and exit codes derived from the module's own `config` and zod schemas. `src/index.ts` is the library export with declarations, and `src/scripts/hello.ts` ships as a plain script inside every host artifact. One @@ -20,17 +20,19 @@ npm run test # plain module tests npm run test:projection # cli-dispatch + script-dispatch pool # after a build -node dist/bin/my-agent-plugin.js greet World -node dist/bin/my-agent-plugin.js greet World --shout -node dist/bin/my-agent-plugin.js greet --help +node dist/bin/my-agent-plugin.mjs greet World +node dist/bin/my-agent-plugin.mjs greet World --shout +node dist/bin/my-agent-plugin.mjs greet --help node artifact/scripts/hello.mjs World # after publishing/installing the package -npx my-agent-plugin-install install claude +npx agent-bundle install claude --from node_modules/my-agent-plugin ``` -Installing the npm package does not mutate any host; run the generated -`my-agent-plugin-install install ` command explicitly. +Installing the npm package does not mutate any host; run +`npx agent-bundle install --from node_modules/` explicitly. +Validate and publish the generated npm root with +`npm run pack:check && npm publish ./dist --ignore-scripts`. ## Layout diff --git a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts index 81eab410f..dafded6ca 100644 --- a/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/cli-tool/agent-bundle.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ // `--output` flag still wins. // output: { distPath: 'artifact' }, // No `bin` or `scripts` fields needed: the routed `src/cli/**` commands - // compile into the package executable (dist/bin/my-agent-plugin.js), the + // compile into the shared artifact/package executable (bin/my-agent-plugin.mjs), the // conventional `src/scripts/hello.ts` ships as `scripts/hello.mjs` inside // every host artifact, and `src/index.ts` becomes the library export with // declarations — all by convention. diff --git a/packages/create-agent-bundle/templates/cli-tool/package_json b/packages/create-agent-bundle/templates/cli-tool/package_json index 6680c8ef7..cf917cae6 100644 --- a/packages/create-agent-bundle/templates/cli-tool/package_json +++ b/packages/create-agent-bundle/templates/cli-tool/package_json @@ -6,15 +6,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "dist", - "artifact", - "README.md" - ], - "bin": { - "my-agent-plugin": "./dist/bin/my-agent-plugin.js", - "my-agent-plugin-install": "./dist/bin/my-agent-plugin-install.js" - }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -25,7 +16,7 @@ "build": "agent-bundle build --json --output artifact", "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:projection", "dev": "agent-bundle dev", - "prepack": "agent-bundle prepack --json --output artifact", + "pack:check": "agent-bundle prepack --json --output artifact", "test": "rstest tests --exclude \"tests/projection/**\"", "test:projection": "rstest --config rstest.projection.config.ts", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts b/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts index a5a5ff573..3726787df 100644 --- a/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts +++ b/packages/create-agent-bundle/templates/cli-tool/tests/projection/cli-dispatch.test.ts @@ -6,7 +6,7 @@ import { cliJson, invokeCli, testManifest } from 'agent-bundle/test'; * CLI's own shell — command resolution, the compiled argv grammar, generated * help, input validation, and exit-code mapping are the product's — in this * process. Nothing is bundled or spawned; the generated - * `dist/bin/my-agent-plugin.js` executable is proven by `npm run build`. + * `dist/bin/my-agent-plugin.mjs` executable is proven by `npm run build`. */ it('compiles the greet command without a build', () => { const manifest = testManifest(); diff --git a/packages/create-agent-bundle/templates/mcp-server/README.md b/packages/create-agent-bundle/templates/mcp-server/README.md index fd03656f1..b6db8193b 100644 --- a/packages/create-agent-bundle/templates/mcp-server/README.md +++ b/packages/create-agent-bundle/templates/mcp-server/README.md @@ -17,11 +17,13 @@ npm run test:projection # in-memory MCP projection pool npx agent-bundle mcp list --server status --target portable --artifact artifact # after publishing/removing "private" and installing the package -npx my-agent-plugin install claude +npx agent-bundle install claude --from node_modules/my-agent-plugin ``` -Installing the npm package does not mutate any host; run the generated -`my-agent-plugin install ` command explicitly. +Installing the npm package does not mutate any host; run +`npx agent-bundle install --from node_modules/` explicitly. +Validate and publish the generated npm root with +`npm run pack:check && npm publish ./dist --ignore-scripts`. ## Layout diff --git a/packages/create-agent-bundle/templates/mcp-server/package_json b/packages/create-agent-bundle/templates/mcp-server/package_json index ae5bcca22..dc12ec721 100644 --- a/packages/create-agent-bundle/templates/mcp-server/package_json +++ b/packages/create-agent-bundle/templates/mcp-server/package_json @@ -7,14 +7,6 @@ "engines": { "node": ">=22.19.0" }, - "files": [ - "dist", - "artifact", - "README.md" - ], - "bin": { - "my-agent-plugin": "./dist/bin/my-agent-plugin.js" - }, "exports": { ".": { "types": "./dist/status.d.ts", @@ -25,7 +17,7 @@ "build": "agent-bundle build --json --output artifact", "check": "npm run validate && npm run build && npm run typecheck && npm run test && npm run test:routes && npm run test:projection", "dev": "agent-bundle dev", - "prepack": "agent-bundle prepack --json --output artifact", + "pack:check": "agent-bundle prepack --json --output artifact", "test": "rstest tests --exclude \"tests/{route-unit,projection}/**\"", "test:projection": "rstest --config rstest.projection.config.ts", "test:routes": "rstest --config rstest.route-unit.config.ts", diff --git a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts index 71f633f17..0d9358063 100644 --- a/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts +++ b/packages/create-agent-bundle/tests/scaffold-packed-matrix.e2e.test.ts @@ -37,7 +37,7 @@ it.concurrent('scaffolds the mcp-server template and serves the conventional ent const checked = await npmRun(projectRoot, 'check'); await expectCleanValidate(projectRoot); - await npmRun(projectRoot, 'prepack'); + await npmRun(projectRoot, 'pack:check'); // The template's own harness pools ran inside `check`, and they are asserted // positively — a silent `check` would also pass if the pools were dropped or @@ -88,7 +88,7 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif expect(checked).toContain('tests/projection/cli-dispatch.test.ts'); expect(checked).toContain('tests/projection/script-dispatch.test.ts'); await expectCleanValidate(projectRoot); - await npmRun(projectRoot, 'prepack'); + await npmRun(projectRoot, 'pack:check'); // The projection pool dispatches through the framework's own generated // setup, resolved from the packed tarball's `agent-bundle/rstest` export. await expectPassedPool(projectRoot, 'test:projection', [ @@ -98,7 +98,7 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif // The src/cli/** convention produced the routed executable package bin: // generated help, the compiled argv grammar, and one canonical JSON line. - const bin = join(projectRoot, 'dist', 'bin', 'greeter.js'); + const bin = join(projectRoot, 'dist', 'bin', 'greeter.mjs'); expect((await stat(bin)).mode & 0o111).not.toBe(0); expect((await readFile(bin, 'utf8')).startsWith('#!/usr/bin/env node\n')).toBe(true); const environment = installedEnvironment(); @@ -131,15 +131,15 @@ it.concurrent('scaffolds the cli-tool template with a routed bin, lib, and artif try { const { stdout } = await execFile('npm', [ 'pack', '--json', '--ignore-scripts', '--pack-destination', packDestination, - ], { cwd: projectRoot, env: installedEnvironment() }); + ], { cwd: join(projectRoot, 'dist'), env: installedEnvironment() }); // packOutputFromJson handles both npm pack --json shapes (array and // package-keyed object), unlike a bare array destructure. const packedPaths = packOutputFromJson(stdout).files.map((file) => file.path); - expect(packedPaths).toContain('artifact/agent-bundle.manifest.json'); - expect(packedPaths).toContain('artifact/plugin.json'); - expect(packedPaths).toContain('artifact/.codex-plugin/plugin.json'); - expect(packedPaths).toContain('artifact/.claude-plugin/plugin.json'); - expect(packedPaths).toContain('dist/bin/greeter-install.js'); + expect(packedPaths).toContain('agent-bundle.manifest.json'); + expect(packedPaths).toContain('plugin.json'); + expect(packedPaths).toContain('.codex-plugin/plugin.json'); + expect(packedPaths).toContain('.claude-plugin/plugin.json'); + expect(packedPaths).toContain('bin/greeter.mjs'); } finally { await rm(packDestination, { force: true, recursive: true }); } diff --git a/packages/create-agent-bundle/tests/scaffold.test.ts b/packages/create-agent-bundle/tests/scaffold.test.ts index 5a7e541f3..b51b721b3 100644 --- a/packages/create-agent-bundle/tests/scaffold.test.ts +++ b/packages/create-agent-bundle/tests/scaffold.test.ts @@ -118,51 +118,46 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys ]); })); - it.effect('scaffolds publishable package fields for templates with package builds', () => Effect.gen(function* () { + it.effect('scaffolds canonical npm-root package commands', () => Effect.gen(function* () { const path = yield* Path.Path; const [cliTool, mcpServer] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter' }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin' }), ], { concurrency: 'unbounded' }); const cliManifest = yield* readJson<{ - readonly bin: Record; - readonly files: readonly string[]; + readonly bin?: Record; + readonly files?: readonly string[]; readonly scripts: Record; }>(path.join(cliTool.root, 'package.json')); - expect(cliManifest.files).toEqual(['dist', 'artifact', 'README.md']); - expect(cliManifest.bin).toEqual({ - greeter: './dist/bin/greeter.js', - 'greeter-install': './dist/bin/greeter-install.js', - }); - expect(cliManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + expect(cliManifest.files).toBeUndefined(); + expect(cliManifest.bin).toBeUndefined(); + expect(cliManifest.scripts['pack:check']).toBe('agent-bundle prepack --json --output artifact'); const mcpManifest = yield* readJson<{ - readonly bin: Record; + readonly bin?: Record; readonly exports: Record; - readonly files: readonly string[]; + readonly files?: readonly string[]; readonly private: boolean; readonly scripts: Record; }>(path.join(mcpServer.root, 'package.json')); expect(mcpManifest.private).toBe(true); - expect(mcpManifest.files).toEqual(['dist', 'artifact', 'README.md']); - expect(mcpManifest.bin).toEqual({ 'status-plugin': './dist/bin/status-plugin.js' }); + expect(mcpManifest.files).toBeUndefined(); + expect(mcpManifest.bin).toBeUndefined(); expect(mcpManifest.exports).toEqual({ '.': { types: './dist/status.d.ts', import: './dist/status.js' }, }); - expect(mcpManifest.scripts.prepack).toBe('agent-bundle prepack --json --output artifact'); + expect(mcpManifest.scripts['pack:check']).toBe('agent-bundle prepack --json --output artifact'); expect(yield* readText(path.join(mcpServer.root, 'agent-bundle.config.ts'))).toContain("lib: './src/status.ts'"); })); - it.effect('drops generated installer bins when no installable host target is selected', () => Effect.gen(function* () { + it.effect('keeps source package metadata independent of selected hosts', () => Effect.gen(function* () { const path = yield* Path.Path; const [cliTool, mcpServer] = yield* Effect.all([ scaffoldTemplate('cli-tool', { pluginName: 'greeter', targets: ['portable'] }), scaffoldTemplate('mcp-server', { pluginName: 'status-plugin', targets: ['portable'] }), ], { concurrency: 'unbounded' }); const cliManifest = yield* readJson<{ readonly bin?: Record }>(path.join(cliTool.root, 'package.json')); - expect(cliManifest.bin).toEqual({ - greeter: './dist/bin/greeter.js', - }); + expect(cliManifest.bin).toBeUndefined(); const mcpManifest = yield* readJson<{ readonly bin?: Record }>(path.join(mcpServer.root, 'package.json')); expect(mcpManifest.bin).toBeUndefined(); @@ -183,44 +178,32 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys const defaultReadme = yield* readText(path.join(defaults.root, 'README.md')); expect(defaultReadme).toContain([ '# after publishing/installing the package', - 'npx greeter-install install claude', - 'npx greeter-install install codex', + 'npx agent-bundle install claude --from node_modules/status-plugin', + 'npx agent-bundle install codex --from node_modules/status-plugin', '', ].join('\n')); expect(defaultReadme).not.toContain('install cursor'); - expect(defaultReadme).toContain('The installer accepts the\nselected host targets only: `claude`, `codex`.'); + expect(defaultReadme).toContain('The package contains these selected host targets: `claude`, `codex`.'); - // A cursor-only scaffold's installer rejects `claude`, so the README must - // not suggest it. + // A cursor-only scaffold must not suggest unavailable host roots. const cursorReadme = yield* readText(path.join(cursorOnly.root, 'README.md')); - expect(cursorReadme).toContain('npx status-plugin install cursor\n'); + expect(cursorReadme).toContain('npx agent-bundle install cursor --from node_modules/status-plugin\n'); expect(cursorReadme).not.toContain('install claude'); expect(cursorReadme).not.toContain('install codex'); // Selecting every host installs into every host. const everyHostReadme = yield* readText(path.join(everyHost.root, 'README.md')); expect(everyHostReadme).toContain([ - 'npx status-plugin install claude', - 'npx status-plugin install codex', - 'npx status-plugin install cursor', + 'npx agent-bundle install claude --from node_modules/status-plugin', + 'npx agent-bundle install codex --from node_modules/status-plugin', + 'npx agent-bundle install cursor --from node_modules/status-plugin', ].join('\n')); - // Portable-only scaffolds ship no installer bin at all. + // Portable-only scaffolds name no install command. const portableReadme = yield* readText(path.join(portableOnly.root, 'README.md')); - expect(portableReadme).not.toMatch(/^npx \S+ install /mu); + expect(portableReadme).not.toMatch(/^npx agent-bundle install /mu); expect(portableReadme).toContain("no installable host target ('portable')"); - expect(portableReadme).toContain('add `claude`, `codex`, or `cursor` to `targets`'); - // Re-enabling installers needs the dropped package.json bin entry back too, - // and the README names exactly the mapping the template shipped. - const templateManifest = yield* readJson<{ readonly bin: Record }>( - path.join(templateRoot(path, 'cli-tool'), 'package_json'), - ); - const installerBin = `${placeholderName}-install`; - expect(templateManifest.bin[installerBin]).toBeDefined(); - const droppedEntry = `"greeter-install": "${templateManifest.bin[installerBin]?.replaceAll(placeholderName, 'greeter')}"`; - expect(portableReadme).toContain(`# ${droppedEntry} to get one`); - expect(portableReadme).toContain(`restore \`${droppedEntry}\` under \`bin\` in`); - expect(portableReadme).toContain('never edits the manifest'); + expect(portableReadme).toContain('Add `claude`, `codex`,'); // The skills-only template has no install section and passes through. const minimalReadme = yield* readText(path.join(minimal.root, 'README.md')); @@ -280,7 +263,7 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(contents).not.toContain('workspace:*'); } const manifest = yield* readJson<{ - readonly bin: Record; + readonly bin?: Record; readonly dependencies?: Record; readonly devDependencies: Record; readonly name: string; @@ -290,10 +273,7 @@ layer(NodeServices.layer, { excludeTestServices: true })('scaffold (real filesys expect(files).toContain('src/cli/greet.ts'); expect(manifest.dependencies).toBeUndefined(); expect(manifest.devDependencies['@agent-bundle/runtime']).toBe(runtimeSpecForFramework(frameworkSpec)); - expect(manifest.bin).toEqual({ - 'status-plugin': './dist/bin/status-plugin.js', - 'status-plugin-install': './dist/bin/status-plugin-install.js', - }); + expect(manifest.bin).toBeUndefined(); const config = yield* readText(path.join(root, 'agent-bundle.config.ts')); expect(config).toContain("name: 'status-plugin'"); // Routed CLI: no `scripts` or `bin` entry names the executable; the diff --git a/website/docs/en/examples/audiobook-curator.mdx b/website/docs/en/examples/audiobook-curator.mdx index 694d30a22..a5921ca7d 100644 --- a/website/docs/en/examples/audiobook-curator.mdx +++ b/website/docs/en/examples/audiobook-curator.mdx @@ -57,7 +57,7 @@ those features, not the build. This example has no hooks. progress token, then replaces it with the completed analysis without changing the final structured receipt. No `progress.report()` call repeats the message. - **The framework owns the package build.** The config declares no `bin` or `scripts` field. The routed - `src/cli/` commands compile into `dist/bin/audiobook-curator.js` for `package.json` `bin`, and + `src/cli/` commands compile into `dist/bin/audiobook-curator.mjs` for `package.json` `bin`, and `dist/index.js` plus declarations serve `exports`. See [CLI and library package entries](../guide/authoring/package-entries.mdx). - **`package.json` is the single version source.** The config declares no `plugin.version`; the @@ -110,7 +110,7 @@ directory already on `PATH`: ```sh cd examples/audiobook-curator -ln -s "$(pwd)/dist/bin/audiobook-curator.js" ~/.local/bin/audiobook-curator +ln -s "$(pwd)/dist/bin/audiobook-curator.mjs" ~/.local/bin/audiobook-curator audiobook-curator --help ``` diff --git a/website/docs/en/guide/authoring/package-entries.mdx b/website/docs/en/guide/authoring/package-entries.mdx index 2b9c859a0..cfa8c3499 100644 --- a/website/docs/en/guide/authoring/package-entries.mdx +++ b/website/docs/en/guide/authoring/package-entries.mdx @@ -443,6 +443,13 @@ from the artifact. The tarball therefore has one plugin root and one routed CLI, `artifact/` directory or package-relative installer wrapper. No npm lifecycle performs a host installation. +The generated root owns its inventory. Source `files` and `bin` fields are replaced; package entry +paths (`exports`, `imports`, `main`, `module`, `types`, and `typesVersions`) are rebased from +`dist/` to the generated root. Pack and publish hooks are removed, while other scripts are copied +unchanged and must already name paths relative to that root. Only manifest-owned artifact files, +compiled package entries, and standard README, license, and notice files are carried; arbitrary +source-project extras are not. + ```sh npx agent-bundle prepack --root . --output artifact --json ``` @@ -451,7 +458,8 @@ npx agent-bundle prepack --root . --output artifact --json 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 -the gate first, then publish the npm root it validated: +the gate directly rather than relying on a source-package lifecycle hook, then publish the npm root +it validated: ```sh npm publish ./dist --ignore-scripts diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index b0c6c3637..4b73269dc 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -43,7 +43,7 @@ dynamic imports, and targets missing from or outside the emitted tree; a `dist` file as `dist/`. How thoroughly syntax is checked follows who produced the bytes. A module the framework compiled (manifest kind `bundle`) is the bundler's own output, so only the ESM lexer runs over it, rejecting unterminated strings, templates, comments, and regexps and unbalanced -braces. A module the framework did not compile — a copied consumer script or generated installer +braces. A module the framework did not compile — a copied consumer script or standalone installer — is parsed in full, as is every bundle of a build whose [`tools` hatch](../../reference/configuration.mdx#tools) could have rewritten the emitted assets. Prebuilt payloads (`kind: 'prebuilt'`) stay opaque and hash-locked only, and declaration files @@ -252,6 +252,10 @@ npm cannot install. | `AB7014` | A `dependencies`, `optionalDependencies`, or `peerDependencies` field names packages nothing in the pack uses — no packed JavaScript imports, requires, or resolves them (compiled bundles inline their imports and `AB6005` fails one they kept external, so `import` evidence comes from prebuilt payload modules and other scripts the framework did not compile, while `require`, `createRequire`, and `import.meta.resolve` calls count from any packed file), no packed declaration references them, no `imports` mapping or consumer install script reaches them, and no prebuilt payload names them in `runtimeDependencies` (one diagnostic per field; optional peers are skipped here but their specifier is still checked by `AB7015`, and an `optionalDependencies` entry supersedes the same name under `dependencies`). Every consumer would install them for nothing. Move build-only packages to `devDependencies`. For `peerDependencies` this is a warning, since a required peer nothing imports may be a deliberate host-compatibility contract; mark it optional in `peerDependenciesMeta` if npm should stop installing it. | | `AB7015` | A `dependencies`, `optionalDependencies`, or `peerDependencies` entry a consumer's npm cannot resolve through a registry. Name and specifier are read with `npm-package-arg`, npm's own parser, and come out as one of three kinds: registry (a version, range, dist-tag, or `npm:` alias of one), fetched (a git, GitHub-shorthand, remote-tarball, or path source — npm 12 refuses git and remote fetches by default (`allow-git`, `allow-remote`), and a path never exists on the consumer's disk), or unparseable (a name npm rejects, a scheme it lacks such as `link:`, `portal:`, or a typo, a selector that is neither a range nor a URL-safe dist-tag, an alias of a non-registry target, or an invalid URL — the manifest read itself fails, so this is reported even on an optional peer). A fetched `optionalDependencies` entry warns instead of failing, since npm continues without it; it stays an error when unparseable, or when a consumer install script needs the skipped package (runs its command — not merely mentions it — loads it from an inline `node -e` program — scanned for `require`, `createRequire`, and `import()` like a packed file — preloads it with `node -r`/`--require`/`--import`/`--loader`, or runs a packed file that imports it, `node .` running the root `main` included; each command after `&&`, `;`, or a newline is read on its own, shell quotes and backslash escapes resolved, and `node`'s options belong to `node` alone and end at the program — `node install.js --require x` preloads nothing, while a `NODE_OPTIONS=--require=x` assignment on the same `node` command does; every script word naming a packed JavaScript file counts as run, deliberately, so that runners the gate does not model — `tsx`, `zx`, `bun`, `deno run` — still have their file's dependencies traced, at the cost of a rare escalation for a word such as `echo install.js`, which the diagnostic makes visible by naming the file). A peer that `dependencies` or `optionalDependencies` also names is judged by that concrete entry; npm never reads the duplicate peer's selector. `workspace:`/`catalog:` are reported too unless the `prepack` lifecycle runs under pnpm, Yarn, or Bun, which rewrite them at pack time — `npm publish` does not, so under npm `AB7015` fires; entries the tarball itself carries — `bundleDependencies` npm actually packed (never peers), and a `file:` path inside the package whose packed source is installable (a directory with a parseable `package.json`, or a well-formed tarball whose `package.json` parses) — are not reported. Depend on a published version, or bundle the package and declare it under `devDependencies`. | +For the canonical `dist/` npm root, `prepack` validates the exact input to +`npm publish ./dist`; it therefore always reports `workspace:` and `catalog:` +runtime specifiers, regardless of the package manager that launched the command. + A prebuilt payload's `runtimeDependencies` declaration counts as use, since the compiler never opens a payload file. The remaining dependency evidence is read from the packed bytes: every `.js`/`.mjs`/`.cjs` file npm would publish is lexed for `import` specifiers and scanned for literal `require("…")`, `require.resolve("…")`, and diff --git a/website/docs/zh/examples/audiobook-curator.mdx b/website/docs/zh/examples/audiobook-curator.mdx index a40d23435..9b8aafa14 100644 --- a/website/docs/zh/examples/audiobook-curator.mdx +++ b/website/docs/zh/examples/audiobook-curator.mdx @@ -46,7 +46,7 @@ description: '有声书策展器示例:一个由路由模块、请求上下文 发送给传入了 progress token 的客户端,再用完成后的分析替换它,而最终的结构化收据不变。没有任何 `progress.report()` 调用重复这条消息。 - **包构建归框架所有。** 配置没有声明 `bin`,也没有声明 `scripts`。`src/cli/` 下的路由命令编译为 - `dist/bin/audiobook-curator.js` 以服务 `package.json` 的 `bin`,而 `dist/index.js` 加声明文件服务 + `dist/bin/audiobook-curator.mjs` 以服务 `package.json` 的 `bin`,而 `dist/index.js` 加声明文件服务 `exports`。见 [CLI 与库包入口](../guide/authoring/package-entries.mdx)。 - **`package.json` 是唯一的版本来源。** 配置没有声明 `plugin.version`;解析出的版本会流入项目上下文、 产物清单、`inspect` 输出、dev 状态,以及这个插件导入的 `agent-bundle/meta` 常量。 @@ -92,7 +92,7 @@ pnpm --filter @agent-bundle-example/audiobook-curator typecheck ```sh cd examples/audiobook-curator -ln -s "$(pwd)/dist/bin/audiobook-curator.js" ~/.local/bin/audiobook-curator +ln -s "$(pwd)/dist/bin/audiobook-curator.mjs" ~/.local/bin/audiobook-curator audiobook-curator --help ``` diff --git a/website/docs/zh/guide/authoring/package-entries.mdx b/website/docs/zh/guide/authoring/package-entries.mdx index 1e7f881da..3882f008c 100644 --- a/website/docs/zh/guide/authoring/package-entries.mdx +++ b/website/docs/zh/guide/authoring/package-entries.mdx @@ -396,13 +396,20 @@ import meta, { name, packageName, packageVersion, version } from 'agent-bundle/m `bin/.mjs`。因此 tarball 只有一个插件根和一个路由式 CLI,没有嵌套的 `artifact/` 目录或 相对包的安装器包装器。没有任何 npm 生命周期会执行宿主安装。 +生成的根目录拥有自己的文件清单。源项目的 `files` 与 `bin` 字段会被替换;包入口路径 +(`exports`、`imports`、`main`、`module`、`types` 与 `typesVersions`)会从 `dist/` 重定位到 +生成根目录。pack 与 publish 钩子会被移除,其他脚本保持原样,因此必须已经使用相对该根目录的路径。 +只有清单拥有的产物文件、编译后的包入口以及标准 README、license 与 notice 文件会被携带;任意的 +源项目额外文件不会被复制。 + ```sh npx agent-bundle prepack --root . --output artifact --json ``` `prepack` 运行发布构建与 `npm pack --dry-run --json --ignore-scripts`,随后对精确的包与产物清单、 清单哈希、包 bin 目标以及发布版本一致性设卡。生成的包元数据会移除 pack 与 publish 生命周期钩子, -避免源项目的 `prepack` 从 `dist/` 递归;消费者安装脚本保持不变。先运行门禁,再发布它已经校验的 npm 根目录: +避免源项目的 `prepack` 从 `dist/` 递归;消费者安装脚本保持不变。请直接运行门禁,不要依赖源包的 +生命周期钩子,然后发布它已经校验的 npm 根目录: ```sh npm publish ./dist --ignore-scripts diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 59cc236f7..b548eaa6a 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -35,7 +35,7 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, 不受支持导入、非字面量动态 import,以及输出树中缺失或逃逸到树外的目标;`dist` 中的发现以 `dist/` 点名其文件。模块语法检查的深度取决于它的字节由谁产出。框架编译的模块(清单 kind 为 `bundle`)是打包器自己的输出,因此只由 ESM 词法分析器扫描,拒绝未终止的字符串、模板、注释与正则以及 -不配对的花括号。框架没有编译的模块——被复制的消费者脚本或生成的安装器——会被完整解析;若一次构建的 +不配对的花括号。框架没有编译的模块——被复制的消费者脚本或独立安装器——会被完整解析;若一次构建的 [`tools` 逃生口](../../reference/configuration.mdx#tools)可能改写了输出资源,该构建的每个 bundle 也会 被完整解析。预构建载荷(`kind: 'prebuilt'`)保持不透明,只做哈希锁定;声明文件(`.d.ts`)不会被遍历。 路由图会在打包器运行之前守住同一份自包含性: @@ -215,6 +215,10 @@ CLI、MCP 入口、钩子包装层以及包专用 JavaScript bundle——都以 | `AB7014` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 字段里列出的包在整个包里无人使用——没有打包后的 JavaScript 导入、require 或解析它(已编译的 bundle 会内联自己的导入,被保持外部化的导入会被 `AB6005` 判为失败,因此 `import` 证据只来自预构建 payload 模块以及其他框架没有编译的脚本,而 `require`、`createRequire` 与 `import.meta.resolve` 调用则可来自任何打包后的文件),没有打包后的声明文件引用它,也没有 `imports` 映射或消费者侧安装脚本触及它,也没有任何预构建 payload 在 `runtimeDependencies` 中声明它(每个字段一条诊断;可选 peer 在此被跳过,但其说明符仍由 `AB7015` 检查,`optionalDependencies` 中的同名项优先于 `dependencies`)。每位消费者都会白白安装它们。请把仅构建期需要的包移到 `devDependencies`。对 `peerDependencies` 而言这是警告:无人导入的必需 peer 可能是有意的宿主兼容性约束;若希望 npm 不再安装它,请在 `peerDependenciesMeta` 中标记为可选。 | | `AB7015` | `dependencies`、`optionalDependencies` 或 `peerDependencies` 中的某一项无法被消费者的 npm 经由注册表解析。包名与说明符一并交给 npm 自己的解析器 `npm-package-arg` 读取,结果分为三类:注册表类(版本、范围、dist-tag,或指向它们的 `npm:` 别名)、抓取类(git、GitHub 简写、远程 tarball 或路径来源——npm 12 默认拒绝 git 与远程抓取(`allow-git`、`allow-remote`),而路径在消费者磁盘上并不存在),以及不可解析类(npm 不接受的包名、它不支持的协议如 `link:`、`portal:` 或拼写错误、既非范围也非 URL 安全 dist-tag 的选择器、指向非注册表目标的别名,或非法 URL——清单本身就读不下去,因此即使出现在可选 peer 上也会被报告)。`optionalDependencies` 中的抓取类项只发出警告而不会失败,因为 npm 会继续安装;不可解析的项,或消费者安装脚本需要的项(运行其命令——而非仅仅提及它——在内联的 `node -e` 程序中加载它——该程序像已打包文件一样被扫描 `require`、`createRequire` 与 `import()`——用 `node -r`/`--require`/`--import`/`--loader` 预加载它,或运行某个导入它的已打包文件,包括通过根 `main` 运行的 `node .`;`&&`、`;` 或换行之后的每条命令单独判读,shell 引号与反斜杠转义均已解析,`node` 的选项只属于 `node` 且到程序为止——`node install.js --require x` 不会预加载任何东西,而同一条 `node` 命令上的 `NODE_OPTIONS=--require=x` 赋值会;脚本中每个点名已打包 JavaScript 文件的词都视为被运行,这是有意为之,使本闸门未建模的运行器——`tsx`、`zx`、`bun`、`deno run`——所运行文件的依赖仍被追踪,代价是像 `echo install.js` 这样的词偶有误升级,诊断会点名该文件使之可见),仍是错误。若 `dependencies` 或 `optionalDependencies` 也声明了同名 peer,则只按该具体条目判断;npm 从不读取重复 peer 的选择器。`workspace:`/`catalog:` 同样会被报告,除非 `prepack` 生命周期运行在 pnpm、Yarn 或 Bun 之下——它们会在打包时重写这些协议;`npm publish` 不会重写,因此在 npm 下 `AB7015` 会触发;tarball 自身携带的项——`bundleDependencies` 中 npm 实际打包进去的项(peer 除外),以及已打包来源可供安装的包内 `file:` 路径(带有可解析 `package.json` 的目录,或格式正确且其 `package.json` 可解析的 tarball)——不会被报告。请依赖已发布的版本,或把该包打包进产物并声明在 `devDependencies` 下。 | +对于规范的 `dist/` npm 根目录,`prepack` 校验的是 +`npm publish ./dist` 的准确输入;因此无论哪个包管理器启动该命令,运行时依赖中的 +`workspace:` 与 `catalog:` 说明符都会被报告。 + 预构建 payload 的 `runtimeDependencies` 声明视为已使用,因为编译器从不打开 payload 文件。其余依赖证据直接读取自打包后的字节:npm 将发布的每个 `.js`/`.mjs`/`.cjs` 文件都会被词法分析出 `import` 说明符,并扫描字面量 `require("…")`、`require.resolve("…")` 与 `import.meta.resolve("…")` 调用(`createRequire(…)` 的绑定视同 `require`,即使导入时重命名了该工厂,直接调用的 `Module.createRequire(…)("…")` 与 `require("node:module").createRequire(…)("…")` 亦然;`path.resolve("…")` 不计入);每个打包后的 `.d.ts` 会被扫描其类型所引用的模块(`/// ` 指令同时计入该包及其 `@types/*` 对应包)。说明符先解码字符串转义再归约为包名,忽略 Node 内建模块。打包代码作为可执行文件运行的依赖——字符串字面量正是其已安装清单所声明的某个 `bin` 命令,如 `spawnSync("tsc", ["--version"])`——视为已使用;`#子路径` 导入计入 `imports` 映射所指向的每个包;消费者侧 `preinstall`/`install`/`postinstall` 脚本(不含 `prepare`——npm 从不为已发布的 tarball 运行它)或其通过 `npm run`(取 `run` 之后的第一个位置参数;`run` 前后的选项,带值与否均被跳过,其后的词如 `npm run setup -- dormant` 中的 `dormant` 是该脚本的参数)或直接的 `npm test`/`start`/`stop`/`restart`(没有 `restart` 脚本时,`npm restart` 依次运行 `stop` 与 `start`)委托的任何脚本点名(或运行其 `bin` 命令;字符串形式的 `bin` 以已安装清单的名字命名——清单按 npm 的方式读取,重复键以最后一个为准——依赖未在本地安装或其清单不是 JSON 时以去掉作用域的包名代替)的依赖 From 15e9366d4df2681706fb54b2e47f5c9899d9be78 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:38:56 +0000 Subject: [PATCH 11/11] fix(build): reconcile latest artifact metadata --- packages/agent-bundle/src/build/package-build.ts | 1 - packages/agent-bundle/src/build/validate-artifact.ts | 1 + packages/agent-bundle/tests/uninstall.test.ts | 5 +++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 38f830961..0d513de3f 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -16,7 +16,6 @@ import type { } from '../core/types.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { assertInside, toPosixRelative } from '../core/paths.ts'; -import { cliBinSourceInputs } from './cli-bins.ts'; import { createCompileEvidenceRecord, type CompileEvidenceRecord, diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index fb7a2866d..59fdcf67b 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -535,6 +535,7 @@ const validateArtifactOwnership = (options: { for (const file of options.files) { if (admitsEverything) continue; + if (artifactRootMetadata.has(file.path)) continue; if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; // Prebuilt payload files live in config-named directories under the // root, so no emitted layout describes them. diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 502caee2e..8de9bd7df 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -426,6 +426,11 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = }, }), ]); + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: 'uninstall-fixture', version: '1.2.3' }, + [{ host: 'cursor' }], + ); await installBundle(options); await mkdir(declaredStateRoot, { recursive: true }); await writeFile(join(declaredStateRoot, 'plugin.sqlite'), 'declared\n');