diff --git a/.changeset/manifest-launch-agreement.md b/.changeset/manifest-launch-agreement.md new file mode 100644 index 000000000..ae330ce16 --- /dev/null +++ b/.changeset/manifest-launch-agreement.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Hold every host MCP document to the manifest's launch records: `agent-bundle build` and `validate-artifact` fail `AB6017` when a target document omits or renames a launchable `executables.mcpServers[]` server, reaches it over a non-stdio transport, starts an artifact file other than its `launch.entry` first, passes the record's `artifact` arguments out of order, or when `projections[host].documents.mcp` does not point at the target's MCP document. Both manifest readers now require a compiled server's `launch.entry` and `worker` to be `bundle` rows and a prebuilt server's entry a `prebuilt` row. `agent-bundle mcp run` launches the host document's line for the record of the same name and no longer falls back to the manifest record alone. `install` and `doctor` report `AB7001` when an indexed file's size or executable bit differs from its `files[]` row, not only its digest. One portable path-segment rule (`isPortablePathSegment`) governs `files[]` rows, the JSON Schema, and the install receipt. (#650) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 01eb6f7b6..30f54d314 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -34,7 +34,7 @@ even when no error diagnostic was reported. | `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 only for what the compiler cannot see — an expression `import()` in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten; 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). | | `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; `AB7001` in detail: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json` — the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, a `files[]` row is missing or its bytes differ from its digest, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`; `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). | +| `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; `AB7001` in detail: the composite root at `--from` cannot be resolved for the host from its `agent-bundle.manifest.json` — the manifest is missing or not canonical, has no `projections[]` row for the host, the row has no host plugin manifest pointer or the pointed file is missing, a `files[]` row is missing or its bytes, size, or executable bit differ from the row, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is not a safe local plugin name; `install`, `uninstall`, and `doctor` never probe `.claude-plugin/plugin.json` or look under `/`; `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 consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | @@ -1863,7 +1863,7 @@ therefore does not prove the absence of such a load. | `AB6014` | error | Ownership and layout: `Artifact file "" is outside declared target emitted layouts.` (a manifested file under a target that no emitted layout, hook manifest, MCP runtime manifest, validation contract, or prebuilt entry accounts for), `Artifact directory "" does not name a declared target namespace.` (a root directory that is not a manifest target), `Artifact directory "" is empty.`, `Declared target "" has no emitted namespace.`, or `Artifact directory changed during validation: "".` | Rebuild the artifact with files only in declared target namespaces. | | `AB6015` | error | Emitted Skill layout and frontmatter: a Skill document outside the canonical `skills//SKILL.md` layout, a Skill resource directory without its `SKILL.md`, Skill Markdown that cannot be read, does not start with YAML frontmatter, has invalid or schema-violating frontmatter (`Emitted Skill frontmatter .`), or whose frontmatter `name` differs from its directory name. | Restore canonical Skill Markdown and copied resources, then rebuild the artifact. | | `AB6016` | error | `Emitted Skill reference "" escapes its Skill root.` or `Emitted Skill references missing regular resource "".` — a relative reference inside a `SKILL.md` body points outside the Skill directory or at a file the artifact does not contain as a regular file. | Copy every referenced Skill resource inside its Skill root, then rebuild the artifact. | -| `AB6017` | error | Target MCP manifest coherence (`generatedPath` is the target's MCP manifest): the manifest is not strict JSON or does not contain only modern supported servers; a server's runtime values cannot be resolved; a `cwd`, `command`, or `args` path escapes the target, references a missing or unmanifested file, or names a non-executable command file; or a compiler-emitted MCP entry is referenced by no server or by more than one. | Rebuild the artifact so every target MCP manifest references its exact compiler outputs. | +| `AB6017` | error | Target MCP manifest coherence (`generatedPath` is the target's MCP manifest): the manifest is not strict JSON or does not contain only modern supported servers; a server's runtime values cannot be resolved; a `cwd`, `command`, or `args` path escapes the target, references a missing or unmanifested file, or names a non-executable command file; a compiler-emitted MCP entry is referenced by no server or by more than one; `projections[host].documents.mcp` does not point at the target's MCP manifest; a launchable `executables.mcpServers[]` server declared for the target is missing from the target's document under its name; or the document's server disagrees with the launch record of the same name — it is not a stdio server, the first artifact-local path its command and arguments name is not the record's `entry`, or the record's `artifact` arguments do not follow the entry in the record's order (the host document and ` web` would start different bytes; the document may add flags and literal arguments around them). | Rebuild the artifact so every target MCP manifest references its exact compiler outputs. | | `AB6018` | error | Hook coherence, judged from `agent-bundle.manifest.json` `executables.hooks[]`: a hook row selects an undeclared or hook-incompatible host, or references a missing or invalid wrapper; a host with hook rows is missing its native hook document, or that document is invalid for command enumeration; a manifest hook row does not map to exactly one native command, or a native command has no manifest row (or more than one). | Rebuild the artifact so native hook commands and the manifest's hook rows agree. | | `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`. | @@ -1901,7 +1901,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .` — the exhaustive host switch received a host that is not `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.` — evaluating the configuration module or discovering source threw before validation. | Install: pass `--host claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | -| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose bytes differ from its `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | +| `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 2` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has no `documents.plugin`, or whose `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, executable bit, or bytes differ from its `bytes`, `mode`, and `sha256`; a Cursor `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json index 87cd235b9..2570809f9 100644 --- a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -118,8 +118,8 @@ }, "relativePath": { "type": "string", - "description": "Safe relative POSIX path from the artifact root: non-empty, no leading slash or drive prefix, no backslash or NUL, and no empty, `.`, or `..` segment.", - "pattern": "^(?![A-Za-z]:)(?:(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+/)*(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+$" + "description": "Safe relative POSIX path from the artifact root: non-empty, no leading slash or drive prefix, no backslash, and every segment portable — never empty, `.`, or `..`, no control or Windows-reserved character (`<>:\"|?*`), not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`), and no trailing dot or space.", + "pattern": "^(?![A-Za-z]:)(?:(?!(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Mm][0-9¹²³]|[Ll][Pp][Tt][0-9¹²³])(?:\\.|/|$))[^/\\\\\\u0000-\\u001f<>:\"|?*]*[^/\\\\\\u0000-\\u001f<>:\"|?*. ]/)*(?!(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Mm][0-9¹²³]|[Ll][Pp][Tt][0-9¹²³])(?:\\.|/|$))[^/\\\\\\u0000-\\u001f<>:\"|?*]*[^/\\\\\\u0000-\\u001f<>:\"|?*. ]$" }, "nonNegativeSafeInteger": { "type": "integer", diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index fdbb9226f..76e25ea8f 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -20,6 +20,7 @@ import type { RouteInputSchemaLiteral, } from '../routes/types.ts'; import { + artifactManifestFileKinds, artifactManifestName, artifactManifestVersion, mcpServerKinds, @@ -30,7 +31,9 @@ import { requireLaunchFiles, requireLaunchReferences, requireManifestVersion, + type ArtifactManifestFileKind, type ArtifactManifestLaunch, + type ArtifactManifestServerLaunch, type ArtifactManifestLaunchArgument, type WebManifest, } from '../web-host/manifest.ts'; @@ -63,7 +66,7 @@ export type { ArtifactManifestLaunch, ArtifactManifestLaunchArgument }; export { artifactManifestName, artifactManifestVersion }; export const artifactCompilerRecordVersion = 1; -export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; +export type { ArtifactManifestFileKind }; export type ArtifactManifestValidationStatus = 'passed'; export interface ArtifactManifestSourceInput { @@ -622,16 +625,14 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => { if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0) { fail(`files[${index}].bytes must be a non-negative safe integer.`); } - if (file.kind !== 'bundle' && file.kind !== 'copy' && file.kind !== 'generated' && file.kind !== 'prebuilt') { - fail(`files[${index}].kind is unknown.`); - } + const kind = requireOneOf(file.kind, `files[${index}].kind`, artifactManifestFileKinds); if (file.mode !== undefined && (!Number.isSafeInteger(file.mode) || (file.mode as number) < 0 || (file.mode as number) > 0o777)) { fail(`files[${index}].mode must be an integer from 0 through 0777.`); } const path = parseArtifactFilePath(file.path, `files[${index}].path`); return { bytes: file.bytes as number, - kind: file.kind as ArtifactManifestFileKind, + kind, ...(file.mode === undefined ? {} : { mode: file.mode as number }), path, sha256: requireHash(file.sha256, `files[${index}].sha256`), @@ -1239,14 +1240,14 @@ const parseMcpApps = (value: unknown, location: string): readonly ArtifactManife const parseMcpServers = ( value: unknown, hosts: ReadonlySet, - launches: ReadonlyMap, + launches: ReadonlyMap, ): readonly ArtifactManifestMcpServer[] => { const servers = requireArray(value, 'executables.mcpServers').map((candidate, index) => { const location = `executables.mcpServers[${index}]`; const server = requireRecord(candidate, location); requireExactKeys(server, location, ['apps', 'hosts', 'id', 'kind', 'name', 'transport'], ['launch']); const name = requireString(server.name, `${location}.name`); - const launch = launches.get(name); + const launch = launches.get(name)?.launch; return { apps: parseMcpApps(server.apps, `${location}.apps`), hosts: parseHosts(server.hosts, `${location}.hosts`, hosts), @@ -1418,8 +1419,11 @@ const referencedPaths = (manifest: { return references; }; -const launchesOf = (servers: readonly ArtifactManifestMcpServer[]): ReadonlyMap => - new Map(servers.flatMap((server) => server.launch === undefined ? [] : [[server.name, server.launch] as const])); +const launchesOf = (servers: readonly ArtifactManifestMcpServer[]): ReadonlyMap => + new Map(servers.flatMap((server) => + server.launch === undefined || (server.kind !== 'compiled' && server.kind !== 'prebuilt') + ? [] + : [[server.name, { kind: server.kind, launch: server.launch }] as const])); const parseWeb = (value: unknown, servers: readonly ArtifactManifestMcpServer[]): WebManifest | undefined => { if (value === undefined) return undefined; @@ -1588,7 +1592,7 @@ const validateManifest = (value: unknown): ArtifactManifest => { fail('distribution.channels lists "npm" exactly when compiler.project.packageName is present.'); } const web = parseWeb(manifest.web, executables.mcpServers); - const filePaths = new Set(files.map((file) => file.path)); + const fileKinds = new Map(files.map((file) => [file.path, file.kind])); for (const [index, payload] of distribution.payloads.entries()) { const prefix = `${payload.name}/`; if (!files.some((file) => file.kind === 'prebuilt' && file.path.startsWith(prefix))) { @@ -1596,9 +1600,9 @@ const validateManifest = (value: unknown): ArtifactManifest => { } } for (const [location, path] of referencedPaths({ distribution, executables, projections })) { - if (!filePaths.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`); + if (!fileKinds.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`); } - requireLaunchFiles(launchesOf(executables.mcpServers), filePaths); + requireLaunchFiles(launchesOf(executables.mcpServers), fileKinds); return { application, diff --git a/packages/agent-bundle/src/build/validate-artifact-mcp.ts b/packages/agent-bundle/src/build/validate-artifact-mcp.ts index bd61b25ac..61aa72ac6 100644 --- a/packages/agent-bundle/src/build/validate-artifact-mcp.ts +++ b/packages/agent-bundle/src/build/validate-artifact-mcp.ts @@ -5,13 +5,13 @@ import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { classifyMcpArtifactArgument } from '../services/mcp-artifact-reference.ts'; import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts'; -import { readTargetMcpServers } from '../services/mcp-runtime.ts'; +import { readTargetMcpServers, type ModernMcpServer } from '../services/mcp-runtime.ts'; import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts'; import type { ArtifactFile, ManifestFile } from './emit.ts'; -import type { ArtifactManifest } from './manifest.ts'; +import type { ArtifactManifest, ArtifactManifestMcpServer } from './manifest.ts'; const mcpArtifactPathApi = process.platform === 'win32' ? Object.freeze({ @@ -113,6 +113,74 @@ const validateMcpArtifactReference = (options: { return Object.freeze(diagnostics); }; +/** + * A host document's server starts the bytes the manifest's launch record of + * the same name names, in the record's order: the first artifact-local path + * the document's command and arguments name is the record's entry (Node's + * script operand), and the record's `artifact` arguments follow it in order. + * Otherwise `mcp run` (host document) and ` web` (manifest record) + * would launch different files under one server name. The document may + * reference more — an adapter's flags, an author's bare relative argument that + * is a `literal` in the record — and each such reference is validated on its + * own above. A document server the record starts but the document reaches + * over another transport is the same disagreement. + */ +const validateLaunchAgreement = (options: { + readonly declared: ArtifactManifestMcpServer | undefined; + readonly kind: ModernMcpServer['kind']; + readonly launchPaths: readonly string[]; + readonly manifestPath: string; + readonly server: string; + readonly target: string; +}): readonly Diagnostic[] => { + const launch = options.declared?.launch; + if (launch === undefined) return Object.freeze([]); + const disagreement = (detail: string): readonly Diagnostic[] => Object.freeze([diagnostic( + 'AB6017', + `MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} ${detail}`, + options.manifestPath, + options.target, + )]); + if (options.kind !== 'stdio') { + return disagreement(`is a ${options.kind} server in the target document, but its manifest launch record starts it over stdio.`); + } + const [first, ...following] = options.launchPaths; + if (first !== launch.entry) { + return disagreement( + `starts ${first === undefined ? 'no artifact file' : JSON.stringify(first)} in the target document, ` + + `but its manifest launch record starts ${JSON.stringify(launch.entry)}.`, + ); + } + let cursor = 0; + for (const argument of launch.args) { + if (argument.kind !== 'artifact') continue; + const index = following.indexOf(argument.path, cursor); + if (index === -1) { + return disagreement( + `does not pass ${JSON.stringify(argument.path)} after ${JSON.stringify(launch.entry)} in the order of its manifest ` + + `launch record; the target document names ${JSON.stringify(options.launchPaths)}.`, + ); + } + cursor = index + 1; + } + return Object.freeze([]); +}; + +const validateDeclaredServersPresent = (options: { + readonly documentServers: ReadonlySet; + readonly manifestPath: string; + readonly servers: readonly ArtifactManifestMcpServer[]; + readonly target: string; +}): readonly Diagnostic[] => Object.freeze(options.servers + .filter((server) => server.launch !== undefined && server.hosts.includes(options.target) && !options.documentServers.has(server.name)) + .map((server) => diagnostic( + 'AB6017', + `MCP server ${JSON.stringify(server.name)} is declared for target ${JSON.stringify(options.target)} with a launch record, ` + + 'but the target document names no such server.', + options.manifestPath, + options.target, + ))); + /** * Every selected host's MCP document lives in the one composite root and * names the shared compiled entries (`mcp/.mjs`) the host's servers @@ -136,12 +204,22 @@ export const validateMcpCoherence = async (options: { const compiledEntries = new Set(); const referencedAnywhere = new Set(); - for (const { host: targetName } of options.manifest.projections) { - const target = { name: targetName }; + for (const projection of options.manifest.projections) { + const target = { name: projection.host }; if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue; const runtime = options.registry.mcpRuntime(target.name); if (runtime === undefined) continue; const manifestPath = runtime.manifestPath; + const pointer = projection.documents.mcp; + if ((pointer !== undefined || files.has(manifestPath)) && pointer !== manifestPath) { + diagnostics.push(diagnostic( + 'AB6017', + `projections[${JSON.stringify(target.name)}].documents.mcp ${pointer === undefined ? 'is absent' : `is ${JSON.stringify(pointer)}`}, ` + + `but the target's MCP manifest is ${JSON.stringify(manifestPath)}.`, + manifestPath, + target.name, + )); + } const mcpLayout = options.registry.artifactLayout(target.name).mcpEntries; const referenceCounts = new Map(); const mcpEntries = options.files.filter((file) => isDirectOutputLayoutPath(file.path, mcpLayout)); @@ -174,6 +252,12 @@ export const validateMcpCoherence = async (options: { target.name, )); } else { + diagnostics.push(...validateDeclaredServersPresent({ + documentServers: new Set(servers.servers.map((entry) => entry.name)), + manifestPath, + servers: options.manifest.executables.mcpServers, + target: target.name, + })); for (const entry of servers.servers) { let server = entry.server; try { @@ -206,8 +290,17 @@ export const validateMcpCoherence = async (options: { } continue; } - const entryPaths = new Set(); + const declared = options.manifest.executables.mcpServers.find((row) => row.name === entry.name); + const launchPaths: string[] = []; if (server.kind !== 'stdio') { + diagnostics.push(...validateLaunchAgreement({ + declared, + kind: server.kind, + launchPaths, + manifestPath, + server: entry.name, + target: target.name, + })); options.mcpServers.push(Object.freeze({ entryPaths: Object.freeze([]), kind: server.kind, @@ -249,7 +342,7 @@ export const validateMcpCoherence = async (options: { if (commandReference.status === 'artifact-local') { recordMcpReference(referenceCounts, commandReference.path, { field: 'command', server: entry.name }); referencedAnywhere.add(commandReference.path); - entryPaths.add(commandReference.path); + launchPaths.push(commandReference.path); } } @@ -273,11 +366,19 @@ export const validateMcpCoherence = async (options: { if (argumentReference.status === 'artifact-local') { recordMcpReference(referenceCounts, argumentReference.path, { field: 'argument', server: entry.name }); referencedAnywhere.add(argumentReference.path); - entryPaths.add(argumentReference.path); + launchPaths.push(argumentReference.path); } } + diagnostics.push(...validateLaunchAgreement({ + declared, + kind: server.kind, + launchPaths, + manifestPath, + server: entry.name, + target: target.name, + })); options.mcpServers.push(Object.freeze({ - entryPaths: Object.freeze([...entryPaths].sort((left, right) => left.localeCompare(right))), + entryPaths: Object.freeze([...new Set(launchPaths)].sort((left, right) => left.localeCompare(right))), kind: server.kind, manifestPath, name: entry.name, diff --git a/packages/agent-bundle/src/core/paths.ts b/packages/agent-bundle/src/core/paths.ts index dbcb45660..c19a0abe4 100644 --- a/packages/agent-bundle/src/core/paths.ts +++ b/packages/agent-bundle/src/core/paths.ts @@ -78,18 +78,37 @@ export const isContainedRelativePath = (value: string): boolean => !/^[a-z]:/iu.test(value) && !value.split(/[/\\]/u).includes('..'); +const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\.|$)/iu; + +/** + * One path segment every supported filesystem can hold and hand back unchanged: + * non-empty, never `.` or `..`, no control character or Windows-reserved + * character, not a Windows device name, and no trailing dot or space (which + * Windows strips). The manifest's `files[]` rows and the installer's receipt + * share this rule, so a manifest the parser accepts is one the installer can + * inventory, copy, and own. + */ +export const isPortablePathSegment = (segment: string): boolean => + segment.length > 0 && + segment !== '.' && + segment !== '..' && + !/[<>:"|?*]/u.test(segment) && + [...segment].every((character) => character.charCodeAt(0) >= 0x20) && + !windowsDeviceName.test(segment) && + !segment.endsWith('.') && + !segment.endsWith(' '); + /** * The manifest's path rule: a non-empty POSIX path that is relative on every platform - * (no leading `/`, no drive letter, no backslash, no NUL) and whose segments are - * non-empty and never `.` or `..`, so the path means the same file wherever the root lands. + * (no leading `/`, no drive letter, no backslash) whose every segment is portable, + * so the path means the same file wherever the root lands. */ export const isRelocatablePosixPath = (path: string): boolean => path.length > 0 && !path.includes('\\') && - !path.includes('\0') && !path.startsWith('/') && !/^[a-z]:/iu.test(path) && - path.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..'); + path.split('/').every(isPortablePathSegment); /** A normalized relative path that cannot traverse out of an artifact root. */ export const safeArtifactPath = (path: string): boolean => diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 750867023..d43004178 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -18,7 +18,8 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { exists, installReceiptFile, isInstallReceiptEntry, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { matchesManifestFile } from '../build/artifact-layout.ts'; +import { exists, installReceiptFile, isInstallReceiptEntry, isPortablePathSegment, isPreservedRuntimeRoot } from '../core/paths.ts'; import { stateOwnershipMarkerFile } from '../core/types.ts'; import { artifactManifestName, type ArtifactManifest } from '../build/manifest.ts'; import { OPERATOR_ENV_FILE_NAMES } from '../launch-env.ts'; @@ -366,11 +367,13 @@ export const manifestInventory = async ( throw error; } const row = rows.get(relativePath); - if ( - row !== undefined && - createHash('sha256').update(bytes).digest('hex') !== row.sha256 - ) { - throw new Error(`--from root does not match its manifest: ${relativePath} bytes differ from its files[] digest.`); + if (row !== undefined && !matchesManifestFile({ + bytes: metadata.size, + mode: metadata.mode & 0o777, + path: relativePath, + sha256: createHash('sha256').update(bytes).digest('hex'), + }, row)) { + throw new Error(`--from root does not match its manifest: ${relativePath} differs from its files[] row in bytes, mode, or digest.`); } hashEntry(hash, relativePath, metadata, bytes); } @@ -475,8 +478,6 @@ export const hashOwnedFiles = async (root: string, files: readonly string[]): Pr * characters, alternate-stream colons, trailing dots or spaces) or resolve as * a DOS device (`NUL`, `CON.txt`, `COM1`, `LPT1.json`, ...). */ -const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\.|$)/iu; - export const isReceiptPath = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && @@ -488,15 +489,7 @@ export const isReceiptPath = (value: unknown): value is string => !value.startsWith('/') && // Runtime roots are never installer-owned, whatever a receipt claims and however it spells them. !isPreservedRuntimeRoot(value.split('/')[0] ?? '') && - value.split('/').every((segment) => - segment !== '' && - segment !== '.' && - segment !== '..' && - !/[<>:"|?*]/u.test(segment) && - !windowsDeviceName.test(segment) && - [...segment].every((character) => character.charCodeAt(0) >= 0x20) && - !segment.endsWith('.') && - !segment.endsWith(' ')); + value.split('/').every(isPortablePathSegment); const isReceiptFileList = (value: unknown): value is readonly string[] => Array.isArray(value) && value.every(isReceiptPath); diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index f98342cb7..999d1009f 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -11,12 +11,10 @@ import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { joinArtifact, resolveContained } from '../core/paths.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; -import { pluginRootEnvAnchor } from '../core/types.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { OPERATOR_ENV_FILE_NONE, OPERATOR_ENV_FILE_VARIABLE } from '../launch-env.ts'; -import { expandLaunchTokens } from '../web-host/manifest.ts'; import { resolveMcpPathTokens } from './mcp-path-tokens.ts'; import { forwardingSignals } from './mcp-run-signals.ts'; import { @@ -30,8 +28,8 @@ export { mcpServerStateDirectory } from '../core/mcp-state-directory.ts'; /** * The foreground MCP server runner behind `agent-bundle mcp run`: it resolves * the compiled entry from the artifact manifest (`executables.mcpServers[]`) - * and executes it with inherited stdio until the server exits. Host MCP - * documents supply only the args/env the manifest row does not carry. + * and executes it with inherited stdio until the server exits, under the + * launch line the selected host's MCP document projects for that record. */ export interface ResolvedMcpStdioLaunch { @@ -66,8 +64,10 @@ const hostMcpDocument = async ( runtime: TargetMcpRuntimeContract, host: string, server: string, -): Promise => { - if (documentPath === undefined) return undefined; +): Promise => { + if (documentPath === undefined) { + throw new Error(`Target ${JSON.stringify(host)} projects no MCP document, so it cannot run MCP server ${JSON.stringify(server)}.`); + } const manifestPath = joinArtifact(artifact, documentPath); let document: unknown; try { @@ -76,7 +76,9 @@ const hostMcpDocument = async ( throw new Error(`MCP manifest for target ${JSON.stringify(host)} is not valid JSON.`); } const result = readTargetMcpServer(runtime, document, server); - if (result.status === 'missing') return undefined; + if (result.status === 'missing') { + throw new Error(`MCP manifest for target ${JSON.stringify(host)} names no server ${JSON.stringify(server)}.`); + } if (result.status === 'invalid') { throw new Error(`MCP server ${JSON.stringify(server)} in target ${JSON.stringify(host)} is invalid.`); } @@ -171,45 +173,15 @@ export const resolveMcpStdioLaunch = async ( pluginRoot: targetRoot, workspaceRoot: resolve(options.workspaceRoot), }; - const entry = joinArtifact(targetRoot, row.launch.entry); + joinArtifact(targetRoot, row.launch.entry); if (row.launch.worker !== undefined) { joinArtifact(targetRoot, row.launch.worker); } - const hostLaunch = hostStdio === undefined - ? undefined - : resolveMcpPathTokens({ - roots, - runtime: launchRuntime, - server: hostStdio, - target: host, - }); - if (hostLaunch !== undefined && hostLaunch.kind !== 'stdio') { + const hostLaunch = resolveMcpPathTokens({ roots, runtime: launchRuntime, server: hostStdio, target: host }); + if (hostLaunch.kind !== 'stdio') { throw new Error(`MCP server ${JSON.stringify(options.server)} is not a stdio server; only stdio servers can run in the foreground.`); } - // Without a host document the record itself is the launch line, under the - // same per-field root split and plugin-root anchor the host projections get. - if (hostLaunch === undefined) { - const envRoots = { ...roots, pluginRoot: envPluginRoot }; - return Object.freeze({ - args: Object.freeze([ - entry, - ...row.launch.args.map((argument) => argument.kind === 'artifact' - ? joinArtifact(targetRoot, argument.path) - : expandLaunchTokens(argument.value, roots)), - ]), - command: 'node', - cwd: targetRoot, - env: Object.freeze({ - [pluginRootEnvAnchor]: envPluginRoot, - ...Object.fromEntries( - Object.entries(row.launch.env).map(([key, value]) => [key, expandLaunchTokens(value, envRoots)]), - ), - }), - }); - } - // The host document is the adapter's own launch line for that entry — `AB6017` - // refused any build whose document skips it, and the digest walk above proved the - // document unchanged — so its argument order is the launch order. + // Validation binds this host launch to the manifest record; preserve its argument order. return Object.freeze({ args: Object.freeze([...hostLaunch.args]), command: hostLaunch.command, diff --git a/packages/agent-bundle/src/web-host/manifest.ts b/packages/agent-bundle/src/web-host/manifest.ts index 03edd23e9..c63a40067 100644 --- a/packages/agent-bundle/src/web-host/manifest.ts +++ b/packages/agent-bundle/src/web-host/manifest.ts @@ -218,15 +218,26 @@ export const mcpServerKinds = Object.freeze(['command', 'compiled', 'prebuilt', const isMcpServerKind = (value: unknown): value is (typeof mcpServerKinds)[number] => typeof value === 'string' && (mcpServerKinds as readonly string[]).includes(value); +export const artifactManifestFileKinds = Object.freeze(['bundle', 'copy', 'generated', 'prebuilt'] as const); +export type ArtifactManifestFileKind = (typeof artifactManifestFileKinds)[number]; + +const isArtifactManifestFileKind = (value: unknown): value is ArtifactManifestFileKind => + typeof value === 'string' && (artifactManifestFileKinds as readonly string[]).includes(value); + +export interface ArtifactManifestServerLaunch { + readonly kind: 'compiled' | 'prebuilt'; + readonly launch: ArtifactManifestLaunch; +} + /** * The launch record of every compiled or prebuilt server, keyed by configured * server name. Two rows of one name are refused rather than the later one * winning: a reader launching by name must never choose between two records. */ -export const parseServerLaunches = (value: unknown): ReadonlyMap => { +export const parseServerLaunches = (value: unknown): ReadonlyMap => { const servers = record(value, 'executables')['mcpServers']; if (!Array.isArray(servers)) throw invalid('executables.mcpServers must be an array.'); - const launches = new Map(); + const launches = new Map(); const names = new Set(); servers.forEach((candidate: unknown, index: number) => { const location = `executables.mcpServers[${index}]`; @@ -234,14 +245,17 @@ export const parseServerLaunches = (value: unknown): ReadonlyMap => { +/** The `files[]` rows by root-relative path, each with its kind: the only bytes a launch record may name. */ +export const parseFileKinds = (value: unknown): ReadonlyMap => { if (!Array.isArray(value)) throw invalid('files must be an array.'); - return new Set(value.map((candidate: unknown, index: number) => - parseArtifactFilePath(record(candidate, `files[${index}]`)['path'], `files[${index}].path`))); + const kinds = new Map(); + value.forEach((candidate: unknown, index: number) => { + const file = record(candidate, `files[${index}]`); + const path = parseArtifactFilePath(file['path'], `files[${index}].path`); + const kind = file['kind']; + if (!isArtifactManifestFileKind(kind)) throw invalid(`files[${index}].kind is unknown.`); + if (kinds.has(path)) throw invalid(`files declares ${JSON.stringify(path)} twice.`); + kinds.set(path, kind); + }); + return kinds; }; +const launchEntryKind: Readonly> = Object.freeze({ + compiled: 'bundle', + prebuilt: 'prebuilt', +}); + /** - * A launch record names indexed bytes only: its entry and worker are `files[]` - * rows, and an `artifact` argument is a row or a directory under the root that - * holds rows (a payload tree indexed file by file), never a path the root does - * not contain. + * A launch record names indexed bytes only: its entry is a `files[]` row of + * the kind its server kind compiles to, its worker a `bundle` row, and an + * `artifact` argument is a row or a directory under the root that holds rows + * (a payload tree indexed file by file), never a path the root does not + * contain. */ export const requireLaunchFiles = ( - launches: ReadonlyMap, - filePaths: ReadonlySet, + launches: ReadonlyMap, + files: ReadonlyMap, ): void => { - const paths = [...filePaths]; - const inRoot = (path: string): boolean => filePaths.has(path) || paths.some((file) => file.startsWith(`${path}/`)); - for (const [name, launch] of launches) { + const paths = [...files.keys()]; + const inRoot = (path: string): boolean => files.has(path) || paths.some((file) => file.startsWith(`${path}/`)); + const requireRow = (location: string, path: string, kind: ArtifactManifestFileKind): void => { + const actual = files.get(path); + if (actual === undefined) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`); + if (actual !== kind) fail(`${location} names ${JSON.stringify(path)}, a ${actual} file, not a ${kind} file.`); + }; + for (const [name, { kind, launch }] of launches) { const location = `executables.mcpServers[${name}].launch`; - if (!filePaths.has(launch.entry)) { - fail(`${location}.entry names ${JSON.stringify(launch.entry)}, which is not a manifest file.`); - } - if (launch.worker !== undefined && !filePaths.has(launch.worker)) { - fail(`${location}.worker names ${JSON.stringify(launch.worker)}, which is not a manifest file.`); - } + requireRow(`${location}.entry`, launch.entry, launchEntryKind[kind]); + if (launch.worker !== undefined) requireRow(`${location}.worker`, launch.worker, 'bundle'); launch.args.forEach((argument, index) => { if (argument.kind === 'artifact' && !inRoot(argument.path)) { fail(`${location}.args[${index}].path names ${JSON.stringify(argument.path)}, which is not inside the artifact.`); @@ -300,7 +329,7 @@ export const requireLaunchFiles = ( /** Every exposed App's `server` is a row with the launch record ` web` starts. */ export const requireLaunchReferences = ( web: WebManifest, - launches: ReadonlyMap, + launches: ReadonlyMap, ): void => { for (const app of web.apps) { if (!launches.has(app.server)) { @@ -328,13 +357,13 @@ export const readWebManifestDocument = async (manifestPath: string): Promise [name, launch])), ...(web === undefined ? {} : { web }), }; } catch (error) { diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 4ff02b2de..b4a73e766 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -556,26 +556,23 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( } }); -it('rejects a manifested MCP host without its projection MCP document', async () => { +it('never publishes a manifested MCP host without its projection MCP document', async () => { + // `validateArtifact` refuses the manifest before the epoch publishes, so the + // inspection service never derives runtime metadata from a pointerless host. const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-inspection-mcp-document-')); const registry = runtimeRegistry(); const store = new EpochStore({ projectRoot: root }); try { - await publish({ + await expect(publish({ files: runtimeFiles(), id: 'epoch-missing-mcp-document', omitMcpDocument: true, registry, root, store, - }); - - await expect(new ArtifactInspectionService(store, registry).inspect('epoch-missing-mcp-document')) - .rejects.toMatchObject({ - code: 'ARTIFACT_INSPECTION_RUNTIME_INVALID', - diagnostics: [expect.objectContaining({ code: 'AB6202' })], - }); + })).rejects.toThrow(`projections["${fixtureTarget}"].documents.mcp is absent, but the target's MCP manifest is "mcp.json".`); + await expect(store.listEpochs()).resolves.toEqual([]); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index daeab04b5..4a37e3a03 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -31,8 +31,10 @@ import { artifactManifestVersion, assembleArtifactManifest, type ArtifactManifest, + type ArtifactManifestMcpServer, } from '../src/build/manifest.ts'; import { artifactDiagnosticRecoveries, validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; +import type { Diagnostic } from '../src/core/diagnostics.ts'; import { digest, sha256Hex } from '../src/core/digest.ts'; import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; import { createMcpPathTokenResolver } from '../src/services/mcp-path-tokens.ts'; @@ -65,6 +67,7 @@ const manifestFor = ( files: readonly ArtifactFixtureFile[], includeModes = true, projections: readonly FixtureProjection[] = [], + mcpServers: readonly ArtifactManifestMcpServer[] = [], ): ArtifactManifest => { const configHash = hash('export default {};\n'); const sourceInputs = [{ path: 'agent-bundle.config.ts', sha256: configHash }]; @@ -114,7 +117,7 @@ const manifestFor = ( }, }, distribution: { channels: ['local'], payloads: [] }, - executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, + executables: { bins: [], hooks: [], mcpServers, scripts: [] }, files: manifestFiles, manifestVersion: artifactManifestVersion, projections: publicProjections, @@ -134,6 +137,7 @@ const writeArtifact = async ( files: readonly ArtifactFixtureFile[], includeModes = true, projections: readonly FixtureProjection[] = [], + mcpServers: readonly ArtifactManifestMcpServer[] = [], ): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-validator-')); for (const file of files) { @@ -144,7 +148,7 @@ const writeArtifact = async ( } await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(files, includeModes, projections)).bytes, + assembleArtifactManifest(manifestFor(files, includeModes, projections, mcpServers)).bytes, ); return root; }; @@ -335,7 +339,11 @@ const coherenceMetadata = Object.freeze({ schemas: Object.freeze([]), }); -const coherenceManifestTarget = Object.freeze({ ...coherenceMetadata, documents: Object.freeze({}), host: coherenceTarget }); +const coherenceManifestTarget = Object.freeze({ + ...coherenceMetadata, + documents: Object.freeze({ mcp: 'native/servers.json' }), + host: coherenceTarget, +}); const coherenceRegistry = (): TargetRegistry => new TargetRegistry().register({ artifactLayout: { mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' } }, @@ -1426,7 +1434,7 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', { contents: 'export const neighbor = true;\n', kind: 'bundle', path: 'neighbor/mcp/mcp-server-deadbeef.mjs' }, ], true, [coherenceManifestTarget, Object.freeze({ ...siblingMetadata, - documents: Object.freeze({}), + documents: Object.freeze({ mcp: 'native/servers.json' }), host: siblingTarget, })]); @@ -1520,6 +1528,90 @@ it.each([ } }); +it('rejects host document launches that disagree with the manifest', async () => { + const entry = 'mcp/mcp-server-deadbeef.mjs'; + const other = 'mcp/mcp-other-deadbeef.mjs'; + const config = 'payload/config.json'; + const schema = 'payload/schema.json'; + const serverRow = (launchEntry: string): ArtifactManifestMcpServer => ({ + apps: [], + hosts: [coherenceTarget], + id: 'mcp:server', + kind: 'compiled', + // The author's plugin-root-anchored arguments are `artifact` rows; a bare + // relative argument stays a literal even though the document resolves it + // inside the artifact. + launch: { + args: [{ kind: 'artifact', path: config }, { kind: 'literal', value: '--verbose' }, { kind: 'artifact', path: schema }], + entry: launchEntry, + env: {}, + }, + name: 'server', + transport: 'stdio', + }); + const document = (server: Record, documentServer = 'server'): ArtifactFixtureFile => ({ + contents: `${JSON.stringify({ mcpServers: { [documentServer]: server } })}\n`, + kind: 'generated', + path: 'native/servers.json', + }); + const stdio = (args: readonly string[]): Record => ({ args, command: 'node', type: 'stdio' }); + const files = (documentFile: ArtifactFixtureFile): ArtifactFixtureFile[] => [ + documentFile, + { contents: 'export const server = true;\n', kind: 'bundle', path: entry }, + { contents: 'export const other = true;\n', kind: 'bundle', path: other }, + { contents: 'export const bootstrap = true;\n', kind: 'generated', path: 'payload/bootstrap.mjs' }, + { contents: '{}\n', kind: 'generated', path: config }, + { contents: '{}\n', kind: 'generated', path: schema }, + ]; + const agreementDiagnostics = (diagnostics: readonly Diagnostic[]): readonly string[] => diagnostics + .filter((entry) => entry.code === 'AB6017' && /manifest launch record|names no such server|documents\.mcp/u.test(entry.message)) + .map((entry) => entry.message); + const cases: readonly { + readonly document: ArtifactFixtureFile; + readonly expected: readonly string[]; + readonly projection?: FixtureProjection; + }[] = [ + { document: document(stdio([entry, config, './extra.txt', '--verbose', schema])), expected: [] }, + { + document: document(stdio([other, config, '--verbose', schema])), + expected: [`MCP server "server" in target "coherent" starts "${other}" in the target document, but its manifest launch record starts "${entry}".`], + }, + { + document: document(stdio(['payload/bootstrap.mjs', entry, config, '--verbose', schema])), + expected: ['MCP server "server" in target "coherent" starts "payload/bootstrap.mjs" in the target document, ' + + `but its manifest launch record starts "${entry}".`], + }, + { + document: document(stdio([entry, schema, '--verbose', config])), + expected: [`MCP server "server" in target "coherent" does not pass "${schema}" after "${entry}" in the order of its manifest ` + + `launch record; the target document names ["${entry}","${schema}","${config}"].`], + }, + { + document: document(stdio([entry, config, '--verbose', schema]), 'server-renamed'), + expected: ['MCP server "server" is declared for target "coherent" with a launch record, but the target document names no such server.'], + }, + { + document: document({ type: 'streamable-http', url: 'https://example.test/mcp' }), + expected: ['MCP server "server" in target "coherent" is a streamable-http server in the target document, ' + + 'but its manifest launch record starts it over stdio.'], + }, + { + document: document(stdio([entry, config, '--verbose', schema])), + expected: ['projections["coherent"].documents.mcp is absent, but the target\'s MCP manifest is "native/servers.json".'], + projection: { ...coherenceManifestTarget, documents: {} }, + }, + ]; + const roots = await Promise.all(cases.map((scenario) => + writeArtifact(files(scenario.document), true, [scenario.projection ?? coherenceManifestTarget], [serverRow(entry)]))); + try { + for (const [index, root] of roots.entries()) { + expect(agreementDiagnostics(await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() }))).toEqual(cases[index]!.expected); + } + } finally { + await Promise.all(roots.map((root) => rm(root, { force: true, recursive: true }))); + } +}); + it('rejects duplicate keys in a canonically manifested native MCP document', async () => { const server = '{"args":[],"command":"node","type":"stdio"}'; const root = await writeArtifact([{ diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 3d0fc63fb..ca930e306 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -488,16 +488,10 @@ describe('composite plugin root (#555)', () => { }); }); - it('mcp run launches from the manifest record alone when the host document names no such server (#604)', { timeout: 180_000 }, async () => { - // Built with the adapter as shipped; run through a registry whose reader - // reports the document's servers under other names — the compiled entry - // stays referenced (AB6017 holds), but no document row is `fixture`, so - // only `executables.mcpServers[].launch` can describe the launch. - const { output, result } = await buildFixture(['synthetic'], { + it('mcp run refuses a root whose host document names the manifest server differently (#604)', { timeout: 180_000 }, async () => { + const { output } = await buildFixture(['synthetic'], { registry: new TargetRegistry().register(syntheticAdapter, { default: true }), }); - const record = result.build.manifest.executables.mcpServers[0]?.launch; - if (record === undefined) throw new Error('expected a compiled MCP entry'); const aliasingRuntime: TargetMcpRuntimeContract = { ...syntheticMcpRuntime, readModernServers: (document) => { @@ -508,26 +502,26 @@ describe('composite plugin root (#555)', () => { }, }; const blindRegistry = new TargetRegistry().register({ ...syntheticAdapter, mcpRuntime: aliasingRuntime }, { default: true }); - const launches: { args: readonly string[]; command: string; cwd: string; env: Readonly> }[] = []; - const child = new EventEmitter() as ChildProcess; - child.kill = () => true; - const workspaceRoot = dirname(output); + const spawned: string[] = []; await expect(runMcp({ artifact: output, loadEnvFiles: false, registry: blindRegistry, - root: workspaceRoot, + root: dirname(output), server: 'fixture', - spawnProcess: (command, args, options) => { - launches.push({ args, command, cwd: options.cwd, env: options.env }); - queueMicrotask(() => child.emit('exit', 0, null)); - return child; + spawnProcess: (command) => { + spawned.push(command); + throw new Error('unreachable'); }, target: 'synthetic', - })).resolves.toBe(0); - expect(launches).toHaveLength(1); - expect(launches[0]).toMatchObject({ args: [join(output, record.entry)], command: 'node', cwd: output }); - expect(launches[0]?.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(workspaceRoot); + })).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB6017', + message: expect.stringContaining('the target document names no such server'), + target: 'synthetic', + })], + }); + expect(spawned).toEqual([]); }); it('judges the built-in hosts by adapter identity, so a custom adapter named like one earns no install surface (#592)', { timeout: 120_000 }, async () => { diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 32da962b6..958e0da6e 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -906,6 +906,29 @@ it('reports manifest-indexed byte drift as AB7001 with the path', async () => { } }); +it('reports manifest-indexed mode drift as AB7001', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await mkdir(join(home, '.cursor')); + try { + await chmod(join(fixture.bundleRoot, 'payload.txt'), 0o755); + const error = await installBundle({ from: fixture.from, home, host: 'cursor' }) + .catch((failure: unknown) => failure); + + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7001', + message: expect.stringContaining('--from root does not match its manifest: payload.txt differs from its files[] row in bytes, mode, or digest.'), + target: 'cursor', + }]); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + it('copies and hashes an operator .env beside the artifact', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts index a6249dd7a..5974434e3 100644 --- a/packages/agent-bundle/tests/manifest-schema.test.ts +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -536,6 +536,14 @@ const schemaEncodedRules: readonly { readonly apply: (manifest: MutableManifest) { apply: (manifest) => { manifest.files[0]!.path = 'claude//hooks.json'; }, rule: 'paths have no empty segment' }, { apply: (manifest) => { manifest.files[0]!.path = 'claude\\hooks.json'; }, rule: 'paths have no backslash' }, { apply: (manifest) => { manifest.files[0]!.path = 'claude/hooks.json/'; }, rule: 'paths have no trailing slash' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/notes.md:stream'; }, rule: 'path segments have no Windows-reserved character' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/CON.txt'; }, rule: 'path segments are not Windows device names' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/con.txt'; }, rule: 'path segments are not Windows device names in any letter case' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/COM\u00b9.log'; }, rule: 'path segments are not superscript-digit Windows device names' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/lpt\u00b3'; }, rule: 'path segments are not bare Windows device names' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/hooks.json.'; }, rule: 'path segments have no trailing dot' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude /hooks.json'; }, rule: 'path segments have no trailing space' }, + { apply: (manifest) => { manifest.files[0]!.path = 'claude/hooks\u0001.json'; }, rule: 'path segments have no control character' }, { apply: (manifest) => { manifest.files[0]!.bytes = -1; }, rule: 'files[].bytes is non-negative' }, { apply: (manifest) => { manifest.files[0]!.bytes = 1.5; }, rule: 'files[].bytes is an integer' }, { apply: (manifest) => { manifest.files[6]!.mode = 0o1000; }, rule: 'files[].mode is at most 0o777' }, @@ -684,6 +692,17 @@ it('encodes the parser rules a schema can state, so both reject the same values' expect(outcomes).toEqual(schemaEncodedRules.map(({ rule }) => ({ parser: 'rejects', rule, schema: 'rejects' }))); }); +it('accepts path segments that only resemble Windows device names in both the parser and the schema', () => { + for (const path of ['claude/COM10.log', 'claude/console.txt', 'claude/nulled/index.json', 'claude/lpt.txt']) { + const manifest: ArtifactManifest = { + ...minimalManifest(), + compiler: { ...minimalManifest().compiler, provenance: [provenance(path)] }, + files: [file(path, 'generated')], + }; + expect([path, verdict(parserAccepts(manifest)), verdict(schemaAccepts(manifest))]).toEqual([path, 'accepts', 'accepts']); + } +}); + it('reports closed-key, required-key, and type failures as formatted lines in deterministic order', () => { const root = clone() as unknown as Record_; root.zzz = true; diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index 9eb759772..ec0ce3adb 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -471,6 +471,16 @@ it('binds the launch record to compiled servers and every exposed App to a launc expect(() => serializeArtifactManifest(unlistedEntry)) .toThrow('executables.mcpServers[catalog].launch.entry names "codex/scripts/missing.mjs", which is not a manifest file.'); + const generatedEntry = withWeb(); + (generatedEntry.executables.mcpServers[0]!.launch as { entry: string }).entry = 'codex/config.json'; + expect(() => serializeArtifactManifest(generatedEntry)) + .toThrow('executables.mcpServers[catalog].launch.entry names "codex/config.json", a generated file, not a bundle file.'); + + const prebuiltAtBundle = withWeb(); + (prebuiltAtBundle.executables.mcpServers[0] as { kind: string }).kind = 'prebuilt'; + expect(() => serializeArtifactManifest(prebuiltAtBundle)) + .toThrow('executables.mcpServers[catalog].launch.entry names "codex/scripts/review.mjs", a bundle file, not a prebuilt file.'); + const escapedArgument = withWeb(); (escapedArgument.executables.mcpServers[0]!.launch!.args as unknown[])[1] = { kind: 'artifact', path: 'codex/missing' }; expect(() => serializeArtifactManifest(escapedArgument)) diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 5e97f5b02..f5fb1f556 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -42,7 +42,7 @@ const writePackedFixtureManifest = async (artifactRoot: string): Promise file.path !== artifactManifestName) .map((file) => ({ bytes: file.bytes, - kind: 'generated' as const, + kind: file.path === 'mcp/server.mjs' ? 'bundle' as const : 'generated' as const, ...((file.mode & 0o111) === 0 ? {} : { mode: file.mode }), path: file.path, sha256: file.sha256, diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts index 6b921e170..7c29909c6 100644 --- a/packages/agent-bundle/tests/support/install-fixture.ts +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -101,11 +101,16 @@ export const writeInstallFixtureManifest = async ( name: server, transport: 'stdio', })); + // Rows are measured the way the compiler measures them: a launch entry is a + // `bundle` row, an executable bit is recorded as `mode`, everything else is generated. + const launchEntries = new Set(mcpServers.map((server) => server.launch!.entry)); const files = await Promise.all((await fixtureFiles(bundleRoot)).map(async (path) => { - const bytes = await readFile(join(bundleRoot, path)); + const [bytes, metadata] = await Promise.all([readFile(join(bundleRoot, path)), lstat(join(bundleRoot, path))]); + const mode = metadata.mode & 0o777; return { bytes: bytes.length, - kind: 'generated' as const, + kind: launchEntries.has(path) ? 'bundle' as const : 'generated' as const, + ...((mode & 0o111) === 0 ? {} : { mode }), path, sha256: sha256Hex(bytes), }; diff --git a/packages/agent-bundle/tests/web-host-routes-unit.test.ts b/packages/agent-bundle/tests/web-host-routes-unit.test.ts index ce58d8457..94ab4c84f 100644 --- a/packages/agent-bundle/tests/web-host-routes-unit.test.ts +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -71,7 +71,7 @@ const writeFixture = async (root: string, options: FixtureOptions): Promise ({ host })), web: { diff --git a/packages/agent-bundle/tests/web-manifest.test.ts b/packages/agent-bundle/tests/web-manifest.test.ts index 010175ebc..5adee3aa5 100644 --- a/packages/agent-bundle/tests/web-manifest.test.ts +++ b/packages/agent-bundle/tests/web-manifest.test.ts @@ -90,7 +90,12 @@ const catalogRow = (kind: 'compiled' | 'prebuilt' = 'compiled') => ({ apps: [], hosts: ['claude'], id: 'mcp:catalog', kind, launch: validLaunch(), name: 'catalog', transport: 'stdio', }); -const fileRows = (...paths: readonly string[]) => paths.map((path) => ({ bytes: 1, kind: 'generated', path, sha256: 'x' })); +const fileRows = (...paths: readonly string[]) => paths.map((path) => ({ + bytes: 1, + kind: path.startsWith('mcp/') ? 'bundle' : path.startsWith('runtime/') ? 'prebuilt' : 'generated', + path, + sha256: 'x', +})); const document = (overrides: Readonly> = {}): Readonly> => ({ application: { id: 'application:fixture', name: 'fixture', version: '1.0.0' }, @@ -185,10 +190,21 @@ it('refuses a launch record naming bytes the manifest does not index', () => wit [fileRows(validLaunch().worker!, 'payload/config.json'), 'executables.mcpServers[catalog].launch.entry names "mcp/mcp-catalog-01234567.mjs", which is not a manifest file.'], [fileRows(validLaunch().entry, 'payload/config.json'), 'executables.mcpServers[catalog].launch.worker names "mcp/mcp-catalog-01234567-flight.mjs", which is not a manifest file.'], [fileRows(validLaunch().entry, validLaunch().worker!), 'executables.mcpServers[catalog].launch.args[1].path names "payload/config.json", which is not inside the artifact.'], + [ + [{ ...fileRows(validLaunch().entry)[0], kind: 'generated' }, ...fileRows(validLaunch().worker!, 'payload/config.json')], + 'executables.mcpServers[catalog].launch.entry names "mcp/mcp-catalog-01234567.mjs", a generated file, not a bundle file.', + ], + [ + [...fileRows(validLaunch().entry, 'payload/config.json'), { ...fileRows(validLaunch().worker!)[0], kind: 'copy' }], + 'executables.mcpServers[catalog].launch.worker names "mcp/mcp-catalog-01234567-flight.mjs", a copy file, not a bundle file.', + ], + [[...fileRows(validLaunch().entry), ...fileRows(validLaunch().entry)], 'files declares "mcp/mcp-catalog-01234567.mjs" twice.'], + [[{ ...fileRows(validLaunch().entry)[0], kind: 'symlink' }], 'files[0].kind is unknown.'], [[{ path: '../escaped.mjs' }], 'files[0].path must be a safe relative POSIX path.'], [[{ path: 'State/server.mjs' }], 'files[0].path must not be under the runtime-owned root "state/".'], [[{ path: '.agent-bundle-install.json' }], 'files[0].path must not be at or under the installer\'s receipt ".agent-bundle-install.json".'], [[{ path: 'agent-bundle.manifest.json' }], 'files[0].path must not name the manifest itself.'], + [[{ path: 'mcp/CON.mjs' }], 'files[0].path must be a safe relative POSIX path.'], [['mcp/mcp-catalog-01234567.mjs'], 'files[0] must be a plain object.'], [undefined, 'files must be an array.'], ]; diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx index a8d7913d7..5a88cea91 100644 --- a/website/docs/en/reference/artifact-manifest.mdx +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -23,7 +23,7 @@ directory layout to find out. | Canonical bytes | The file is the canonical JSON serialization of the document — object keys sorted, no insignificant whitespace, one trailing newline. Every reader re-serializes what it parsed and rejects the file unless the bytes are identical. A hand edit, a reformat, or a duplicate key fails the read. | | Closed keys | Every object admits exactly its documented keys. An unknown key at any level is a parse error. | | Sorted arrays | Every array has an explicit sort key (named per section below) and no duplicates. | -| Lean reader | The rows above describe the full parser (`parseArtifactManifest`), which every consumer in the table below uses except one: the ` web` support bundled into a generated bin reads the manifest without the parser. It validates only the slices it launches from and ignores every other key, but those slices it refuses rather than filters, under the same checks the full parser runs (`web-host/manifest.ts` owns them; both import it): a `manifestVersion` other than `2` or a duplicate JSON key; a `projections[]` row that is not an object, names no host, or repeats one; an `executables.mcpServers[]` row that is not an object, has an unknown `kind`, carries a `launch` record when its kind must not (or lacks one when it must), or repeats a server `name` — two rows of one name never resolve to the later one; a malformed `launch` record, or one whose entry, worker, or `artifact` argument is not a `files[]` row (an argument may name a directory under the root that holds rows); and a `web.apps[]` row whose `server` is not a row with a launch record. | +| Lean reader | The rows above describe the full parser (`parseArtifactManifest`), which every consumer in the table below uses except one: the ` web` support bundled into a generated bin reads the manifest without the parser. It validates only the slices it launches from and ignores every other key, but those slices it refuses rather than filters, under the same checks the full parser runs (`web-host/manifest.ts` owns them; both import it): a `manifestVersion` other than `2` or a duplicate JSON key; a `projections[]` row that is not an object, names no host, or repeats one; an `executables.mcpServers[]` row that is not an object, has an unknown `kind`, carries a `launch` record when its kind must not (or lacks one when it must), or repeats a server `name` — two rows of one name never resolve to the later one; a malformed `launch` record, or one whose entry is not the `files[]` row its server kind starts (`bundle` for `compiled`, `prebuilt` for `prebuilt`), whose worker is not a `bundle` row, or whose `artifact` argument is not inside the root (an argument may name a directory under the root that holds rows); and a `web.apps[]` row whose `server` is not a row with a launch record. | | Paths | Every path is POSIX, relative to the root, and safe: no leading `/`, no backslash, no `.` or `..` segment, and never a build-machine absolute path. Moving the entire composite root to a different absolute directory keeps every reader working: every path stays root-relative and resolves inside the moved tree. Every path a section points at — a host document, an executable, an App view, an install file — is also a `files[]` row. | ## Who writes it @@ -88,7 +88,7 @@ each row lives on `compiler.provenance`, not here. | Field | Required | Contents | | --- | --- | --- | -| `path` | yes | Root-relative path. Never `agent-bundle.manifest.json`, and never at or under a root entry the artifact does not own, in any letter case: the runtime's `state/` (so a payload may not be named `state`, `AB4741`) and the installer's `.agent-bundle-install.json` receipt. Installers neither copy nor own those entries, so a copy of the indexed rows always re-measures to the manifest's inventory. | +| `path` | yes | Root-relative path whose every segment is portable: no control character, none of the characters Windows reserves (angle brackets, colon, double quote, pipe, question mark, asterisk), no Windows device name (`CON`, `NUL`, `COM1`, …), no trailing dot or space — the installer's receipt holds paths under the same rule, so a manifest the parser accepts is one every installer can copy and own. Never `agent-bundle.manifest.json`, and never at or under a root entry the artifact does not own, in any letter case: the runtime's `state/` (so a payload may not be named `state`, `AB4741`) and the installer's `.agent-bundle-install.json` receipt. Installers neither copy nor own those entries, so a copy of the indexed rows always re-measures to the manifest's inventory. | | `bytes` | yes | Size in bytes. | | `sha256` | yes | Content digest. Validation compares real bytes, so a hand-edited generated file fails. | | `kind` | yes | `bundle` (a compiled entry), `copy` (a byte-for-byte copy), `generated` (a compiler-written document), or `prebuilt` (a prebuilt payload file). | @@ -440,7 +440,7 @@ mismatch in either direction is a rebuild, not a fallback. | `AB6001` | The manifest is not a strict canonical manifest, or it changed while validation ran. | | `AB6004` | A `files[]` row and the tree disagree. | | `AB6018` | `executables.hooks[]` and a host hooks document disagree about the compiler wrappers. | -| `AB7001` | `install`, `uninstall`, or `doctor` cannot resolve the host from the manifest at `--from`, or a listed file is missing or differs from its `files[]` digest. | +| `AB7001` | `install`, `uninstall`, or `doctor` cannot resolve the host from the manifest at `--from`, or a listed file is missing or differs from its `files[]` row in size, executable bit, or digest. | All are described in the [Diagnostics reference](./diagnostics.md). diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx index ea2a24609..7b79e0de3 100644 --- a/website/docs/zh/reference/artifact-manifest.mdx +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -20,7 +20,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 规范字节 | 文件就是该文档的规范 JSON 序列化——对象键已排序、没有无意义的空白、末尾恰有一个换行。每个读取方都会把解析结果重新序列化,字节不完全一致就拒绝该文件。手工编辑、重新格式化或重复键都会让读取失败。 | | 封闭键 | 每个对象只接受文档化的那些键。任何层级上的未知键都是解析错误。 | | 有序数组 | 每个数组都有显式的排序键(各分节下方注明),且没有重复项。 | -| 精简读取方 | 上面各行描述的是完整解析器(`parseArtifactManifest`),下表中的消费者除一个之外都使用它:打包进生成 bin 的 ` web` 支持不经解析器读取清单。它只校验自己启动所需的切片并忽略其余所有键,但对这些切片是拒绝而非过滤,所用检查与完整解析器相同(由 `web-host/manifest.ts` 持有,两者都从它导入):`2` 以外的 `manifestVersion` 或重复的 JSON 键;不是对象、未命名 host 或重复 host 的 `projections[]` 行;不是对象、`kind` 未知、按 kind 不应携带却带有 `launch` 记录(或应携带却缺失)、或重复 server `name` 的 `executables.mcpServers[]` 行——同名两行绝不会解析为后者;格式错误的 `launch` 记录,或其 entry、worker 或 `artifact` 参数不是 `files[]` 行的记录(参数可以指向根目录下含有行的目录);以及 `server` 并非带 launch 记录之行的 `web.apps[]` 行。 | +| 精简读取方 | 上面各行描述的是完整解析器(`parseArtifactManifest`),下表中的消费者除一个之外都使用它:打包进生成 bin 的 ` web` 支持不经解析器读取清单。它只校验自己启动所需的切片并忽略其余所有键,但对这些切片是拒绝而非过滤,所用检查与完整解析器相同(由 `web-host/manifest.ts` 持有,两者都从它导入):`2` 以外的 `manifestVersion` 或重复的 JSON 键;不是对象、未命名 host 或重复 host 的 `projections[]` 行;不是对象、`kind` 未知、按 kind 不应携带却带有 `launch` 记录(或应携带却缺失)、或重复 server `name` 的 `executables.mcpServers[]` 行——同名两行绝不会解析为后者;格式错误的 `launch` 记录,或其 entry 不是其服务器 kind 所启动的那种 `files[]` 行(`compiled` 对应 `bundle`,`prebuilt` 对应 `prebuilt`)、worker 不是 `bundle` 行、或 `artifact` 参数不在根目录之内的记录(参数可以指向根目录下含有行的目录);以及 `server` 并非带 launch 记录之行的 `web.apps[]` 行。 | | 路径 | 每个路径都是 POSIX 形式、相对根目录且安全:没有前导 `/`、没有反斜杠、没有 `.` 或 `..` 段,也绝不会是构建机上的绝对路径。把整个组合根目录搬到另一个绝对路径后,每个读取方仍然可用:所有路径都保持相对根目录,并在搬迁后的树内解析。任何分节指向的路径——宿主文档、可执行文件、App 视图、安装文件——同时也是一行 `files[]`。 | ## 由谁写入 @@ -79,7 +79,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | 字段 | 必填 | 内容 | | --- | --- | --- | -| `path` | 是 | 相对根目录的路径。绝不会是 `agent-bundle.manifest.json`,也绝不会位于工件不拥有的根条目之上或之下(任何大小写):运行时的 `state/`(因此 payload 不能命名为 `state`,`AB4741`)与安装器的 `.agent-bundle-install.json` 回执。安装器既不复制也不拥有这些条目,因此对已索引行的复制总能重新测量出与清单一致的清点。 | +| `path` | 是 | 相对根目录的路径,且每个路径段都可移植:没有控制字符,也没有 Windows 保留的字符(尖括号、冒号、双引号、竖线、问号、星号),不是 Windows 设备名(`CON`、`NUL`、`COM1`……),结尾没有点或空格——安装器回执中的路径遵守同一规则,因此解析器接受的清单,每个安装器都能复制并拥有。绝不会是 `agent-bundle.manifest.json`,也绝不会位于工件不拥有的根条目之上或之下(任何大小写):运行时的 `state/`(因此 payload 不能命名为 `state`,`AB4741`)与安装器的 `.agent-bundle-install.json` 回执。安装器既不复制也不拥有这些条目,因此对已索引行的复制总能重新测量出与清单一致的清点。 | | `bytes` | 是 | 字节大小。 | | `sha256` | 是 | 内容摘要。校验比对的是真实字节,因此被手工改过的生成文件会失败。 | | `kind` | 是 | `bundle`(编译后的入口)、`copy`(逐字节复制)、`generated`(编译器写出的文档)或 `prebuilt`(预构建 payload 文件)。 | @@ -418,7 +418,7 @@ Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录 | `AB6001` | 清单不是严格的规范清单,或在校验运行期间发生了变化。 | | `AB6004` | 某行 `files[]` 与树不一致。 | | `AB6018` | `executables.hooks[]` 与某份宿主钩子文档就编译器 wrapper 不一致。 | -| `AB7001` | `install`、`uninstall` 或 `doctor` 无法从 `--from` 下的清单解析出该宿主,或某个列出的文件缺失或与其 `files[]` 摘要不符。 | +| `AB7001` | `install`、`uninstall` 或 `doctor` 无法从 `--from` 下的清单解析出该宿主,或某个列出的文件缺失,或在大小、可执行位或摘要上与其 `files[]` 行不符。 | 全部说明见[诊断参考](./diagnostics.md)。