From 73ac5254236dc1ec3b08ab3ed2de2231b38daf4f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:23:37 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(portable):=20complete=20Agent=20Plugin?= =?UTF-8?q?s=201.0.0=20adoption=20=E2=80=94=20manifest=20metadata,=20pinne?= =?UTF-8?q?d=20byte=20lane,=20doctor=20findings=20(#307)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Author the standard's §5.4 manifest metadata and §5.6 extensions under the portable config key and emit them into root plugin.json; add the pinned Agent Plugins byte lane (AB6035–AB6038) to validate --host-validation, to doctor for installed Cursor local plugins declaring the standard's $schema (AB7320), and to the portable host-install proof; record dated capability rows for every standard feature; re-verify schema pins; adapterRevision 1.5.0 → 1.6.0. --- .changeset/portable-agent-plugins-contract.md | 5 + README.md | 2 + docs/diagnostics.md | 38 +- docs/framework-mode.md | 16 +- .../adapters/capabilities/portable-1.0.0.json | 38 ++ .../agent-bundle/src/adapters/portable.ts | 266 +++++++- .../adapters/schemas/portable/PROVENANCE.json | 2 + packages/agent-bundle/src/api.ts | 69 +- .../portable-plugin-validation.ts | 592 ++++++++++++++++++ packages/agent-bundle/src/index.ts | 2 + packages/agent-bundle/src/install/doctor.ts | 73 ++- .../tests/adapter-capability-states.test.ts | 16 +- .../tests/adapter-metadata.test.ts | 17 +- packages/agent-bundle/tests/doctor.test.ts | 58 ++ .../agent-bundle.config.ts | 8 + .../tests/host-install-proof.test.ts | 2 + .../tests/portable-adapter.test.ts | 123 ++++ .../tests/portable-plugin-validation.test.ts | 247 ++++++++ .../tests/support/host-install.ts | 21 + 19 files changed, 1548 insertions(+), 47 deletions(-) create mode 100644 .changeset/portable-agent-plugins-contract.md create mode 100644 packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts create mode 100644 packages/agent-bundle/tests/portable-plugin-validation.test.ts diff --git a/.changeset/portable-agent-plugins-contract.md b/.changeset/portable-agent-plugins-contract.md new file mode 100644 index 000000000..5bc6f4f3d --- /dev/null +++ b/.changeset/portable-agent-plugins-contract.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +portable: complete the Agent Plugins 1.0.0 adoption (#307) — author the standard's §5.4 manifest metadata (`author`, `homepage`, `repository`, `license`, `keywords`) and §5.6 reverse-domain `extensions` under the `portable` config key and emit them into the root `plugin.json` (omitted fields leave the manifest byte-identical to the previous contract; malformed values fail closed with `portable.manifest..invalid`); add the pinned Agent Plugins byte lane (`validatePortablePlugin`, `AB6035`–`AB6038`: pinned schemas plus the normative command/cwd/URL/header/placeholder/version/skill-layout/symlink-containment rules) to `validate --artifact --host-validation`, to `doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`), and to the portable host-install proof; record dated capability rows for every standard feature (manifest metadata, extensions, extension directories, legacy SSE); re-verify the schema pins against the live 1.0.0 schemas and the specification repository (2026-09-02); adapterRevision 1.5.0 → 1.6.0. diff --git a/README.md b/README.md index f9fcb4ff5..a704f466a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ npx agent-bundle dev --root . # local workbench with live rebu `targets: ['plugin']` emits one multi-host bundle at `dist/plugin/`: `.claude-plugin/`, `.codex-plugin/`, and `.cursor-plugin/` manifests over shared `skills/`, `hooks/`, `mcp/`, and `scripts/` directories. The bundle's generated `AGENTS.md` explains how to install it into each host. Per-host layouts are available as the `claude`, `codex`, `cursor`, and `portable` targets. +The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) (specification 1.0.0) adapter — the default target, and the layout Cursor, Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT load natively (Claude Code consumes it only through CLI translation). It emits the closed root `plugin.json` (canonical `$schema`, `name`, `version`, `description`, plus `author`, `homepage`, `repository`, `license`, `keywords`, and reverse-domain `extensions` authored under the `portable` config key), `skills//SKILL.md`, and `mcp.json` with stdio and Streamable HTTP servers whose `args`, `env` values, and `cwd` use the standard's `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` placeholders. Rules, commands, hooks, marketplaces, and client extension directories are honestly unavailable there because the v1 standard packages only skills and MCP servers. Both documents are validated against the vendored, hash-pinned 1.0.0 schemas and the normative text at plan time, after every build (`AB6011`/`AB6012`), under `validate --artifact --host-validation` (`AB6035`–`AB6038`), and by `agent-bundle doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`); see [Diagnostics](docs/diagnostics.md#agent-plugins-portable-validation-ab6035ab6038). Pins live in `packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json`; the capability table `packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json` carries a dated row for every standard feature. + Claude Code language servers are declared under `claude.lspServers`; the `claude` target and the Claude half of `plugin` emit the record as plugin-root `.lsp.json`. Agent Bundle expands path tokens only in `command`, `args`, `env`, and `workspaceFolder`, and it does not include the language-server binary — install that separately so the declared command is available on `PATH`. Codex, Cursor, and the portable format do not currently receive this host-scoped configuration. Claude Code plugin defaults are declared under `claude.settings` and emitted as plugin-root `settings.json`, which Claude Code applies when the plugin is enabled. The pinned contract supports only `agent` and `subagentStatusLine`; Agent Bundle rejects any other key rather than shipping a default Claude Code would silently ignore, and it expands no path tokens here because `settings.json` is absent from the host's placeholder-substitution table. Because the plugin `agents/` component is still deferred, declaring `agent` also raises a warning: the referenced agent has to reach the plugin root some other way, such as a prebuilt payload. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index de389f3bc..d0d2d0ec9 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -24,7 +24,7 @@ gate a build, a validation, or a dev rebuild. | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB5000` | General CLI and adapter failures. | -| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`AB6025`: a manifest-declared `logo` path is missing from the artifact or escapes the deploy tree; `AB6034`: emitted Skill Markdown has no instruction body). | +| `AB60xx` | Built-artifact validation, including schema documents and referenced files (`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). | | `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7xxx` | Project preparation and development rebuilds. | @@ -74,6 +74,40 @@ locally against emitted bytes. | --- | --- | --- | --- | | `AB6034` | error | An emitted `SKILL.md` has valid YAML frontmatter but no Markdown instruction body after it. The pinned Agent Skills specification requires frontmatter followed by Markdown content. | Add Markdown instructions after the Skill frontmatter, then rebuild the artifact. | +## Agent Plugins portable validation (`AB6035`–`AB6038`) + +The `portable` target is the [Agent Plugins open standard](https://agent-plugins.org/specification) +(specification 1.0.0) adapter. Its contract is pinned in +`packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json` (schema +hashes, specification repository commit, retrieval and re-verification dates). +The standard publishes machine-readable schemas plus normative text the schemas +cannot express; the text wins on conflict, so validation runs both lanes and +never spawns a client CLI (the standard publishes no reference validator). + +Validation happens at three moments, all fail-closed: + +1. **Plan time** (`agent-bundle build`/`validate`): the emitted `plugin.json` + and `mcp.json` are validated against the pinned schemas before they are + written (`portable.schema.plugin`, `portable.schema.mcp`), authored manifest + metadata is checked field by field (`portable.manifest..invalid`), and + MCP path tokens are refused where the standard forbids them + (`portable.mcp.token.*`). These target-scoped codes are errors. +2. **Artifact time** (`agent-bundle build`, `validate --artifact`): the generic + target-contract pass reports a missing required document as `AB6011` and a + pinned-schema rejection as `AB6012`; `validate --artifact --host-validation` + additionally runs the byte lane below and returns a `portable` host + validation report. +3. **Installed bytes** (`agent-bundle doctor`): a Cursor local plugin whose root + `plugin.json` declares an Agent Plugins `$schema` is validated with the same + byte lane and reported under `AB7320` (an error marks the entry `corrupt`). + +| Code | Severity | Meaning | Recovery | +| --- | --- | --- | --- | +| `AB6035` | error | The root `plugin.json` is missing, or a present `plugin.json`/`mcp.json` is unreadable, not valid JSON, or rejected by its pinned Agent Plugins 1.0.0 schema (closed manifest fields, plugin name constraints, reserved `PLUGIN_ROOT`/`PLUGIN_DATA` env keys, closed server variants). | Repair the generated Agent Plugins document so it satisfies the pinned 1.0.0 schema, then rebuild. | +| `AB6036` | error | A normative-text rule the schemas cannot express is violated: `plugin.json` and `mcp.json` declare different Agent Plugins versions (§10.1); a stdio `command` is neither a bare executable name nor a bundled plugin-relative `./` file, or carries a placeholder (§7.2.1); a `./`, `${PLUGIN_ROOT}`, or `${PLUGIN_DATA}` `cwd` escapes its root after resolution (§4.1/§7.2.1); a remote `url` is not an absolute HTTP(S) URL, carries user information or a fragment, uses plain HTTP against a non-loopback host, or carries a placeholder; header names are invalid, repeat under different casing, or carry placeholders (§7.2.1); an `env` key carries a placeholder (§9.2); `skills/` or `mcp.json` is present with the wrong filesystem kind (§6.2); or a `skills//` directory has no regular `SKILL.md` (§7.1). | Repair the generated portable layout or MCP entry to satisfy the Agent Plugins 1.0.0 normative text, then rebuild. | +| `AB6037` | error | A symlink inside the plugin resolves outside the plugin root, or cannot be resolved at all (§4.1 containment). | Replace the escaping symlink with a file or a link that resolves inside the plugin root, then rebuild. | +| `AB6038` | info | Every portable host-validation report states that Agent Plugins publishes no reference validator and names the pinned schema provenance (specification repository commit, retrieval and re-verification dates) used for local validation. | Review the pinned Agent Plugins provenance before changing the local validator contract. | + ## npm prepack gate (`AB7010`–`AB7013`) | Code | Meaning | @@ -411,7 +445,7 @@ host CLI, repair a bundle, or perform a live protocol exchange. | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules. The message retains the originating build-validator code and detail. | Rebuild that host bundle from valid source bytes, then rerun Doctor. | -| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract; the loader-recognized entry remains `installed`. | Reinstall an invalid Cursor plugin or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | +| `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules, when a root `plugin.json` install that declares an Agent Plugins `$schema` violates the pinned Agent Plugins 1.0.0 contract (`AB6035`–`AB6037`, retained in the message), or when any local plugin contains a symlink that escapes `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info naming the contract applied to an Agent Plugins install, or stating that a `.claude-plugin/plugin.json` (or schema-less root `plugin.json`) install has no Cursor-side pinned static document contract; loader-recognized entries remain `installed`. | Reinstall an invalid Cursor plugin, rebuild an invalid portable bundle, or repair an escaping symlink. For other manifest flavors, use that ecosystem's validator when static document proof is required. | ## Development package build (`AB7103`) diff --git a/docs/framework-mode.md b/docs/framework-mode.md index d48f21b8d..e5f7ddc51 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -169,10 +169,18 @@ this format natively alongside Cursor Plugins; Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT are native clients too. Claude Code consumes the standard only through CLI translation, so its dedicated target remains necessary. The standard packages only skills and MCP servers, leaving rules, commands, and -hooks honestly unavailable on the portable target. A dogfood proof against the -real Cursor IDE plugin loader (discovery, skill listing, MCP launch, and three -observed Cursor 3.18.25 placeholder-expansion conformance gaps) is recorded in -`docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md`. +hooks honestly unavailable on the portable target. The standard's manifest +metadata (`author`, `homepage`, `repository`, `license`, `keywords`) and +reverse-domain `extensions` are authored under the `portable` config key and +land in the root `plugin.json`; omitting them leaves the manifest exactly as +before. Emitted bytes are validated against the pinned schemas and the +normative text at plan time, after every build, under +`validate --artifact --host-validation`, and by `doctor` for installed Cursor +local plugins that declare the standard's `$schema` +(`AB6035`–`AB6038`, `AB7320`; see `docs/diagnostics.md`). A dogfood proof +against the real Cursor IDE plugin loader (discovery, skill listing, MCP +launch, and three observed Cursor 3.18.25 placeholder-expansion conformance +gaps) is recorded in `docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md`. The framework CLI performs those same operations: diff --git a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json index 7a819741b..6c8837504 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -77,6 +77,10 @@ "2026-09-02: Cursor 3.18.25 does not expand ${PLUGIN_ROOT} in cwd or args and does not default an omitted cwd to the plugin root (spec 7.2.1/9.2 MUSTs), so spec-conformant stdio servers fail to launch there; its proprietary ${CURSOR_PLUGIN_ROOT} expands and connects.", "2026-09-02: With a launchable configuration the Cursor IDE completed the stdio handshake (connect_success with stable heartbeat), so the launch pipeline itself consumes this format." ], + "legacySse": { + "reason": "2026-09-02: Agent Plugins 1.0.0 §7.2.1 defines the deprecated HTTP+SSE variant as OPTIONAL for clients; the compiler emits only the required modern transports and rejects a legacy transport with AB4339 (https://agent-plugins.org/specification).", + "state": "unavailable" + }, "pathTokens": { "args": [ "${PLUGIN_DATA}", @@ -120,7 +124,41 @@ }, "observedSpecificationVersion": "1.0.0", "plugin": { + "extensionDirectories": { + "reason": "2026-09-02: Agent Plugins 1.0.0 §8.2 reserves top-level reverse-domain directories for client-owned files; the compiler emits no client extension directory because no pinned client publishes a file-based namespace contract for this format (https://agent-plugins.org/specification).", + "state": "unavailable" + }, + "extensions": { + "configKey": "portable.extensions", + "evidence": [ + "2026-09-02: Agent Plugins 1.0.0 §5.6/§8.1 — `extensions` maps reverse-domain client namespaces to opaque objects; clients ignore unimplemented namespaces without validating their contents (https://agent-plugins.org/specification)." + ], + "state": "supported" + }, "manifest": "plugin.json", + "manifestMetadata": { + "configKey": "portable", + "evidence": [ + "2026-09-02: Agent Plugins 1.0.0 §5.4 metadata fields author, homepage, repository, license, and keywords are validated only by JSON type by clients; the compiler additionally refuses malformed URLs, emails, and empty strings before emission (https://agent-plugins.org/specification)." + ], + "fields": [ + "author", + "homepage", + "keywords", + "license", + "repository" + ], + "state": "supported" + }, "skills": true + }, + "specificationSections": { + "clientExtensions": "8", + "componentDiscovery": "6", + "componentTypes": "7", + "manifest": "5", + "packageModel": "4", + "placeholderExpansion": "9", + "versioning": "10" } } diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index bdaefeea3..ed9984618 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -40,13 +40,35 @@ import { withInstallSurface } from '../install/surface.ts'; import { deepFreeze } from '../core/freeze.ts'; +/** Agent Plugins 1.0.0 §5.4 `author` object: optional `name`, `email`, and `url` strings. */ +export interface PortableAuthorConfig { + readonly email?: string; + readonly name?: string; + readonly url?: string; +} + +/** + * Portable-only manifest metadata layered onto the emitted root `plugin.json` + * (Agent Plugins 1.0.0 §5.4 metadata fields and §5.6/§8.1 `extensions`). + * Every field is optional; omitted fields are omitted from the manifest. + */ +export interface PortableManifestConfig { + readonly author?: PortableAuthorConfig; + /** Client extension namespaces (reverse-domain, §8) mapped to their opaque object payloads. */ + readonly extensions?: Readonly>>>; + readonly homepage?: string; + readonly keywords?: readonly string[]; + readonly license?: string; + readonly repository?: string; +} + export interface PortableConfigExtension { - portable?: AgentBundlePortableConfig; + portable?: AgentBundlePortableConfig & PortableManifestConfig; } declare module '../core/types.ts' { interface AgentBundleConfigExtensions { - portable?: AgentBundlePortableConfig; + portable?: AgentBundlePortableConfig & PortableManifestConfig; } } @@ -58,7 +80,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.5.0', + adapterRevision: '1.6.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); @@ -123,6 +145,228 @@ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(portableN const hasPortableTarget = (targets: readonly string[]): boolean => targets.includes(portableName); +const isPlainDataRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + [null, Object.prototype].includes(Object.getPrototypeOf(value)); + +const isNonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +const isAbsoluteHttpUrl = (value: unknown): value is string => { + if (!isNonemptyString(value)) return false; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +}; + +const isEmail = (value: unknown): value is string => + isNonemptyString(value) && /^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(value); + +/** §8: client extension namespaces are reverse-domain identifiers such as `com.example.client`. */ +const isExtensionNamespace = (value: string): boolean => + /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/u.test(value); + +const manifestMetadataFields = Object.freeze([ + 'author', + 'extensions', + 'homepage', + 'keywords', + 'license', + 'repository', +] as const); + +type ManifestMetadataField = (typeof manifestMetadataFields)[number]; + +interface PortableManifestMetadataPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document: Readonly>; + readonly sourceInputs: readonly string[]; +} + +const noManifestMetadataPlan: PortableManifestMetadataPlan = deepFreeze({ + diagnostics: [], + document: {}, + sourceInputs: [], +}); + +const manifestMetadataDiagnostic = ( + field: ManifestMetadataField | 'author.email' | 'author.name' | 'author.url', + message: string, + recovery: string, +): Diagnostic => ({ + ...errorDiagnostic(`portable.manifest.${field}.invalid`, message), + recovery, +}); + +const planAuthor = ( + author: unknown, + diagnostics: Diagnostic[], +): Readonly> | undefined => { + if (!isPlainDataRecord(author)) { + diagnostics.push(manifestMetadataDiagnostic( + 'author', + 'Portable author must be a plain object (Agent Plugins 1.0.0 §5.4).', + 'Set portable.author to an object with optional name, email, and url strings, or remove it.', + )); + return undefined; + } + const unknownFields = Object.keys(author).filter((field) => !['email', 'name', 'url'].includes(field)); + if (unknownFields.length > 0) { + diagnostics.push(manifestMetadataDiagnostic( + 'author', + `Portable author contains unsupported field${unknownFields.length === 1 ? '' : 's'} ` + + `${unknownFields.map((field) => JSON.stringify(field)).join(', ')}; Agent Plugins 1.0.0 §5.4 permits only name, email, and url.`, + 'Keep only portable.author.name, portable.author.email, and portable.author.url.', + )); + } + const { email, name, url } = author; + if (name !== undefined && !isNonemptyString(name)) { + diagnostics.push(manifestMetadataDiagnostic( + 'author.name', + 'Portable author.name must be a nonempty string after trimming whitespace.', + 'Set portable.author.name to the author or team name, or remove it.', + )); + } + if (email !== undefined && !isEmail(email)) { + diagnostics.push(manifestMetadataDiagnostic( + 'author.email', + 'Portable author.email must be a nonempty email address.', + 'Set portable.author.email to a contact email address, or remove it.', + )); + } + if (url !== undefined && !isAbsoluteHttpUrl(url)) { + diagnostics.push(manifestMetadataDiagnostic( + 'author.url', + 'Portable author.url must be an absolute HTTP or HTTPS URL.', + 'Set portable.author.url to the author or team homepage, or remove it.', + )); + } + if ( + unknownFields.length > 0 || + (name !== undefined && !isNonemptyString(name)) || + (email !== undefined && !isEmail(email)) || + (url !== undefined && !isAbsoluteHttpUrl(url)) + ) { + return undefined; + } + return Object.freeze({ + ...(email === undefined ? {} : { email }), + ...(name === undefined ? {} : { name }), + ...(url === undefined ? {} : { url }), + }); +}; + +const planExtensions = ( + extensions: unknown, + diagnostics: Diagnostic[], +): Readonly> | undefined => { + if (!isPlainDataRecord(extensions)) { + diagnostics.push(manifestMetadataDiagnostic( + 'extensions', + 'Portable extensions must be a plain object keyed by client extension namespace (Agent Plugins 1.0.0 §8.1).', + 'Set portable.extensions to { "": { ... } }, or remove it.', + )); + return undefined; + } + let valid = true; + const planned: Record = Object.create(null) as Record; + for (const [namespace, value] of Object.entries(extensions)) { + if (!isExtensionNamespace(namespace)) { + valid = false; + diagnostics.push(manifestMetadataDiagnostic( + 'extensions', + `Portable extension namespace ${JSON.stringify(namespace)} is not a reverse-domain identifier (Agent Plugins 1.0.0 §8).`, + 'Key portable.extensions by a reverse-domain namespace such as "com.example.client".', + )); + continue; + } + if (!isPlainDataRecord(value)) { + valid = false; + diagnostics.push(manifestMetadataDiagnostic( + 'extensions', + `Portable extension namespace ${JSON.stringify(namespace)} must map to a plain object (Agent Plugins 1.0.0 §8.1).`, + `Set portable.extensions[${JSON.stringify(namespace)}] to an object, or remove it.`, + )); + continue; + } + planned[namespace] = value; + } + return valid ? Object.freeze({ ...planned }) : undefined; +}; + +/** + * Agent Plugins 1.0.0 §5.4 metadata and §5.6 `extensions` authored under the + * `portable` config extension. Metadata beyond the JSON-type floor is checked + * (§5.4 recommends SPDX and URL forms; a client MUST NOT reject them, but this + * compiler refuses to ship values it knows to be malformed). + */ +const planPortableManifestMetadata = (model: NormalizedPlugin): PortableManifestMetadataPlan => { + const extension = model.extensions[portableName]; + if (extension === undefined || !isPlainDataRecord(extension.value)) return noManifestMetadataPlan; + const declared = extension.value; + if (manifestMetadataFields.every((field) => declared[field] === undefined)) return noManifestMetadataPlan; + + const diagnostics: Diagnostic[] = []; + const document: Record = {}; + const { author, extensions, homepage, keywords, license, repository } = declared; + if (author !== undefined) { + const planned = planAuthor(author, diagnostics); + if (planned !== undefined) document['author'] = planned; + } + for (const [field, value] of [['homepage', homepage], ['repository', repository]] as const) { + if (value === undefined) continue; + if (isAbsoluteHttpUrl(value)) { + document[field] = value; + continue; + } + diagnostics.push(manifestMetadataDiagnostic( + field, + `Portable ${field} must be an absolute HTTP or HTTPS URL.`, + `Set portable.${field} to an absolute URL, or remove it.`, + )); + } + if (license !== undefined) { + if (isNonemptyString(license)) document['license'] = license; + else { + diagnostics.push(manifestMetadataDiagnostic( + 'license', + 'Portable license must be a nonempty string (an SPDX identifier is recommended by Agent Plugins 1.0.0 §5.4).', + 'Set portable.license to a license identifier such as MIT or Apache-2.0, or remove it.', + )); + } + } + if (keywords !== undefined) { + const invalidIndex = Array.isArray(keywords) + ? keywords.findIndex((keyword) => !isNonemptyString(keyword)) + : undefined; + if (Array.isArray(keywords) && invalidIndex === -1) document['keywords'] = Object.freeze([...keywords]); + else { + diagnostics.push(manifestMetadataDiagnostic( + 'keywords', + invalidIndex === undefined + ? 'Portable keywords must be an array of nonempty strings.' + : `Portable keywords[${invalidIndex}] must be a nonempty string after trimming whitespace.`, + 'Set portable.keywords to discovery tags such as ["research", "crm"], or remove it.', + )); + } + } + if (extensions !== undefined) { + const planned = planExtensions(extensions, diagnostics); + if (planned !== undefined) document['extensions'] = planned; + } + + return Object.freeze({ + diagnostics: Object.freeze(diagnostics), + document: Object.freeze(document), + sourceInputs: Object.freeze([extension.provenance.sourcePath]), + }); +}; + const planMcpServer = ( server: NormalizedMcpServer, ): { readonly diagnostics: readonly Diagnostic[]; readonly value?: Record } => { @@ -226,6 +470,8 @@ const planMcpServer = ( const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const diagnostics: Diagnostic[] = []; + const manifestMetadata = planPortableManifestMetadata(model); + diagnostics.push(...manifestMetadata.diagnostics); const plugin = { $schema: portablePluginSchema, ...(model.metadata.description === undefined @@ -233,6 +479,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { : { description: model.metadata.description }), name: model.metadata.name, version: model.metadata.version, + ...manifestMetadata.document, }; const entries: TargetArtifactEntry[] = [ { @@ -242,6 +489,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { sourceInputs: sourceInputs( model.metadata.provenance.sourcePath, ...model.targets.filter((target) => target.name === portableName).map((target) => target.provenance.sourcePath), + ...manifestMetadata.sourceInputs, ), }, ]; @@ -333,14 +581,26 @@ export const portableAdapter: TargetAdapter = Object.freeze({ commands: unavailableCapability( 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', ), + extensionDirectories: unavailableCapability(capabilityTable.plugin.extensionDirectories.reason), hooks: unavailableCapability('Agent Plugins 1.0.0 does not define a hooks component.'), install: unavailableCapability(capabilityTable.install.reason), + manifestExtensions: capabilityStateFromSupport( + capabilityTable.plugin.extensions.state === 'supported', + evidence, + 'Agent Plugins 1.0.0 does not define a manifest extensions field.', + ), + manifestMetadata: capabilityStateFromSupport( + capabilityTable.plugin.manifestMetadata.state === 'supported', + evidence, + 'Agent Plugins 1.0.0 does not define manifest metadata fields.', + ), marketplace: unavailableCapability('Agent Plugins 1.0.0 does not define a marketplace document.'), mcp: capabilityStateFromSupport( capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp, evidence, 'Agent Plugins 1.0.0 does not support both required modern MCP transports.', ), + mcpLegacySse: unavailableCapability(capabilityTable.mcp.legacySse.reason), rules: unavailableCapability( 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', ), diff --git a/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json index 8898fc4cb..2a97cfea0 100644 --- a/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json @@ -1,6 +1,8 @@ { "normativeTextWinsOnConflict": true, "retrievedAt": "2026-09-01", + "reverifiedAt": "2026-09-02", + "reverification": "2026-09-02: live https://agent-plugins.org/schemas/1.0.0/{plugin,mcp}.schema.json bytes rehashed to the pinned sha256 values; specification repository HEAD unchanged at the pinned commit; a 1.1.0 working draft was started upstream on 2026-08-15 (commit a2afd7ec7edb916da638fc5c94640d4a7ba4480f) without published 1.1.0 schemas, so the 1.0.0 pin stands.", "schemas": { "mcp.schema.json": { "bytes": 3408, diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 335b273d8..5c1b3b9c3 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -109,6 +109,10 @@ import { validateCursorPlugin, type CursorPluginValidationReport, } from './host-contracts/cursor-plugin-validation.ts'; +import { + validatePortablePlugin, + type PortablePluginValidationReport, +} from './host-contracts/portable-plugin-validation.ts'; import type { EvalComparison } from './eval/compare.ts'; import { EvalRunStoreError } from './eval/errors.ts'; import { @@ -174,8 +178,13 @@ export type { NativeHost, RedactedEventEnvelope, } from './host-contracts/host-contract.ts'; -export { validateClaudePlugin, validateCodexPlugin, validateCursorPlugin }; -export type { ClaudePluginValidationReport, CodexPluginValidationReport, CursorPluginValidationReport }; +export { validateClaudePlugin, validateCodexPlugin, validateCursorPlugin, validatePortablePlugin }; +export type { + ClaudePluginValidationReport, + CodexPluginValidationReport, + CursorPluginValidationReport, + PortablePluginValidationReport, +}; export { HookService } from './services/hook-service.ts'; export type { HookListOptions, HookSimulationOptions } from './services/hook-service.ts'; @@ -257,6 +266,7 @@ export interface ValidateResult { | ClaudePluginValidationReport | CodexPluginValidationReport | CursorPluginValidationReport + | PortablePluginValidationReport )[]; readonly model?: NormalizedPlugin; } @@ -480,6 +490,40 @@ const temporaryArtifact = async ( } }; +type HostValidatedTarget = 'claude' | 'codex' | 'cursor' | 'plugin' | 'portable'; + +const hostValidatedTargets: ReadonlySet = new Set([ + 'claude', + 'codex', + 'cursor', + 'plugin', + 'portable', +]); + +const isHostValidatedTarget = (name: string): name is HostValidatedTarget => hostValidatedTargets.has(name); + +const hostValidationReport = ( + target: HostValidatedTarget, + pluginDirectory: string, + strict: boolean | undefined, +): Promise[number]> => { + switch (target) { + case 'codex': + return validateCodexPlugin({ pluginDirectory, strict, target }); + case 'cursor': + return validateCursorPlugin({ pluginDirectory, target }); + case 'portable': + return validatePortablePlugin({ pluginDirectory, target }); + case 'claude': + case 'plugin': + return validateClaudePlugin({ pluginDirectory, strict, target }); + default: { + const exhaustive: never = target; + throw new TypeError(`Unknown host-validated target ${String(exhaustive)}.`); + } + } +}; + export const validate = async (options: ValidateOptions): Promise => { if (options.artifact !== undefined) { const artifact = resolve(options.artifact); @@ -493,24 +537,9 @@ export const validate = async (options: ValidateOptions): Promise - target.name === 'claude' || target.name === 'codex' || target.name === 'cursor' || target.name === 'plugin') - .map((target) => target.name === 'codex' - ? validateCodexPlugin({ - pluginDirectory: join(artifact, target.name), - strict: options.strict, - target: target.name, - }) - : target.name === 'cursor' - ? validateCursorPlugin({ - pluginDirectory: join(artifact, target.name), - target: target.name, - }) - : validateClaudePlugin({ - pluginDirectory: join(artifact, target.name), - strict: options.strict, - target: target.name, - }))); + .map((target) => target.name) + .filter(isHostValidatedTarget) + .map((target) => hostValidationReport(target, join(artifact, target), options.strict))); return Object.freeze({ diagnostics: freezeDiagnostics([ ...validated.diagnostics, diff --git a/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts new file mode 100644 index 000000000..924a34d36 --- /dev/null +++ b/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts @@ -0,0 +1,592 @@ +import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises'; +import { isAbsolute, join, normalize, relative, resolve } from 'node:path'; + +import capabilityTable from '../adapters/capabilities/portable-1.0.0.json' with { type: 'json' }; +import schemaProvenance from '../adapters/schemas/portable/PROVENANCE.json' with { type: 'json' }; +import mcpSchema from '../adapters/schemas/portable/mcp.schema.json' with { type: 'json' }; +import pluginSchema from '../adapters/schemas/portable/plugin.schema.json' with { type: 'json' }; +import { + createAdapterValidator, + validateJsonSchemaDocument, + type TargetArtifactDocumentValidator, +} from '../adapters/types.ts'; +import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; +import { freezeDiagnostics } from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; +import { isInsideOrEqual } from '../core/paths.ts'; + +/** + * Agent Plugins 1.0.0 bytes-at-rest validation for the `portable` target. + * + * The standard publishes machine-readable schemas plus normative text that + * the schemas cannot express (§4.1 containment, §7.2.1 command and URL forms, + * §9.2 placeholder scope, §10.1 version agreement). The specification text is + * authoritative when the two conflict (`PROVENANCE.json` + * `normativeTextWinsOnConflict`), so this lane checks both. It reads bytes + * only: no client CLI is spawned and nothing is repaired. + */ + +type PortableDiagnosticCode = 'AB6035' | 'AB6036' | 'AB6037' | 'AB6038'; +type DocumentPath = 'mcp.json' | 'plugin.json'; + +export type PortablePluginValidationStatus = 'failed' | 'passed'; + +export interface PortablePluginValidationReport { + readonly diagnostics: readonly Diagnostic[]; + readonly host: 'portable'; + readonly specificationVersion: string; + readonly status: PortablePluginValidationStatus; + readonly target: string; +} + +export interface ValidatePortablePluginFilesOptions { + readonly pluginDirectory: string; + readonly target: string; +} + +export type ValidatePortablePluginOptions = ValidatePortablePluginFilesOptions; + +interface PinnedDocumentContract { + readonly path: DocumentPath; + readonly required: boolean; + readonly validate: TargetArtifactDocumentValidator; +} + +interface ParsedDocument { + readonly path: DocumentPath; + readonly value: unknown; +} + +const schemaValidator = createAdapterValidator(); +const pinnedDocumentContracts = Object.freeze([ + Object.freeze({ + path: 'plugin.json', + required: true, + validate: validateJsonSchemaDocument(schemaValidator.compile(pluginSchema)), + }), + Object.freeze({ + path: 'mcp.json', + required: false, + validate: validateJsonSchemaDocument(schemaValidator.compile(mcpSchema)), + }), +]); + +const placeholderPattern = /\$\{PLUGIN_(?:ROOT|DATA)\}/u; +const headerNamePattern = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/u; +const loopbackIpv4Pattern = /^127(?:\.\d{1,3}){3}$/u; +const schemaVersionPattern = /^https:\/\/agent-plugins\.org\/schemas\/([^/]+)\//u; + +const recoveryFor = (code: PortableDiagnosticCode): string => { + switch (code) { + case 'AB6035': + return 'Repair the generated Agent Plugins document so it satisfies the pinned 1.0.0 schema, then rebuild.'; + case 'AB6036': + return 'Repair the generated portable layout or MCP entry to satisfy the Agent Plugins 1.0.0 normative text, then rebuild.'; + case 'AB6037': + return 'Replace the escaping symlink with a file or a link that resolves inside the plugin root, then rebuild.'; + case 'AB6038': + return 'Review the pinned Agent Plugins provenance before changing the local validator contract.'; + default: { + const exhaustive: never = code; + throw new Error(`Unexpected portable validator diagnostic code: ${String(exhaustive)}`); + } + } +}; + +const diagnostic = ( + code: PortableDiagnosticCode, + message: string, + severity: DiagnosticSeverity, + target: string, +): Diagnostic => Object.freeze({ + code, + message, + recovery: recoveryFor(code), + severity, + target, +}); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const displayPath = (root: string, path: string): string => relative(root, path).replaceAll('\\', '/'); + +const schemaVersion = (identifier: unknown): string | undefined => + typeof identifier === 'string' ? schemaVersionPattern.exec(identifier)?.[1] : undefined; + +const fileKind = async (path: string): Promise<'directory' | 'file' | 'missing' | 'other'> => { + try { + const metadata = await stat(path); + if (metadata.isDirectory()) return 'directory'; + if (metadata.isFile()) return 'file'; + return 'other'; + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'ELOOP')) return 'missing'; + throw error; + } +}; + +/** + * §4.1 plugin-relative path: begins with `./`, resolves against the plugin + * root, and stays inside it after lexical normalization. Filesystem symlink + * containment is the separate §4.1 symlink lane. + */ +const pluginRelativeTarget = (pluginDirectory: string, value: string): string | undefined => { + if (!value.startsWith('./') || value.includes('\\') || value.includes('\0')) return undefined; + const candidate = resolve(pluginDirectory, normalize(value)); + return isInsideOrEqual(pluginDirectory, candidate) ? candidate : undefined; +}; + +const readDocuments = async ( + pluginDirectory: string, + target: string, +): Promise> => { + const diagnostics: Diagnostic[] = []; + const documents: ParsedDocument[] = []; + for (const contract of pinnedDocumentContracts) { + const file = join(pluginDirectory, contract.path); + const kind = await fileKind(file); + if (kind === 'missing') { + if (contract.required) { + diagnostics.push(diagnostic( + 'AB6035', + `${contract.path} is required at the plugin root (Agent Plugins 1.0.0 §4.1).`, + 'error', + target, + )); + } + continue; + } + if (kind !== 'file') { + diagnostics.push(diagnostic( + contract.required ? 'AB6035' : 'AB6036', + `${contract.path} is present but does not resolve to a regular file (Agent Plugins 1.0.0 §6.2).`, + 'error', + target, + )); + continue; + } + let source: string; + try { + source = await readFile(file, 'utf8'); + } catch { + diagnostics.push(diagnostic( + 'AB6035', + `${contract.path} could not be read for pinned-schema validation.`, + 'error', + target, + )); + continue; + } + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch { + diagnostics.push(diagnostic('AB6035', `${contract.path} is not valid JSON.`, 'error', target)); + continue; + } + documents.push(Object.freeze({ path: contract.path, value })); + for (const issue of contract.validate(value)) { + diagnostics.push(diagnostic( + 'AB6035', + `${contract.path}${issue.instancePath.length === 0 ? '/' : issue.instancePath}: ${issue.message}.`, + 'error', + target, + )); + } + } + return Object.freeze({ + diagnostics: freezeDiagnostics(diagnostics), + documents: Object.freeze(documents), + }); +}; + +const versionAgreementDiagnostics = ( + documents: readonly ParsedDocument[], + target: string, +): readonly Diagnostic[] => { + const plugin = documents.find((document) => document.path === 'plugin.json'); + const mcp = documents.find((document) => document.path === 'mcp.json'); + if (plugin === undefined || mcp === undefined || !isRecord(plugin.value) || !isRecord(mcp.value)) { + return Object.freeze([]); + } + const pluginVersion = schemaVersion(plugin.value['$schema']); + const mcpVersion = schemaVersion(mcp.value['$schema']); + if (pluginVersion === undefined || mcpVersion === undefined || pluginVersion === mcpVersion) { + return Object.freeze([]); + } + return freezeDiagnostics([diagnostic( + 'AB6036', + `mcp.json declares Agent Plugins ${mcpVersion} while plugin.json declares ${pluginVersion}; the versions must agree (Agent Plugins 1.0.0 §10.1).`, + 'error', + target, + )]); +}; + +const isLoopbackHost = (hostname: string): boolean => + hostname === 'localhost' || + hostname === '[::1]' || + hostname === '::1' || + loopbackIpv4Pattern.test(hostname); + +const remoteUrlDiagnostics = ( + serverName: string, + url: unknown, + target: string, +): readonly Diagnostic[] => { + if (typeof url !== 'string') return Object.freeze([]); + const location = `mcp.json/mcpServers/${serverName}/url`; + if (placeholderPattern.test(url)) { + return freezeDiagnostics([diagnostic( + 'AB6036', + `${location} contains an Agent Plugins placeholder, but clients never expand placeholders in url (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )]); + } + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return freezeDiagnostics([diagnostic( + 'AB6036', + `${location} must be an absolute HTTP or HTTPS URL (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )]); + } + const diagnostics: Diagnostic[] = []; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + diagnostics.push(diagnostic( + 'AB6036', + `${location} must use the http or https scheme (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + if (parsed.username.length > 0 || parsed.password.length > 0) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} must not contain user information (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + if (url.includes('#')) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} must not contain a fragment (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} uses plain HTTP against non-loopback host ${JSON.stringify(parsed.hostname)}; non-loopback endpoints must use HTTPS (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + return freezeDiagnostics(diagnostics); +}; + +const headerDiagnostics = ( + serverName: string, + headers: unknown, + target: string, +): readonly Diagnostic[] => { + if (!isRecord(headers)) return Object.freeze([]); + const diagnostics: Diagnostic[] = []; + const seen = new Map(); + for (const [name, value] of Object.entries(headers)) { + const location = `mcp.json/mcpServers/${serverName}/headers/${name}`; + if (!headerNamePattern.test(name)) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} is not a valid HTTP header field name (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + if (typeof value === 'string' && /[\r\n\0]/u.test(value)) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} is not a valid HTTP header field value (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + if (placeholderPattern.test(name) || (typeof value === 'string' && placeholderPattern.test(value))) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} contains an Agent Plugins placeholder, but clients never expand placeholders in headers (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + const folded = name.toLowerCase(); + const previous = seen.get(folded); + if (previous !== undefined) { + diagnostics.push(diagnostic( + 'AB6036', + `${location} repeats header ${JSON.stringify(previous)} under different casing; header names are case-insensitive (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } else { + seen.set(folded, name); + } + } + return freezeDiagnostics(diagnostics); +}; + +const stdioDiagnostics = async ( + pluginDirectory: string, + serverName: string, + server: Readonly>, + target: string, +): Promise => { + const diagnostics: Diagnostic[] = []; + const command = server['command']; + const location = `mcp.json/mcpServers/${serverName}`; + if (typeof command === 'string') { + if (placeholderPattern.test(command)) { + diagnostics.push(diagnostic( + 'AB6036', + `${location}/command contains an Agent Plugins placeholder, but clients never expand placeholders in command (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } else if (command.startsWith('./')) { + const resolved = pluginRelativeTarget(pluginDirectory, command); + if (resolved === undefined) { + diagnostics.push(diagnostic( + 'AB6036', + `${location}/command ${JSON.stringify(command)} escapes the plugin root (Agent Plugins 1.0.0 §4.1).`, + 'error', + target, + )); + } else if ((await fileKind(resolved)) !== 'file') { + diagnostics.push(diagnostic( + 'AB6036', + `${location}/command ${JSON.stringify(command)} does not resolve to a bundled regular file (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + } else if (/[\s/\\]/u.test(command) || isAbsolute(command) || command.startsWith('.')) { + diagnostics.push(diagnostic( + 'AB6036', + `${location}/command ${JSON.stringify(command)} is neither a bare executable name nor a plugin-relative ./ path (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + } + const cwd = server['cwd']; + if (typeof cwd === 'string') { + const relativePart = cwd.startsWith('./') + ? cwd + : cwd.startsWith('${PLUGIN_ROOT}') + ? `.${cwd.slice('${PLUGIN_ROOT}'.length)}` + : cwd.startsWith('${PLUGIN_DATA}') + ? `.${cwd.slice('${PLUGIN_DATA}'.length)}` + : undefined; + if (relativePart !== undefined) { + const anchor = join(pluginDirectory, 'anchor'); + const candidate = resolve(anchor, normalize(relativePart === '.' ? './' : relativePart)); + if (!isInsideOrEqual(anchor, candidate)) { + diagnostics.push(diagnostic( + 'AB6036', + `${location}/cwd ${JSON.stringify(cwd)} escapes its ${cwd.startsWith('${PLUGIN_DATA}') ? 'plugin data directory' : 'plugin root'} after resolution (Agent Plugins 1.0.0 §7.2.1).`, + 'error', + target, + )); + } + } + } + const env = server['env']; + if (isRecord(env)) { + for (const key of Object.keys(env)) { + if (!placeholderPattern.test(key)) continue; + diagnostics.push(diagnostic( + 'AB6036', + `${location}/env key ${JSON.stringify(key)} contains an Agent Plugins placeholder, but expansion never applies to env keys (Agent Plugins 1.0.0 §9.2).`, + 'error', + target, + )); + } + } + return freezeDiagnostics(diagnostics); +}; + +const serverDiagnostics = async ( + pluginDirectory: string, + documents: readonly ParsedDocument[], + target: string, +): Promise => { + const mcp = documents.find((document) => document.path === 'mcp.json'); + if (mcp === undefined || !isRecord(mcp.value) || !isRecord(mcp.value['mcpServers'])) return Object.freeze([]); + const diagnostics: Diagnostic[] = []; + for (const [serverName, server] of Object.entries(mcp.value['mcpServers'])) { + if (!isRecord(server)) continue; + switch (server['type']) { + case 'stdio': + diagnostics.push(...await stdioDiagnostics(pluginDirectory, serverName, server, target)); + break; + case 'sse': + case 'streamable-http': + diagnostics.push(...remoteUrlDiagnostics(serverName, server['url'], target)); + diagnostics.push(...headerDiagnostics(serverName, server['headers'], target)); + break; + default: + // The pinned schema already rejects unknown variants (AB6035). + break; + } + } + return freezeDiagnostics(diagnostics); +}; + +const skillDiagnostics = async ( + pluginDirectory: string, + target: string, +): Promise => { + const skillsRoot = join(pluginDirectory, 'skills'); + const kind = await fileKind(skillsRoot); + if (kind === 'missing') return Object.freeze([]); + if (kind !== 'directory') { + return freezeDiagnostics([diagnostic( + 'AB6036', + 'skills is present but does not resolve to a directory (Agent Plugins 1.0.0 §6.2).', + 'error', + target, + )]); + } + const diagnostics: Diagnostic[] = []; + const entries = (await readdir(skillsRoot, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const skillDirectory = join(skillsRoot, entry.name); + if ((await fileKind(skillDirectory)) !== 'directory') continue; + const skillFile = join(skillDirectory, 'SKILL.md'); + if ((await fileKind(skillFile)) === 'file') continue; + diagnostics.push(diagnostic( + 'AB6036', + `skills/${entry.name} has no regular SKILL.md file, so clients skip it (Agent Plugins 1.0.0 §7.1).`, + 'error', + target, + )); + } + return freezeDiagnostics(diagnostics); +}; + +const symlinkDiagnostics = async ( + pluginDirectory: string, + target: string, +): Promise => { + let rootRealPath: string; + try { + rootRealPath = await realpath(pluginDirectory); + } catch (error) { + if (isErrno(error, 'ENOENT')) return Object.freeze([]); + return freezeDiagnostics([diagnostic( + 'AB6037', + 'The portable plugin directory could not be resolved for symlink containment validation.', + 'error', + target, + )]); + } + const diagnostics: Diagnostic[] = []; + const visit = async (directory: string): Promise => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + diagnostics.push(diagnostic( + 'AB6037', + `${displayPath(pluginDirectory, directory)} could not be inspected for symlink containment.`, + 'error', + target, + )); + return; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const path = join(directory, entry.name); + if (entry.isSymbolicLink()) { + try { + const targetPath = await realpath(path); + if (!isInsideOrEqual(rootRealPath, targetPath)) { + diagnostics.push(diagnostic( + 'AB6037', + `${displayPath(pluginDirectory, path)} is a symlink whose real target escapes the plugin root (Agent Plugins 1.0.0 §4.1).`, + 'error', + target, + )); + } + } catch { + diagnostics.push(diagnostic( + 'AB6037', + `${displayPath(pluginDirectory, path)} is a symlink whose real target cannot be resolved inside the plugin root (Agent Plugins 1.0.0 §4.1).`, + 'error', + target, + )); + } + continue; + } + if (entry.isDirectory()) await visit(path); + } + }; + const rootMetadata = await lstat(pluginDirectory).catch(() => undefined); + if (rootMetadata?.isDirectory() === true) await visit(pluginDirectory); + return freezeDiagnostics(diagnostics); +}; + +/** Pure byte lane: pinned schemas, normative-text rules, and §4.1 symlink containment. */ +export const validatePortablePluginFiles = async ( + options: ValidatePortablePluginFilesOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + const [documents, skills, symlinks] = await Promise.all([ + readDocuments(pluginDirectory, options.target), + skillDiagnostics(pluginDirectory, options.target), + symlinkDiagnostics(pluginDirectory, options.target), + ]); + return freezeDiagnostics([ + ...documents.diagnostics, + ...versionAgreementDiagnostics(documents.documents, options.target), + ...await serverDiagnostics(pluginDirectory, documents.documents, options.target), + ...skills, + ...symlinks, + ]); +}; + +export const validatePortablePlugin = async ( + options: ValidatePortablePluginOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + const transparency = diagnostic( + 'AB6038', + `Agent Plugins publishes no reference validator; this report validates local bytes against the ` + + `${schemaProvenance.version} schemas pinned at ${schemaProvenance.specRepository.url}@` + + `${schemaProvenance.specRepository.commit.slice(0, 9)} (retrieved ${schemaProvenance.retrievedAt}, ` + + `re-verified ${schemaProvenance.reverifiedAt}) and the normative specification text.`, + 'info', + options.target, + ); + const diagnostics = freezeDiagnostics([ + transparency, + ...await validatePortablePluginFiles({ pluginDirectory, target: options.target }), + ]); + return Object.freeze({ + diagnostics, + host: 'portable', + specificationVersion: capabilityTable.observedSpecificationVersion, + status: diagnostics.some((entry) => entry.severity === 'error') ? 'failed' : 'passed', + target: options.target, + }); +}; \ No newline at end of file diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index 5d409e1e2..796e528bd 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -106,6 +106,8 @@ export type AgentBundleConfig = CoreAgentBundleConfig & CodexConfigExtension & PortableConfigExtension; +export type { PortableAuthorConfig, PortableManifestConfig } from './adapters/portable.ts'; + export type { AgentBundleConfigExtensions, AgentBundleDevConfig, diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 0d9bf623d..a12982136 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -16,6 +16,7 @@ import { validateCursorPluginFiles, validateCursorPluginSymlinks, } from '../host-contracts/cursor-plugin-validation.ts'; +import { validatePortablePluginFiles } from '../host-contracts/portable-plugin-validation.ts'; import type { BoundedChildProcessRequest, BoundedChildProcessResult, @@ -509,19 +510,64 @@ const cursorManifestCandidates = Object.freeze([ 'plugin.json', ]); +/** + * Static byte lane per pinned loader manifest flavor: the Cursor-native + * flavor gets Cursor's pinned document contract, a root `plugin.json` that + * declares an Agent Plugins `$schema` (Cursor loads that format natively, + * #306 dogfood) gets the pinned Agent Plugins 1.0.0 contract, and every + * flavor gets Cursor local-root symlink containment. + */ +const installedCursorStaticIssues = async ( + installed: InstalledCursorManifest, + path: string, + installRoot: string, +): Promise => { + if (installed.manifest === cursorManifestCandidates[0]) { + return validateCursorPluginFiles({ containmentRoot: installRoot, pluginDirectory: path, target: 'cursor' }); + } + const symlinks = validateCursorPluginSymlinks({ + containmentRoot: installRoot, + pluginDirectory: path, + target: 'cursor', + }); + if (!isAgentPluginsManifest(installed)) return symlinks; + const [portable, containment] = await Promise.all([ + validatePortablePluginFiles({ pluginDirectory: path, target: 'portable' }), + symlinks, + ]); + return Object.freeze([...portable, ...containment]); +}; + +interface InstalledCursorManifest { + readonly manifest: string; + readonly name: string; + /** Declared `$schema`, when the manifest carries one (Agent Plugins manifests always do). */ + readonly schema?: string; + readonly version?: string; +} + +const agentPluginsSchemaPrefix = 'https://agent-plugins.org/schemas/'; + +/** A root `plugin.json` that declares an Agent Plugins schema identifier is an Agent Plugins package. */ +const isAgentPluginsManifest = (installed: InstalledCursorManifest): boolean => + installed.manifest === cursorManifestCandidates[2] && + installed.schema !== undefined && + installed.schema.startsWith(agentPluginsSchemaPrefix); + const readInstalledManifest = async ( root: string, -): Promise<{ readonly manifest: string; readonly name: string; readonly version?: string } | undefined> => { +): Promise => { for (const manifest of cursorManifestCandidates) { try { const value = JSON.parse(await readFile(join(root, manifest), 'utf8')) as unknown; if (value === null || typeof value !== 'object' || Array.isArray(value)) continue; - const record = value as { readonly name?: unknown; readonly version?: unknown }; + const record = value as { readonly $schema?: unknown; readonly name?: unknown; readonly version?: unknown }; if (typeof record.name !== 'string') continue; if (record.version !== undefined && typeof record.version !== 'string') continue; return Object.freeze({ manifest, name: record.name, + ...(typeof record.$schema === 'string' ? { schema: record.$schema } : {}), ...(typeof record.version === 'string' ? { version: record.version } : {}), }); } catch (error) { @@ -624,24 +670,23 @@ const cursorInventory = async ( )); continue; } - const staticIssues = manifest.manifest === cursorManifestCandidates[0] - ? await validateCursorPluginFiles({ - containmentRoot: installRoot, - pluginDirectory: path, - target: 'cursor', - }) - : await validateCursorPluginSymlinks({ - containmentRoot: installRoot, - pluginDirectory: path, - target: 'cursor', - }); + const staticIssues = await installedCursorStaticIssues(manifest, path, installRoot); const staticDiagnostics = staticValidationDiagnostics( 'AB7320', 'cursor', path, staticIssues, ); - if (manifest.manifest !== cursorManifestCandidates[0]) { + if (isAgentPluginsManifest(manifest)) { + diagnostics.push(diagnostic( + 'AB7320', + `Cursor plugin entry ${JSON.stringify(path)} is a root plugin.json declaring ${JSON.stringify(manifest.schema)}, ` + + 'which Cursor loads as an Agent Plugins package; Doctor validated it against the pinned Agent Plugins 1.0.0 contract.', + 'Rebuild the portable bundle from valid source bytes if the Agent Plugins contract reports errors.', + 'info', + 'cursor', + )); + } else if (manifest.manifest !== cursorManifestCandidates[0]) { diagnostics.push(diagnostic( 'AB7320', `Cursor plugin entry ${JSON.stringify(path)} uses loader manifest flavor ` + diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 7dfc07421..dc725bb16 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -650,9 +650,19 @@ it.each([ reason: expect.stringContaining(reason), state: 'unavailable', }); - for (const target of ['cursor', 'portable'] as const) { - expect(registry.get(target).capabilities[capability]).toBeUndefined(); - expect(registry.supports(target, capability)).toBe(false); + expect(registry.get('cursor').capabilities[capability]).toBeUndefined(); + expect(registry.supports('cursor', capability)).toBe(false); + // Agent Plugins 1.0.0 §5.4 defines manifest metadata for the portable manifest (#307); it + // has no custom manifest path rules, so only that capability is declared there. + if (capability === 'manifestMetadata') { + expect(registry.get('portable').capabilities[capability]).toMatchObject({ + evidence: { observedVersion: '1.0.0', target: 'portable' }, + state: 'supported', + }); + expect(registry.supports('portable', capability)).toBe(true); + } else { + expect(registry.get('portable').capabilities[capability]).toBeUndefined(); + expect(registry.supports('portable', capability)).toBe(false); } expect(registry.supports('claude', capability)).toBe(true); expect(registry.supports('plugin', capability)).toBe(false); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index d205236af..a6ce4a647 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -52,7 +52,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.5.0', + adapterRevision: '1.6.0', observedVersion: '1.0.0', schemas: [ { @@ -237,6 +237,21 @@ it('records observed capability versions and rehashes schema snapshots against p committedAt: '2026-08-19T16:34:23Z', url: 'https://github.com/agentplugins/agent-plugins-spec', }); + expect(provenance.reverifiedAt).toBe('2026-09-02'); + expect(provenance.reverification).toEqual(expect.stringContaining('a2afd7ec7edb916da638fc5c94640d4a7ba4480f')); + // Every Agent Plugins 1.0.0 feature carries an honest, dated capability row. + const plugin = capabilityTable.plugin as Record; + const mcp = capabilityTable.mcp as Record; + expect(plugin.manifestMetadata).toMatchObject({ + fields: ['author', 'homepage', 'keywords', 'license', 'repository'], + state: 'supported', + }); + expect(plugin.extensions).toMatchObject({ configKey: 'portable.extensions', state: 'supported' }); + expect(plugin.extensionDirectories).toMatchObject({ + reason: expect.stringContaining('2026-09-02'), + state: 'unavailable', + }); + expect(mcp.legacySse).toMatchObject({ reason: expect.stringContaining('AB4339'), state: 'unavailable' }); } } }); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 13e353a94..c38beadc1 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -243,6 +243,64 @@ it('inventories all pinned Cursor manifest candidates in loader order', async () } }); +it('validates root plugin.json installs that declare an Agent Plugins schema against the pinned 1.0.0 contract', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + const pluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; + const mcpSchema = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; + try { + await writeJson(join(installRoot, 'conformant', 'plugin.json'), { + $schema: pluginSchema, + name: 'conformant', + version: '1.0.0', + }); + await writeJson(join(installRoot, 'conformant', 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { tool: { args: ['${PLUGIN_ROOT}/mcp/tool.mjs'], command: 'node', type: 'stdio' } }, + }); + await mkdir(join(installRoot, 'conformant', 'mcp'), { recursive: true }); + await writeFile(join(installRoot, 'conformant', 'mcp', 'tool.mjs'), 'export {};\n'); + await writeJson(join(installRoot, 'broken', 'plugin.json'), { + $schema: pluginSchema, + name: 'broken', + unknownField: true, + version: '1.0.0', + }); + await writeJson(join(installRoot, 'broken', 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { remote: { type: 'streamable-http', url: 'http://mcp.example.test/mcp' } }, + }); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').inventory).toMatchObject({ + findings: [ + { manifest: 'plugin.json', name: 'broken', state: 'corrupt' }, + { manifest: 'plugin.json', name: 'conformant', state: 'installed' }, + ], + status: 'known', + }); + const staticDiagnostics = report.diagnostics.filter((entry) => entry.code === 'AB7320'); + expect(staticDiagnostics.filter((entry) => entry.severity === 'info')).toEqual([ + expect.objectContaining({ message: expect.stringContaining('/broken" is a root plugin.json') }), + expect.objectContaining({ message: expect.stringContaining('/conformant" is a root plugin.json') }), + ]); + for (const info of staticDiagnostics.filter((entry) => entry.severity === 'info')) { + expect(info.message).toContain('Agent Plugins package'); + expect(info.message).toContain(pluginSchema); + } + expect(staticDiagnostics.filter((entry) => entry.severity === 'error').map((entry) => entry.message)).toEqual([ + expect.stringMatching(/reported AB6035: plugin\.json\/: must NOT have additional properties/u), + expect.stringMatching(/reported AB6036: mcp\.json\/mcpServers\/remote\/url uses plain HTTP against non-loopback host/u), + ]); + } finally { + await fixture.cleanup(); + } +}); + it('accepts a versionless Cursor inventory manifest as installed', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); diff --git a/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts index 534c4a8de..a692ab615 100644 --- a/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts @@ -12,6 +12,14 @@ export default { name: 'host-install-portable-proof', version: '1.0.0', }, + portable: { + author: { name: 'Agent Bundle proof harness', url: 'https://github.com/ScriptedAlchemy/agent-bundle' }, + extensions: { 'com.example.proof': { fixture: true } }, + homepage: 'https://github.com/ScriptedAlchemy/agent-bundle', + keywords: ['proof', 'agent-plugins'], + license: 'MIT', + repository: 'https://github.com/ScriptedAlchemy/agent-bundle', + }, skills: ['src/skills/probe'], targets: ['portable'], }; diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 26d197042..5c537e4a7 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -469,6 +469,7 @@ it( ); expect(report, proofLabel).toEqual({ + contract: 'agent-plugins-1.0.0 byte lane clean (AB6035–AB6037)', destination: '.cursor/plugins/local/host-install-portable-proof', documents: { mcp: 'schema-valid', @@ -477,6 +478,7 @@ it( hooks: 'not-emitted', host: 'cursor', install: { first: 'installed', second: 'already-installed', version: '1.0.0' }, + manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config', pluginVariables: { allowedLocations: 'args/env values/cwd only', locations: [ diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 314cfe615..4758aa2e1 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -116,6 +116,129 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () ]); }); +const portableExtension = (value: unknown): NormalizedPlugin['extensions'] => ({ + portable: { + id: 'extension:portable', + key: 'portable', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'portable', + value, + }, +}); + +it('emits every Agent Plugins 1.0.0 §5.4 metadata field and §5.6 extensions from the portable config extension', () => { + const plan = portableAdapter.plan({ + ...plugin(), + extensions: portableExtension({ + author: { email: 'team@example.com', name: 'Example Team', url: 'https://example.com/team' }, + extensions: { 'com.example.client': { setting: true }, 'org.example-tools.ide': {} }, + homepage: 'https://docs.example.com/plugin', + keywords: ['reports', 'summaries'], + license: 'MIT', + repository: 'https://github.com/example/plugin', + }), + }); + const manifest = plan.entries.find((entry) => entry.relativePath === 'plugin.json'); + + expect(plan.diagnostics).toEqual([]); + expect(manifest).toMatchObject({ kind: 'write', sourceInputs: ['/workspace/agent-bundle.config.ts'] }); + expect(JSON.parse((manifest as Extract).content)).toEqual({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + author: { email: 'team@example.com', name: 'Example Team', url: 'https://example.com/team' }, + description: 'A portable test plugin', + extensions: { 'com.example.client': { setting: true }, 'org.example-tools.ide': {} }, + homepage: 'https://docs.example.com/plugin', + keywords: ['reports', 'summaries'], + license: 'MIT', + name: 'portable-test', + repository: 'https://github.com/example/plugin', + version: '1.2.3', + }); +}); + +it('leaves plugin.json byte-identical to the pre-metadata contract when no portable metadata is declared', () => { + const bare = portableAdapter.plan(plugin()); + const emptyExtension = portableAdapter.plan({ ...plugin(), extensions: portableExtension({}) }); + const expected = + '{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","description":"A portable test plugin","name":"portable-test","version":"1.2.3"}\n'; + + for (const plan of [bare, emptyExtension]) { + expect(plan.diagnostics).toEqual([]); + expect(plan.entries.find((entry) => entry.relativePath === 'plugin.json')).toMatchObject({ content: expected }); + } +}); + +it('refuses malformed portable manifest metadata with one field-scoped diagnostic each', () => { + const plan = portableAdapter.plan({ + ...plugin(), + extensions: portableExtension({ + author: { email: 'not-an-email', name: ' ', role: 'maintainer', url: 'ftp://example.com' }, + extensions: { 'no-dot-namespace': {}, 'com.example.client': 'not an object' }, + homepage: 'docs.example.com', + keywords: ['reports', ''], + license: '', + repository: 'git@github.com:example/plugin.git', + }), + }); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'portable.manifest.author.invalid', + 'portable.manifest.author.name.invalid', + 'portable.manifest.author.email.invalid', + 'portable.manifest.author.url.invalid', + 'portable.manifest.homepage.invalid', + 'portable.manifest.repository.invalid', + 'portable.manifest.license.invalid', + 'portable.manifest.keywords.invalid', + 'portable.manifest.extensions.invalid', + 'portable.manifest.extensions.invalid', + ]); + expect(plan.diagnostics.every((diagnostic) => + diagnostic.severity === 'error' && diagnostic.target === 'portable' && typeof diagnostic.recovery === 'string')).toBe(true); + // Invalid fields never reach the manifest; the required identity still does. + const manifest = plan.entries.find((entry) => entry.relativePath === 'plugin.json'); + expect(JSON.parse((manifest as Extract).content)).toEqual({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + description: 'A portable test plugin', + name: 'portable-test', + version: '1.2.3', + }); +}); + +it('declares an honest capability row for every Agent Plugins 1.0.0 feature', () => { + const { capabilities } = createDefaultRegistry().get('portable'); + + expect(capabilities.skills).toMatchObject({ state: 'supported' }); + expect(capabilities.mcp).toMatchObject({ state: 'supported' }); + expect(capabilities.manifestMetadata).toMatchObject({ evidence: { target: 'portable' }, state: 'supported' }); + expect(capabilities.manifestExtensions).toMatchObject({ evidence: { target: 'portable' }, state: 'supported' }); + for (const [name, fragment] of [ + ['extensionDirectories', '§8.2'], + ['mcpLegacySse', '§7.2.1'], + ['hooks', 'hooks'], + ['commands', 'commands'], + ['rules', 'rules'], + ['marketplace', 'marketplace'], + ['install', 'profile'], + ] as const) { + expect(capabilities[name]).toMatchObject({ reason: expect.stringContaining(fragment), state: 'unavailable' }); + } + expect(capabilities.extensionDirectories).toMatchObject({ reason: expect.stringContaining('2026-09-02') }); + expect(capabilities.mcpLegacySse).toMatchObject({ reason: expect.stringContaining('2026-09-02') }); +}); + +it('rejects non-object portable author and extensions values', () => { + const plan = portableAdapter.plan({ + ...plugin(), + extensions: portableExtension({ author: 'Example Team', extensions: ['com.example.client'] }), + }); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'portable.manifest.author.invalid', + 'portable.manifest.extensions.invalid', + ]); +}); + it('copies project assets selected for portable and skips assets scoped to other targets', () => { const assetProvenance = { kind: 'conventional' as const, sourcePath: '/workspace/agent-bundle.config.ts' }; const plan = portableAdapter.plan({ diff --git a/packages/agent-bundle/tests/portable-plugin-validation.test.ts b/packages/agent-bundle/tests/portable-plugin-validation.test.ts new file mode 100644 index 000000000..dddc51c53 --- /dev/null +++ b/packages/agent-bundle/tests/portable-plugin-validation.test.ts @@ -0,0 +1,247 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, expect, it } from '@rstest/core'; + +import { + validatePortablePlugin, + validatePortablePluginFiles, +} from '../src/host-contracts/portable-plugin-validation.ts'; + +const pluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; +const mcpSchema = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +const writeText = async (path: string, value: string): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, value); +}; + +const conformantBundle = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-portable-validation-')); + roots.push(root); + await writeJson(join(root, 'plugin.json'), { + $schema: pluginSchema, + author: { name: 'Example Team' }, + description: 'Conformant fixture', + extensions: { 'com.example.client': { setting: true } }, + keywords: ['fixture'], + license: 'MIT', + name: 'conformant-fixture', + version: '1.0.0', + }); + await writeJson(join(root, 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { + bundled: { + args: ['${PLUGIN_ROOT}/mcp/server.mjs'], + command: './bin/launch', + cwd: '${PLUGIN_ROOT}', + env: { CACHE: '${PLUGIN_DATA}/cache' }, + type: 'stdio', + }, + loopback: { type: 'streamable-http', url: 'http://localhost:8787/mcp' }, + remote: { + headers: { 'X-Tenant': 'public' }, + type: 'streamable-http', + url: 'https://mcp.example.test/mcp', + }, + tool: { command: 'node', type: 'stdio' }, + }, + }); + await writeText(join(root, 'bin', 'launch'), '#!/bin/sh\nexit 0\n'); + await writeText(join(root, 'mcp', 'server.mjs'), 'export {};\n'); + await writeText(join(root, 'skills', 'summarize', 'SKILL.md'), '---\nname: summarize\ndescription: Summarize.\n---\nSummarize.\n'); + return root; +}; + +const codes = (diagnostics: readonly { readonly code: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.code); + +const messages = (diagnostics: readonly { readonly message: string }[]): string[] => + diagnostics.map((diagnostic) => diagnostic.message); + +it('passes a conformant Agent Plugins 1.0.0 bundle and reports the pinned provenance transparently', async () => { + const root = await conformantBundle(); + const report = await validatePortablePlugin({ pluginDirectory: root, target: 'portable' }); + + expect(await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' })).toEqual([]); + expect(report).toMatchObject({ + host: 'portable', + specificationVersion: '1.0.0', + status: 'passed', + target: 'portable', + }); + expect(report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB6038', + message: expect.stringContaining('agentplugins/agent-plugins-spec@ff8ab5e39'), + severity: 'info', + target: 'portable', + })]); + expect(report.diagnostics[0]?.message).toContain('re-verified 2026-09-02'); +}); + +it('requires the root plugin.json and rejects documents the pinned schemas refuse', async () => { + const root = await conformantBundle(); + await rm(join(root, 'plugin.json')); + const missing = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + expect(missing).toEqual([expect.objectContaining({ + code: 'AB6035', + message: 'plugin.json is required at the plugin root (Agent Plugins 1.0.0 §4.1).', + severity: 'error', + })]); + + await writeJson(join(root, 'plugin.json'), { + $schema: pluginSchema, + author: { name: 'Example', twitter: '@example' }, + name: 'Not-Lowercase', + unknownTopLevel: true, + }); + await writeJson(join(root, 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { reserved: { command: 'node', env: { PLUGIN_ROOT: '/tmp' }, type: 'stdio' } }, + }); + const rejected = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + expect(new Set(codes(rejected))).toEqual(new Set(['AB6035'])); + expect(messages(rejected)).toEqual(expect.arrayContaining([ + expect.stringMatching(/^plugin\.json\/name: must match pattern/u), + expect.stringMatching(/^plugin\.json\/author: must NOT have additional properties/u), + expect.stringMatching(/^plugin\.json\/: must NOT have additional properties/u), + expect.stringMatching(/^mcp\.json\/mcpServers\/reserved\/env/u), + ])); + + await writeText(join(root, 'plugin.json'), '{ not json'); + expect(messages(await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }))) + .toEqual(expect.arrayContaining(['plugin.json is not valid JSON.'])); +}); + +it('reports an Agent Plugins version disagreement between plugin.json and mcp.json (§10.1)', async () => { + const root = await conformantBundle(); + await writeJson(join(root, 'mcp.json'), { + $schema: 'https://agent-plugins.org/schemas/1.1.0/mcp.schema.json', + mcpServers: {}, + }); + const diagnostics = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + + // The pinned 1.0.0 schema also rejects the foreign identifier (AB6035); the + // normative disagreement is still named so the repair is unambiguous. + expect(codes(diagnostics)).toEqual(['AB6035', 'AB6036']); + expect(messages(diagnostics)[1]).toBe( + 'mcp.json declares Agent Plugins 1.1.0 while plugin.json declares 1.0.0; the versions must agree (Agent Plugins 1.0.0 §10.1).', + ); +}); + +it('applies the normative text where the schemas are silent: commands, cwd, URLs, headers, env keys', async () => { + const root = await conformantBundle(); + await writeJson(join(root, 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { + escapingCommand: { command: './../outside', type: 'stdio' }, + escapingCwd: { command: 'node', cwd: '${PLUGIN_ROOT}/../elsewhere', type: 'stdio' }, + escapingData: { command: 'node', cwd: '${PLUGIN_DATA}/../elsewhere', type: 'stdio' }, + fragment: { type: 'streamable-http', url: 'https://mcp.example.test/mcp#section' }, + headers: { + headers: { 'Bad Header': 'x', 'X-Tenant': 'a', 'x-tenant': 'b', 'X-Token': '${PLUGIN_ROOT}' }, + type: 'streamable-http', + url: 'https://mcp.example.test/mcp', + }, + missingBundled: { command: './bin/absent', type: 'stdio' }, + pathCommand: { command: 'bin/server', type: 'stdio' }, + placeholderCommand: { command: '${PLUGIN_ROOT}/bin/launch', type: 'stdio' }, + placeholderEnvKey: { command: 'node', env: { '${PLUGIN_ROOT}': 'x' }, type: 'stdio' }, + placeholderUrl: { type: 'sse', url: 'https://${PLUGIN_ROOT}/mcp' }, + plainHttp: { type: 'streamable-http', url: 'http://mcp.example.test/mcp' }, + relativeUrl: { type: 'streamable-http', url: '/mcp' }, + userInfo: { type: 'streamable-http', url: 'https://user:secret@mcp.example.test/mcp' }, + }, + }); + const diagnostics = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + + expect(new Set(codes(diagnostics))).toEqual(new Set(['AB6036'])); + expect(messages(diagnostics)).toEqual([ + 'mcp.json/mcpServers/escapingCommand/command "./../outside" escapes the plugin root (Agent Plugins 1.0.0 §4.1).', + 'mcp.json/mcpServers/escapingCwd/cwd "${PLUGIN_ROOT}/../elsewhere" escapes its plugin root after resolution (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/escapingData/cwd "${PLUGIN_DATA}/../elsewhere" escapes its plugin data directory after resolution (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/fragment/url must not contain a fragment (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/headers/headers/Bad Header is not a valid HTTP header field name (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/headers/headers/x-tenant repeats header "X-Tenant" under different casing; header names are case-insensitive (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/headers/headers/X-Token contains an Agent Plugins placeholder, but clients never expand placeholders in headers (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/missingBundled/command "./bin/absent" does not resolve to a bundled regular file (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/pathCommand/command "bin/server" is neither a bare executable name nor a plugin-relative ./ path (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/placeholderCommand/command contains an Agent Plugins placeholder, but clients never expand placeholders in command (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/placeholderEnvKey/env key "${PLUGIN_ROOT}" contains an Agent Plugins placeholder, but expansion never applies to env keys (Agent Plugins 1.0.0 §9.2).', + 'mcp.json/mcpServers/placeholderUrl/url contains an Agent Plugins placeholder, but clients never expand placeholders in url (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/plainHttp/url uses plain HTTP against non-loopback host "mcp.example.test"; non-loopback endpoints must use HTTPS (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/relativeUrl/url must be an absolute HTTP or HTTPS URL (Agent Plugins 1.0.0 §7.2.1).', + 'mcp.json/mcpServers/userInfo/url must not contain user information (Agent Plugins 1.0.0 §7.2.1).', + ]); +}); + +it('reports fixed component locations of the wrong filesystem kind and skill directories without SKILL.md', async () => { + const root = await conformantBundle(); + await rm(join(root, 'skills'), { recursive: true }); + await writeText(join(root, 'skills'), 'not a directory'); + await rm(join(root, 'mcp.json')); + await mkdir(join(root, 'mcp.json')); + const wrongKinds = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + expect(messages(wrongKinds)).toEqual([ + 'mcp.json is present but does not resolve to a regular file (Agent Plugins 1.0.0 §6.2).', + 'skills is present but does not resolve to a directory (Agent Plugins 1.0.0 §6.2).', + ]); + expect(codes(wrongKinds)).toEqual(['AB6036', 'AB6036']); + + await rm(join(root, 'skills')); + await rm(join(root, 'mcp.json'), { recursive: true }); + await mkdir(join(root, 'skills', 'empty'), { recursive: true }); + await mkdir(join(root, 'skills', 'nested', 'SKILL.md'), { recursive: true }); + await writeText(join(root, 'skills', 'README.md'), 'stray file, ignored by clients\n'); + await writeText(join(root, 'skills', 'summarize', 'SKILL.md'), '---\nname: summarize\ndescription: d\n---\nBody.\n'); + const skills = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + expect(skills).toEqual([ + expect.objectContaining({ + code: 'AB6036', + message: 'skills/empty has no regular SKILL.md file, so clients skip it (Agent Plugins 1.0.0 §7.1).', + }), + expect.objectContaining({ + code: 'AB6036', + message: 'skills/nested has no regular SKILL.md file, so clients skip it (Agent Plugins 1.0.0 §7.1).', + }), + ]); +}); + +it('rejects symlinks whose real target escapes the plugin root while accepting contained links', async () => { + const root = await conformantBundle(); + const outside = await mkdtemp(join(tmpdir(), 'agent-bundle-portable-outside-')); + roots.push(outside); + await writeText(join(outside, 'secret.md'), 'outside\n'); + await symlink(join(root, 'skills', 'summarize', 'SKILL.md'), join(root, 'skills', 'summarize', 'ALIAS.md')); + await symlink(join(outside, 'secret.md'), join(root, 'skills', 'summarize', 'references.md')); + await symlink(join(outside, 'missing.md'), join(root, 'dangling.md')); + + const diagnostics = await validatePortablePluginFiles({ pluginDirectory: root, target: 'portable' }); + expect(diagnostics).toEqual([ + expect.objectContaining({ + code: 'AB6037', + message: 'dangling.md is a symlink whose real target cannot be resolved inside the plugin root (Agent Plugins 1.0.0 §4.1).', + severity: 'error', + }), + expect.objectContaining({ + code: 'AB6037', + message: 'skills/summarize/references.md is a symlink whose real target escapes the plugin root (Agent Plugins 1.0.0 §4.1).', + severity: 'error', + }), + ]); + const report = await validatePortablePlugin({ pluginDirectory: root, target: 'portable' }); + expect(report.status).toBe('failed'); + expect(report.diagnostics.every((diagnostic) => typeof diagnostic.recovery === 'string')).toBe(true); +}); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index d8aa12a09..56e361661 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -18,6 +18,7 @@ import { } from '../../src/adapters/cursor.ts'; import { createAdapterValidator } from '../../src/adapters/types.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'; import { compileTestManifest, @@ -235,6 +236,8 @@ export interface CursorHostInstallReport { } export interface PortableHostInstallReport { + /** The installed bytes pass the same pinned byte lane `validate --host-validation` and Doctor run. */ + readonly contract: 'agent-plugins-1.0.0 byte lane clean (AB6035–AB6037)'; readonly destination: string; readonly documents: { readonly mcp: 'schema-valid'; @@ -247,6 +250,7 @@ export interface PortableHostInstallReport { readonly second: 'already-installed'; readonly version: '1.0.0'; }; + readonly manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config'; readonly pluginVariables: { readonly allowedLocations: 'args/env values/cwd only'; readonly locations: readonly string[]; @@ -1154,6 +1158,15 @@ export const runPortableHostInstallProof = async ( pluginManifest.name === portablePlugin && pluginManifest.version === version, 'Portable plugin manifest did not carry the fixture identity.', ); + assertProof( + record(pluginManifest.author)?.name === 'Agent Bundle proof harness' && + pluginManifest.license === 'MIT' && + pluginManifest.homepage === 'https://github.com/ScriptedAlchemy/agent-bundle' && + pluginManifest.repository === 'https://github.com/ScriptedAlchemy/agent-bundle' && + JSON.stringify(pluginManifest.keywords) === JSON.stringify(['proof', 'agent-plugins']) && + record(record(pluginManifest.extensions)?.['com.example.proof'])?.fixture === true, + 'Portable plugin manifest did not carry the authored Agent Plugins §5.4 metadata and §5.6 extensions.', + ); const mcpServers = record(mcpManifest.mcpServers); assertProof(mcpServers !== undefined, 'Portable MCP document had no server map.'); @@ -1236,9 +1249,16 @@ export const runPortableHostInstallProof = async ( ); } + const contractDiagnostics = await validatePortablePluginFiles({ pluginDirectory: destination, target: 'portable' }); + assertProof( + contractDiagnostics.length === 0, + `Portable installed bytes failed the pinned Agent Plugins byte lane: ${JSON.stringify(contractDiagnostics)}`, + ); + await install('Already installed'); return Object.freeze({ + contract: 'agent-plugins-1.0.0 byte lane clean (AB6035–AB6037)', destination: normalizedRelative(home, destination), documents: Object.freeze({ mcp: 'schema-valid', @@ -1251,6 +1271,7 @@ export const runPortableHostInstallProof = async ( second: 'already-installed', version, }), + manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config', pluginVariables: Object.freeze({ allowedLocations: 'args/env values/cwd only', locations: Object.freeze(placeholderLocations), From 066ed4c737a1da75ecb63bab7721f30b58d8b5a1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 05:40:47 +0000 Subject: [PATCH 2/2] test(packed): expect the portable Agent Plugins host-validation report from an installed tarball --- packages/agent-bundle/tests/packed-consumer.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/tests/packed-consumer.test.ts b/packages/agent-bundle/tests/packed-consumer.test.ts index 78d139791..9abf6639a 100644 --- a/packages/agent-bundle/tests/packed-consumer.test.ts +++ b/packages/agent-bundle/tests/packed-consumer.test.ts @@ -234,14 +234,19 @@ it('uses only an installed tarball after source deletion', async () => { readonly target: string; }[]; }; - expect(validationDocument.hostValidation.map((report) => report.host).sort()).toEqual(['claude', 'codex']); - for (const host of ['claude', 'codex'] as const) { + expect(validationDocument.hostValidation.map((report) => report.host).sort()).toEqual(['claude', 'codex', 'portable']); + for (const host of ['claude', 'codex', 'portable'] as const) { const report = validationDocument.hostValidation.find((candidate) => candidate.host === host)!; expect(report.target).toBe(host); expect(report.diagnostics.every((diagnostic) => diagnostic.severity === 'info')).toBe(true); if (host === 'claude' && report.status === 'passed') { expect(report.diagnostics).toEqual([]); } + if (host === 'portable') { + // The pinned Agent Plugins byte lane spawns no client; it passes from the installed tarball alone. + expect(report.status).toBe('passed'); + expect(report.diagnostics.map((diagnostic) => diagnostic.code)).toEqual(['AB6038']); + } if (report.status === 'unavailable') expect(report.diagnostics.length).toBeGreaterThan(0); } const hostDiagnosticCodes = validationDocument.hostValidation