From a9ee9f16bf404dc2e97cfc2f423f24796a898669 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 17:35:26 +0000 Subject: [PATCH 1/6] build: compile evidence record module and AB6039 --- .../src/build/artifact-diagnostics.ts | 4 +- .../src/build/compile-evidence.ts | 348 ++++++++++++++++++ 2 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 packages/agent-bundle/src/build/compile-evidence.ts 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/compile-evidence.ts b/packages/agent-bundle/src/build/compile-evidence.ts new file mode 100644 index 000000000..ad73c0549 --- /dev/null +++ b/packages/agent-bundle/src/build/compile-evidence.ts @@ -0,0 +1,348 @@ +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 { isAllowedExternalRequest } 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'), + }), + }); +}; + +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])); + for (const [path, file] of files) { + if (file.kind !== 'bundle') continue; + const asset = recorded.get(path); + if (asset === undefined) diagnostics.push(evidenceDiagnostic(`does not cover compiled file ${JSON.stringify(path)}.`)); + else if (asset.sha256 !== file.sha256) diagnostics.push(evidenceDiagnostic(`for ${JSON.stringify(path)} describes different bytes.`)); + } + for (const asset of record.assets) { + const file = files.get(asset.path); + if (file === undefined || file.kind !== 'bundle') { + diagnostics.push(evidenceDiagnostic(`names ${JSON.stringify(asset.path)}, which the manifest does not list as a compiled file.`)); + } + for (const external of asset.externals) { + switch (external.kind) { + case 'builtin': + if (!isAllowedExternalRequest(external.request)) { + 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 (external.target === undefined || !files.has(external.target)) { + 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); +}; From f8cfafce1d1fa145d091bc5aed747d91f974e76a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:15:59 +0000 Subject: [PATCH 2/6] Persist the compile evidence record beside emitted files (AB6039) --- .changeset/619-compile-evidence-record.md | 8 + docs/diagnostics.md | 26 ++- docs/entry-conventions.md | 8 +- packages/agent-bundle/src/build/build.ts | 29 ++- packages/agent-bundle/src/build/cli-bins.ts | 4 +- 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 | 58 +++++- .../agent-bundle/src/build/package-build.ts | 24 ++- packages/agent-bundle/src/build/rslib.ts | 4 +- .../src/build/validate-artifact.ts | 33 +++- .../tests/artifact-validator.test.ts | 169 ++++++++++++++++++ packages/agent-bundle/tests/build.test.ts | 39 ++++ .../tests/compile-evidence.test.ts | 151 ++++++++++++++++ .../agent-bundle/tests/compile-stages.test.ts | 5 +- .../tests/dev-package-build-service.test.ts | 15 ++ .../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 | 39 +++- .../docs/en/reference/targets-artifacts.mdx | 5 + .../docs/zh/guide/concepts/architecture.mdx | 7 +- .../docs/zh/guide/distribution/validation.mdx | 34 +++- .../docs/zh/reference/targets-artifacts.mdx | 4 + 25 files changed, 695 insertions(+), 40 deletions(-) create mode 100644 .changeset/619-compile-evidence-record.md 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..bb0ac37d9 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 +keeps the same record in memory for `prepack` (paths `dist/bin/…`), not on +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.` — a `builtin` external is not an allowed built-in. `Compile evidence for "" records sibling "", which the artifact does not contain.` — an `artifact-relative` external's `target` is missing from the file table. | 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 67d1efc80..8c832b809 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1311,8 +1311,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/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 e7960facf..51e2d3b68 100644 --- a/packages/agent-bundle/src/build/cli-bins.ts +++ b/packages/agent-bundle/src/build/cli-bins.ts @@ -232,8 +232,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/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 4aa939b21..962ad7479 100644 --- a/packages/agent-bundle/src/build/entries.ts +++ b/packages/agent-bundle/src/build/entries.ts @@ -248,7 +248,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') @@ -262,7 +262,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' @@ -542,8 +542,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)}.`); })(), @@ -717,8 +717,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..779f97429 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -16,14 +16,15 @@ import { DiagnosticError, freezeDiagnostics, type Diagnostic } from '../core/dia import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../core/types.ts'; import { stableJson } from '../core/digest.ts'; import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; +import { posixRelativeWhenInside } from '../core/paths.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, ExternalIR, ModuleIR } from './compile-result.ts'; import { composeToolsLayers, frameworkInvariantLayer } from './compose-layers.ts'; import { ArtifactDependencyAuditPlugin } from './dependency-audit-plugin.ts'; import { listArtifactFiles, resolveArtifactDestination } from './emit.ts'; -import { viewSelfContainmentDiagnostics } from './external-policy.ts'; +import { classifyExternal, viewSelfContainmentDiagnostics } from './external-policy.ts'; import { mcpAppBundlerFailureDiagnostic, mcpAppCompileErrorDiagnostics, @@ -43,6 +44,7 @@ import { virtualModulesPluginConstructor, } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; +import { moduleKindOf, packageNameOfResource } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; export type { McpAppCompileMode, McpAppOutputSize } from './mcp-app-diagnostics.ts'; @@ -83,6 +85,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 +447,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 +476,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 +563,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 +571,33 @@ 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): CompileResult => { + const asset = `mcp-apps/${app.name}.html`; + const evidence = viewEvidence[index]!; + return Object.freeze({ + assets: Object.freeze([{ path: asset, sourceInputs: app.sourceInputs }]), + diagnostics: Object.freeze([]), + externals: Object.freeze(evidence.externals.map((external): ExternalIR => ({ + asset, + externalType: external.externalType, + issuers: external.issuers.map((issuer) => posixRelativeWhenInside(options.cwd, issuer)), + kind: classifyExternal(external, { asset, emittedAssets }), + request: external.request, + userRequest: external.userRequest, + }))), + modules: Object.freeze(evidence.modules.map((module): ModuleIR => { + const packageName = module.resource === undefined ? undefined : packageNameOfResource(module.resource); + return { + asset, + identifier: module.identifier, + kind: moduleKindOf(module.resource, options.cwd, []), + ...(packageName === undefined ? {} : { package: packageName }), + ...(module.resource === undefined ? {} : { resource: module.resource }), + }; + })), + }); + })); /** * 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 +619,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 +647,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..414b73061 100644 --- a/packages/agent-bundle/src/build/rslib.ts +++ b/packages/agent-bundle/src/build/rslib.ts @@ -763,7 +763,7 @@ const assertDistinctLibIds = (entries: readonly RslibEntry[]): void => { } }; -const packageNameOfResource = (resource: string): string | undefined => { +export const packageNameOfResource = (resource: string): string | undefined => { const segments = resource.replaceAll('\\', '/').split('/'); const nodeModules = segments.lastIndexOf('node_modules'); if (nodeModules === -1) return undefined; @@ -774,7 +774,7 @@ const packageNameOfResource = (resource: string): string | undefined => { : name; }; -const moduleKindOf = ( +export const moduleKindOf = ( resource: string | undefined, cwd: string, dependencyRoots: readonly string[], diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 483ee92bb..c5469fc1d 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; @@ -739,6 +767,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 +775,7 @@ export const validateArtifactWithSnapshot = async ( emittedSkillDiagnostics, generatedFileDiagnostics, ] = await Promise.all([ + validateCompileEvidence({ artifactRoot, manifest }), validateTargetContracts({ artifactRoot, files: inspection.files, @@ -786,6 +816,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..701e3e0ba 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.arrayContaining([ + 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.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..cb5cc7e22 --- /dev/null +++ b/packages/agent-bundle/tests/compile-evidence.test.ts @@ -0,0 +1,151 @@ +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 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'), + ])); + }); +}); 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/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..73919bca3 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,42 @@ 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 +keeps the same record in memory for `prepack` (paths `dist/bin/…`), not on +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.` + 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..b1b7dfe71 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,37 @@ 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`)。包构建把同一份记录留在内存里供 `prepack` 使用(路径为 `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.` + 每条诊断都是一份结构化记录:稳定的 `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 522d031e705d075a52cca90b91aa2c396a7dbc58 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:23:13 +0000 Subject: [PATCH 3/6] Share the evidence-to-IR lowering between Rslib surfaces and MCP App views --- packages/agent-bundle/src/build/mcp-apps.ts | 39 +++--------- packages/agent-bundle/src/build/rslib.ts | 68 +++++++++++++-------- 2 files changed, 52 insertions(+), 55 deletions(-) diff --git a/packages/agent-bundle/src/build/mcp-apps.ts b/packages/agent-bundle/src/build/mcp-apps.ts index 779f97429..4ed3439f5 100644 --- a/packages/agent-bundle/src/build/mcp-apps.ts +++ b/packages/agent-bundle/src/build/mcp-apps.ts @@ -16,15 +16,14 @@ import { DiagnosticError, freezeDiagnostics, type Diagnostic } from '../core/dia import type { AgentBundleToolsConfig, NormalizedMcpApp } from '../core/types.ts'; import { stableJson } from '../core/digest.ts'; import { MAX_APP_HTML_BYTES } from '../core/mcp-app-limits.ts'; -import { posixRelativeWhenInside } from '../core/paths.ts'; import { escapeRegExp } from '../core/strings.ts'; import type { AgentBundleMeta } from '../meta.ts'; import { appRuntimePath, appRuntimeSpecifier } from './app-runtime.ts'; -import type { CompilationEvidence, CompileResult, ExternalIR, ModuleIR } 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'; -import { classifyExternal, viewSelfContainmentDiagnostics } from './external-policy.ts'; +import { viewSelfContainmentDiagnostics } from './external-policy.ts'; import { mcpAppBundlerFailureDiagnostic, mcpAppCompileErrorDiagnostics, @@ -44,7 +43,7 @@ import { virtualModulesPluginConstructor, } from './meta.ts'; import { collectBundledOutputEvidence } from './provenance.ts'; -import { moduleKindOf, packageNameOfResource } from './rslib.ts'; +import { compileResultOf } from './rslib.ts'; import { runtimeIgnoredRoot } from './runtime-path.ts'; export type { McpAppCompileMode, McpAppOutputSize } from './mcp-app-diagnostics.ts'; @@ -572,32 +571,12 @@ export const compileMcpApps = async ( 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): CompileResult => { - const asset = `mcp-apps/${app.name}.html`; - const evidence = viewEvidence[index]!; - return Object.freeze({ - assets: Object.freeze([{ path: asset, sourceInputs: app.sourceInputs }]), - diagnostics: Object.freeze([]), - externals: Object.freeze(evidence.externals.map((external): ExternalIR => ({ - asset, - externalType: external.externalType, - issuers: external.issuers.map((issuer) => posixRelativeWhenInside(options.cwd, issuer)), - kind: classifyExternal(external, { asset, emittedAssets }), - request: external.request, - userRequest: external.userRequest, - }))), - modules: Object.freeze(evidence.modules.map((module): ModuleIR => { - const packageName = module.resource === undefined ? undefined : packageNameOfResource(module.resource); - return { - asset, - identifier: module.identifier, - kind: moduleKindOf(module.resource, options.cwd, []), - ...(packageName === undefined ? {} : { package: packageName }), - ...(module.resource === undefined ? {} : { resource: module.resource }), - }; - })), - }); - })); + 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 diff --git a/packages/agent-bundle/src/build/rslib.ts b/packages/agent-bundle/src/build/rslib.ts index 414b73061..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, @@ -763,7 +764,7 @@ const assertDistinctLibIds = (entries: readonly RslibEntry[]): void => { } }; -export const packageNameOfResource = (resource: string): string | undefined => { +const packageNameOfResource = (resource: string): string | undefined => { const segments = resource.replaceAll('\\', '/').split('/'); const nodeModules = segments.lastIndexOf('node_modules'); if (nodeModules === -1) return undefined; @@ -774,7 +775,42 @@ export const packageNameOfResource = (resource: string): string | undefined => { : name; }; -export const moduleKindOf = ( +/** 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, dependencyRoots: readonly 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) => { From 1c2917cfa705663a135d5aebd152fea96cef29bd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:38:19 +0000 Subject: [PATCH 4/6] Account for the compile evidence record in root listings and the epoch tamper test --- .../agent-bundle/tests/build-compose.test.ts | 4 ++++ .../tests/hook-playground-service.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) 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/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 }); From 10c722d1e0b24e4777f7bc5c93df60851e9ee127 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:52:08 +0000 Subject: [PATCH 5/6] Re-judge recorded externals with the build's policy; keep the record out of the generic JSON check --- docs/diagnostics.md | 4 +- .../src/build/compile-evidence.ts | 17 ++++---- .../src/build/validate-artifact.ts | 3 +- .../tests/artifact-validator.test.ts | 4 +- .../tests/compile-evidence.test.ts | 43 +++++++++++++++++++ .../docs/en/guide/distribution/validation.mdx | 4 +- .../docs/zh/guide/distribution/validation.mdx | 2 +- 7 files changed, 61 insertions(+), 16 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index bb0ac37d9..2578db0aa 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1830,8 +1830,8 @@ producer `{ name: 'agent-bundle', rspack, version }`, `coverage.rewritable` 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 -keeps the same record in memory for `prepack` (paths `dist/bin/…`), not on -disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in +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 diff --git a/packages/agent-bundle/src/build/compile-evidence.ts b/packages/agent-bundle/src/build/compile-evidence.ts index ad73c0549..ecf833710 100644 --- a/packages/agent-bundle/src/build/compile-evidence.ts +++ b/packages/agent-bundle/src/build/compile-evidence.ts @@ -6,7 +6,7 @@ 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 { isAllowedExternalRequest } from './external-policy.ts'; +import { classifyExternal } from './external-policy.ts'; /** * The compile evidence record: what the compiler service reported about each @@ -310,28 +310,29 @@ export const compileEvidenceDiagnostics = ( )); } const recorded = new Map(record.assets.map((asset) => [asset.path, asset])); - for (const [path, file] of files) { - if (file.kind !== 'bundle') continue; + 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 !== file.sha256) diagnostics.push(evidenceDiagnostic(`for ${JSON.stringify(path)} describes different bytes.`)); + else if (asset.sha256 !== files.get(path)!.sha256) diagnostics.push(evidenceDiagnostic(`for ${JSON.stringify(path)} describes different bytes.`)); } for (const asset of record.assets) { - const file = files.get(asset.path); - if (file === undefined || file.kind !== 'bundle') { + 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) { + // The same judgement the build made, over the file table instead of the module graph. + const judged = classifyExternal(external, { asset: asset.path, emittedAssets: compiled }); switch (external.kind) { case 'builtin': - if (!isAllowedExternalRequest(external.request)) { + 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 (external.target === undefined || !files.has(external.target)) { + 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.`, )); diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index c5469fc1d..9dde7c782 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -624,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. diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 701e3e0ba..f7f2a9b47 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -261,13 +261,13 @@ 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.arrayContaining([ + 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 }); } diff --git a/packages/agent-bundle/tests/compile-evidence.test.ts b/packages/agent-bundle/tests/compile-evidence.test.ts index cb5cc7e22..f6e000952 100644 --- a/packages/agent-bundle/tests/compile-evidence.test.ts +++ b/packages/agent-bundle/tests/compile-evidence.test.ts @@ -9,6 +9,7 @@ import { createCompileEvidenceRecord, parseCompileEvidenceRecord, serializeCompileEvidenceRecord, + type CompileEvidenceExternal, type CompileEvidenceRecord, } from '../src/build/compile-evidence.ts'; import type { CompileResult } from '../src/build/compile-result.ts'; @@ -148,4 +149,46 @@ describe('compile evidence records', () => { 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 }], + ]); + const judge = (externals: readonly CompileEvidenceExternal[]): readonly string[] => + compileEvidenceDiagnostics({ + assets: [ + { externals, packages: [], path: 'a.js', sha256: hash }, + { externals: [], packages: [], path: 'lib/b.js', 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')]); + }); }); diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 73919bca3..8e9cd8675 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -76,8 +76,8 @@ 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 -keeps the same record in memory for `prepack` (paths `dist/bin/…`), not on -disk. `coverage.unobserved` lists the load forms Rslib leaves verbatim in the +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 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index b1b7dfe71..83e8b312c 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -61,7 +61,7 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, `coverage.unobserved`。 `agent-bundle validate --artifact` 把已列入清单的记录对照清单文件表复核,且不读取 JavaScript -(`AB6039`)。包构建把同一份记录留在内存里供 `prepack` 使用(路径为 `dist/bin/…`),不写到磁盘。 +(`AB6039`)。包构建在进程内返回同一份记录(`PackageBuildResult.evidence`,路径为 `dist/bin/…`),不写到磁盘。 `coverage.unobserved` 列出 Rslib 在捆绑中原样保留的加载形式,因此编译器既不会打包它们,也不会记录 它们:`import()`、`require()`、`require.resolve(…)`、 `createRequire(…)(…)`、`import.meta.resolve(…)`。因此「没有记录任何 externals」并不能证明不存在 From 3d377b4378c8454609a5304df3cd06f6b2152816 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:59:34 +0000 Subject: [PATCH 6/6] Judge recorded externals per compilation boundary: views keep none, node bundles load only node bundles --- docs/diagnostics.md | 2 +- packages/agent-bundle/src/build/compile-evidence.ts | 13 ++++++++++++- .../agent-bundle/tests/compile-evidence.test.ts | 10 +++++++++- website/docs/en/guide/distribution/validation.mdx | 1 + website/docs/zh/guide/distribution/validation.mdx | 1 + 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 2578db0aa..8d1bb543d 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1860,7 +1860,7 @@ therefore does not prove the absence of such a load. | `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.` — a `builtin` external is not an allowed built-in. `Compile evidence for "" records sibling "", which the artifact does not contain.` — an `artifact-relative` external's `target` is missing from the file table. | Rebuild the artifact so its compile evidence record describes the emitted files. | +| `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/packages/agent-bundle/src/build/compile-evidence.ts b/packages/agent-bundle/src/build/compile-evidence.ts index ecf833710..7ed51f5ae 100644 --- a/packages/agent-bundle/src/build/compile-evidence.ts +++ b/packages/agent-bundle/src/build/compile-evidence.ts @@ -288,6 +288,9 @@ export const parseCompileEvidenceRecord = (bytes: string): CompileEvidenceRecord }); }; +/** 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); @@ -316,13 +319,21 @@ export const compileEvidenceDiagnostics = ( 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: compiled }); + const judged = classifyExternal(external, { asset: asset.path, emittedAssets: nodeBundles }); switch (external.kind) { case 'builtin': if (judged !== 'builtin') { diff --git a/packages/agent-bundle/tests/compile-evidence.test.ts b/packages/agent-bundle/tests/compile-evidence.test.ts index f6e000952..942ed3da7 100644 --- a/packages/agent-bundle/tests/compile-evidence.test.ts +++ b/packages/agent-bundle/tests/compile-evidence.test.ts @@ -165,12 +165,14 @@ describe('compile evidence records', () => { ['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[]): readonly string[] => + 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 }, @@ -190,5 +192,11 @@ describe('compile evidence records', () => { // 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/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index 8e9cd8675..cc087d570 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -94,6 +94,7 @@ evidence record describes the emitted files. Message forms: - `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`, diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 83e8b312c..e5b547d8c 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -77,6 +77,7 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, - `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`——只有