Artifact validation
Validation answers two different questions with one command. Against source it asks whether the project describes a legal bundle; against an artifact it asks whether the emitted bytes are the bytes the manifest promised.
validate --artifact, mcp, and hooks all work against a built artifact with the project
sources deleted. That is deliberate: an artifact that only validates while its source tree is
present is not really self-contained, and the packed-deleted-source proof level in
Testing exists for the same reason.
Content-addressed, not path-existence
agent-bundle.manifest.json records a SHA-256 digest for every emitted file, including copied
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. 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
rendered route's Flight worker .mjs, and the lib entry), plus every MCP App view. 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
other request fails whatever spelling Rspack emitted (import, require, or its createRequire
shim). An expression request (import(expr), require(expr)) is outside the compiler's view:
Rslib's profile leaves it verbatim without parsing it, so it is neither bundled nor external.
The emitted-module walk remains behind that compiler evidence for exactly what the compiler cannot
see. It reads every emitted .js/.mjs module as an ES module and, in any of them, compiled ones
included, fails an expression import() (AB6005 has a non-literal dynamic import). A module the
compile evidence record proves, a manifest bundle file the record covers with the same bytes,
from a build without a tools hatch, is lexed, not
parsed, and each literal import it still carries is held to the record: a Node built-in or one of
the file's recorded externals passes; any other request is one the build was told to ignore
(rspackIgnore/webpackIgnore, Rspack leaves it verbatim with no module, external, or warning)
and fails AB6005 loads "<request>", which the compiler neither bundled nor recorded as an external. A matching digest proves the bytes are the compiler's, not that every import in them was
resolved. Every other module is parsed in full and its
imports resolved against the manifest, a generated install.mjs, a copied consumer script, every
module of an artifact without a record, and every module of a build whose hatch could have rewritten
the emitted assets (the record's coverage.rewritable). Those findings are the residual AB6005
forms: unsupported or invalid specifiers, targets missing from or outside the emitted tree, invalid
syntax; a dist finding names its file as dist/<path>. Prebuilt payloads (kind: 'prebuilt')
stay opaque and hash-locked only, and declaration files (.d.ts) are not walked.
During compilation, AB4837 rejects imports of compiler-carrying framework entries:
agent-bundle, agent-bundle/api, agent-bundle/config, agent-bundle/eval,
agent-bundle/rstest, agent-bundle/test, and agent-bundle/test/browser.
The check uses transformed dependency requests and resolved package export identities,
including aliases, and names the importing module. Type-only imports erased by the
configured transform remain legal. Source-only inspect and validate do not establish
this boundary. agent-bundle/app is a browser-safe leaf and remains allowed.
An installed artifact serves an App through <plugin> web.
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(<expression>), require(<expression>), 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 <reason>.(the strict parser's own message, e.g.is not valid JSON)Compile evidence record cannot be read.Compile evidence was judged under policy <name>@<revision>; this validator applies closed-world-externals@1.Compile evidence does not cover compiled file "<path>".Compile evidence for "<path>" describes different bytes.Compile evidence names "<path>", which the manifest does not list as a compiled file.Compile evidence for "<path>" records "<request>" as a built-in; it is not one.Compile evidence for "<path>" records sibling "<request>", which the artifact does not contain.Compile evidence for "<path>" records "<request>" 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
present; warnings and infos never gate a build, a validation, or a dev rebuild. eval and
inspect also exit 1 for a failing or inconclusive trial or an invalid model, with no error
diagnostic involved, see the CLI exit codes.
Claude Code strict validation
Host validation runs only for the selected projections, over the composite root. When Claude Code
is on PATH and claude is among the selected projections, artifact validation runs the installed
claude plugin validate over the root. agent-bundle build runs the same checks over the root it
just wrote (--no-host-validation skips them; --strict promotes host warnings to build errors),
and agent-bundle doctor --host claude --from <root> runs them over the root and over every
installed copy Claude lists. Claude Code decides what it checks from the manifest it is pointed
at, and a root that holds both .claude-plugin/plugin.json and .claude-plugin/marketplace.json
is treated as a marketplace, whose validation never opens the plugin's hook, skill, agent, or
command files. Agent Bundle therefore runs two commands:
- Host errors become Agent Bundle errors (
AB6021). - Host warnings stay warnings (
AB6020) unlessagent-bundle validate --strictis set. - Each finding names the file Claude Code checked, for example
(hooks hooks/hooks.json): hooks: hooks.postToolUse: unknown hook event. Agent Bundle asks for the CLI's--jsonreport first (Claude Code 2.1.259 or later; the pinned 2.1.260 prints it) and parses the text report only when the CLI rejects the flag or the probed version predates 2.1.259. The marketplace run'splugins[0] plugin.json →copies of manifest findings the plugin run already reported are dropped. - A missing binary is reported as an explicit informational skip (
AB6019), never as fabricated success; a probe or run that cannot complete isAB6022. AbuildwithoutclaudeonPATHspawns once and reports the skip once.
The load check
Validation is not a load verdict. Claude Code 2.1.250 through 2.1.260 accept, under --strict,
manifests and component files that a session then refuses to load, an invalid
monitors/monitors.json, or a hooks field naming the auto-loaded hooks/hooks.json. The only
place the refusal shows is the plugin's row in claude --plugin-dir <root> plugin list --json
("Plugins reference" → "plugin list"): a refused row carries an errors array with the host's
message, a healthy row has no errors key. So build and validate --artifact follow the two
validation runs with exactly that listing and read the bundle's <name>@inline row:
The listing is read-only and needs no login. Doctor does not repeat it: its --plugin-dir
registration proof and the inventory rows' errors already carry the same verdicts.
CI should use strict validation:
Use --no-host-validation when a deterministic schema-only check is required, a machine without
the hosts installed must still be able to run the same gate and get the same schema verdict.
During development, load the built root for one session without installing it, then iterate:
Run /reload-plugins inside that session after agent-bundle build rewrites hooks/,
.mcp.json, or agents/; SKILL.md edits take effect without it. A --plugin-dir plugin is not
visible to a bare claude plugin list; put the flag before the subcommand to inspect it:
That listing is the load verdict described above, and build and validate --artifact run it for
you. agent-bundle doctor --host claude --from <root> reads the same errors array on both
the --plugin-dir registration proof and the installed copy and reports it as AB7325
(load-failed), and agent-bundle install claude fails with AB7006 when the installed copy
carries it. The pinned Claude plugin schema also rejects hooks: "./hooks/hooks.json" up front
(AB6012), since the manifest field is for additional hook files only.
Doctor also reads each row's enabled flag. A copy that claude plugin disable switched off
("Plugins reference" → "plugin disable") is still installed and may still be byte-identical to the
bundle, but none of it reaches a session; Doctor lists it as disabled, keeps its content verdict,
and adds an AB7327 warning whose recovery is the exact claude plugin enable <name>@<marketplace> [--scope <scope>] command, reinstalling does not enable a disabled plugin.
Codex and Cursor: pinned schemas
Neither Codex nor Cursor publishes a plugin-validation developer verb today, so Agent Bundle validates their emitted JSON documents against its own vendored pinned schemas and treats host schema generators as a drift signal, never as a substitute plugin contract.
AB6031 is the interesting one: when Codex's generated schema disagrees with the pinned
revision, the pin is only updated once Codex publishes the matching contract. Following live host
output automatically would turn an upstream change into a silent contract change here.
Agent Skills spec lint
AB6034 is an error on emitted bytes: a SKILL.md with valid YAML frontmatter but no
Markdown instruction body after it. The pinned Agent Skills specification requires frontmatter
followed by Markdown content, and the check runs against what the artifact actually contains
rather than against the authored source.
Hash pins versus repository-owned tables
Hash pins cover vendored external content whose ground truth lives outside the repository and can
drift: host document schemas under src/adapters/schemas/* (with upstream URL, commit, and
SHA-256 recorded in PROVENANCE.json), the Agent Skills specification-derived schema pin in the
manifest agentSkills block, and emitted artifact files and source inputs for integrity.
Repository-owned capability tables and evidence are not hashed. Capability evidence records
the observed host version (observedVersion) and target, while adapters carry a monotonic
adapterRevision. Git already versions repository-owned content; hashing it again inside the
repository is self-referential and causes churn on every table edit.
The npm prepack gate
prepack runs the release build, creates the canonical npm root at dist/, and runs
npm pack --dry-run --json --ignore-scripts from that root by launching npm's
npm-cli.js through the current Node executable (process.execPath), so the gate
works when npm is not a spawnable PATH command (a pnpm-managed Node, or Windows
without npm.cmd on PATH). It never treats npm_execpath as npm when that variable
points at pnpm. The generated package copies the
validated composite artifact unchanged, points routed package.json bins at the
manifest-declared bin/<name>.mjs, and adds only separately authored bins and libraries. The gate
checks that exact inventory, manifest hashes, package bin targets, release-version agreement,
package-only compile evidence, and the installed-dependency fields of package.json. Authored
package.json.files and .npmignore rules never narrow the generated root: every selected hidden
host directory and every manifest file is packed, or prepack fails before publication. The build
inlines every dependency into authored package entries and the host packs, so a published plugin should install nothing:
declare the framework, @agent-bundle/runtime, react, zod, and the rest of the stack under
devDependencies. A compiled bundle, a host-pack module or a dist bundle alike, cannot keep
a bare package external: the compiler reports AB6005 before prepack reaches the inventory. So
bundling a package does not prove that consumers need it installed. --output is an artifact path
relative to --root that overrides the configured
output.distPath, defaulting to artifact. Run the gate directly (generated projects expose
pack:check), then publish the generated root with npm publish ./dist --ignore-scripts.
No npm lifecycle ever performs a host install.
Consumer preinstall, install, and postinstall scripts are preserved only when they do not
invoke Node directly, or when they are one node <generated-package-path> command. Known authored
paths below the source dist/ root are rebased to the generated root and checked against staged
files. Unsupported Node commands and absent source assets report AB4768; the package builder
never copies the source tree to make a lifecycle path work.
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,
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
compiler: a browser document has no allowable external, so any module a view's compilation kept
external, a react mapped to a global through the hatch, say, fails the view with AB6005
(Compiled MCP App view "mcp-apps/<name>.html" keeps … external …). AB4725 rejects
tools.rsbuild.output.autoExternal other than false and statically visible string or object
externals entries that name packages. RegExp, function-form, relative, and mutator externals are
judged from the compilation's own evidence: AB6005 fails a generated executable in a host pack
or dist when Rspack kept anything except a built-in, pnpapi, or an emitted sibling external.
An external kept through the tools hatch is therefore
not a way around the contract and never reaches the prepack inventory.
AB7014 accepts exactly three kinds of consumer-runtime evidence:
runtimeDependencieson a prebuilt payload declared withdefinePrebuilt;- a packed declaration file reference, as TypeScript's
preProcessFilereads it: imports, exports, import-equalsrequire,import("…")types, module augmentations in an external-module file, and/// <reference types>(including its@typestwin); an ambientdeclare modulein a file with no imports counts for nothing, while a#subpathfollows the packageimportsmap; - a consumer-side
preinstall,install, orpostinstallscript, including delegated scripts, that names the dependency or runs one of itsbincommands.
The framework's own runtime modules load no package at run time, so there is no framework process-dependency record to read as a fourth source.
A package merely inlined into a compiled bundle is not used. When that is the only evidence,
AB7014 names the dist bundles that inlined it, for example:
The build inlined "a" into dist/bin/x.js; every consumer installs them for nothing. Move
build-only packages to devDependencies; compiled bundles inline their imports (AB6005). Keep a
runtime dependency only for what a packed declaration file references, a consumer install script
names or runs, or a prebuilt payload declares in runtimeDependencies (definePrebuilt).
A release build also refuses a project whose package.json has no valid release version
(AB4013), so a published artifact never carries the
0.0.0-dev.<short-revision> development fallback.
Next
- Host installation: registering a validated bundle with a host.
- Preview packages: how these outputs reach consumers today.