From 57405f93072c49b0ef9a8b07d0d2f24169db3582 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 03:54:37 +0000 Subject: [PATCH 1/4] fix(portable): correct the Copilot, CodeWhale, VS Code, and Hermes client records Copilot CLI records .plugin/plugin.json as the one manifest location its published order puts ahead of the emitted root, and narrows its mcp row to the listed stdio server the probe proved. CodeWhale's mcp row names the remote endpoint, header, secret-URL, redirect, and capabilities.network_hosts restrictions its plugin boundary imposes, and where that declaration has to ride. VS Code's chat.pluginLocations action takes the register role, since it loads the directory in place; Cline keeps install, because its action copies the tree. Hermes records the new repository install source for a Git owner/repository install that leaves the plugin disabled. --- .../portable-client-record-corrections.md | 20 ++++ .../adapters/capabilities/portable-1.0.0.json | 31 +++-- .../src/adapters/capability-state.ts | 9 +- packages/agent-bundle/src/install/surface.ts | 20 +++- .../tests/install-surface.test.ts | 108 +++++++++++++++++- .../tests/portable-adapter.test.ts | 54 +++++++++ website/plugins/generated-reference.ts | 11 +- 7 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 .changeset/portable-client-record-corrections.md diff --git a/.changeset/portable-client-record-corrections.md b/.changeset/portable-client-record-corrections.md new file mode 100644 index 000000000..3bd2e29b9 --- /dev/null +++ b/.changeset/portable-client-record-corrections.md @@ -0,0 +1,20 @@ +--- +'agent-bundle': patch +--- + +Correct four portable client records in the pinned capability table and the +`INSTALL.md` and host-reference text rendered from them: GitHub Copilot CLI +records `.plugin/plugin.json` as the one manifest location its published order +puts ahead of the emitted root (the emitted root still wins over +`.claude-plugin/plugin.json`) and narrows its `mcp` row to the listed stdio +server it was proven on; CodeWhale's `mcp` row names the remote endpoint, +header, secret-URL, redirect and `capabilities.network_hosts` restrictions its +plugin boundary imposes, and that the host declaration rides in +`extensions["net.codewhale"]`, which only an authored `portable.extensions` +emits; VS Code's `chat.pluginLocations` action carries the `register` role, +since it loads the directory in place (Cline keeps `install`, because its +action copies the tree); and Hermes records the new `repository` install source +for `hermes plugins install / --no-enable`, a Git repository +rather than an indexed marketplace name. A non-local install now names its +source in the generated instructions, and the generated host reference gains an +install-source column in both locales. (#728) 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 0ee7c1533..86579b6ac 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 @@ -697,9 +697,10 @@ }, "mcp": { "evidence": [ - "2026-09-06: docs/MCP.md (commit 806d93e826ee615b702629775f0ec3dd19c97854, 2026-08-25) supports all three transports the 1.0.0 MCP schema defines, including the stdio and streamable-http forms this projection emits." + "2026-09-06: docs/MCP.md (commit 806d93e826ee615b702629775f0ec3dd19c97854, 2026-08-25) supports all three transports the 1.0.0 MCP schema defines, including the stdio and streamable-http forms this projection emits.", + "2026-09-06: docs/MCP.md#plugin-contributed (commit 19d34a5fb6c07b34e0b7234beb74a1cf1969efb4) states the bundle boundary verbatim — \"unknown fields and ambiguous transports fail closed; stdio environment values must be exact environment-source references; remote literal headers and secret-bearing URLs are rejected; declared network hosts must exactly match the normalized endpoint host set; and redirects remain on the reviewed origin\" — and docs/PLUGIN_BUNDLES.md states that \"`capabilities`, `when`, and `display_name` ride in `extensions[\"net.codewhale\"]`\" and that \"`capabilities.network_hosts` is not a future surface: it is enforced today, and must exactly match the normalized host set of the bundle's remote MCP endpoints\". Its validation section states the remote form verbatim: \"Remote MCP URLs must use HTTPS, except for explicit loopback HTTP endpoints. They cannot contain user information, a query, or a fragment. Literal headers are rejected: authentication must name a source environment variable through `env_headers` or `bearer_token_env_var`.\"" ], - "reason": "2026-09-06: CodeWhale narrows the standard's env rule — \"Local stdio environment entries must use exact ${SOURCE_ENV} references\" — so an emitted env value that is anything other than one whole variable reference is not accepted there.", + "reason": "2026-09-06: CodeWhale narrows the standard at the plugin boundary. Its env rule — \"Local stdio environment entries must use exact ${SOURCE_ENV} references\" — rejects an emitted env value that is anything other than one whole variable reference. A remote server emitted into mcp.json is narrower still: the URL must be HTTPS (or explicit loopback HTTP) with no user information, query, or fragment, a literal header is rejected in favor of CodeWhale's own env_headers or bearer_token_env_var keys, redirects must stay on the reviewed origin, and the bundle must declare exactly the normalized endpoint host set in capabilities.network_hosts. That declaration rides in extensions[\"net.codewhale\"], which this projection writes only when the author authors portable.extensions, so a remote server emitted without it has no accepted host declaration there.", "state": "degraded" }, "placeholders": { @@ -741,11 +742,19 @@ "skills", "mcp.json" ], - "shadowedBy": [], + "shadowedBy": [ + { + "path": ".plugin/plugin.json", + "surfaces": [ + "manifest" + ] + } + ], "evidence": [ "2026-09-06: `copilot plugin install /tmp/hostcli/fixture` on 1.0.83 answered \"Plugin \\\"probe-plugin\\\" installed successfully. Installed 1 skill.\" for a hand-built Agent Plugins package (root plugin.json with the 1.0.0 $schema, skills/review/SKILL.md, mcp.json), and `copilot plugins list` then reported the plugin, the plugin-contributed MCP server, and the plugin-contributed skill from that one manifest.", "2026-09-06: the same install printed \"Warning: Direct plugin installs (repos, URLs, local paths) are deprecated. Only plugin@marketplace installs will be supported in a future release\", so the local-path command above is a deprecated path on 1.0.83.", - "2026-09-06: the installed copy is a copy, not a link: ~/.copilot/config.json recorded cache_path ~/.copilot/installed-plugins/_direct/fixture with source {\"source\":\"local\",\"path\":\"...\"}." + "2026-09-06: the installed copy is a copy, not a link: ~/.copilot/config.json recorded cache_path ~/.copilot/installed-plugins/_direct/fixture with source {\"source\":\"local\",\"path\":\"...\"}.", + "2026-09-06: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference#file-locations orders the manifest locations \".plugin/plugin.json, plugin.json, .github/plugin/plugin.json, or .claude-plugin/plugin.json (checked in this order)\", so only .plugin/plugin.json wins over the emitted root manifest; the emitted root plugin.json is checked before .claude-plugin/plugin.json, so a composite root that also carries the Claude manifest is still read from the root. The order is published for the manifest file alone: the skill tree and mcp.json stay at their fixed plugin-root locations." ] }, "surfaces": { @@ -765,7 +774,8 @@ "evidence": [ "2026-09-06: `copilot plugins list` reported \"MCP servers: Plugin-contributed: probe -- stdio transport\" and `copilot mcp get probe` reported Source: Plugin, Source plugin: probe-plugin (1.0.0) — the emitted mcp.json, read without a .mcp.json present." ], - "state": "supported" + "reason": "2026-09-06: the probe listed one stdio server: no streamable-http server from an emitted mcp.json was registered, launched, or authenticated on 1.0.83, so the evidence covers the stdio subset of the document rather than every transport the projection can emit.", + "state": "degraded" }, "placeholders": { "evidence": [ @@ -892,14 +902,14 @@ "observed": "hermes-agent.nousresearch.com developer guide retrieved 2026-09-06; no version is printed on the page", "tier": "agent-plugins", "install": { - "source": "marketplace", + "source": "repository", "actions": [ { "role": "install", "command": "hermes plugins install / --no-enable" } ], - "location": "~/.hermes/plugins// with the root plugin.json at that level; \"Anything deeper is ignored\". No local-directory install form is published for a portable package." + "location": "~/.hermes/plugins// with the root plugin.json at that level; \"Anything deeper is ignored\". The install takes a Git repository, not an indexed marketplace name and not a local directory: no local-directory install form is published for a portable package, and the install leaves the plugin disabled." }, "discovery": { "required": [ @@ -911,6 +921,7 @@ "evidence": [ "2026-09-06: https://hermes-agent.nousresearch.com/docs/developer-guide/plugins/ states \"Portable packages use root plugin.json in the same locations\" as native packages (~/.hermes/plugins//) and \"Anything deeper is ignored\".", "2026-09-06: the only documented install spec for a portable package is `hermes plugins install owner/repository`; no local-directory install form is published, so installing the emitted directory in place is unproven.", + "2026-09-06: https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins/ separates the two install inputs — \"hermes plugins install # install by index name (resolved to repo @ pinned ref)\" against \"hermes plugins install user/repo # install from Git, then prompt Enable? [y/N]\" — and states that \"Explicit `owner/repo` or Git-URL identifiers never touch the index\", so the recorded command names a Git repository rather than an indexed marketplace entry. The same page states the prompt \"defaults to no\" and that `--no-enable` skips it, so the recorded install leaves the plugin installed and disabled.", "2026-09-06: the same page scopes the whole tier — \"This is a compatibility adapter for the portable components Hermes already owns… an explicit supported subset, not a claim of full Agent Plugins conformance.\"" ] }, @@ -1123,11 +1134,11 @@ "source": "local-directory", "actions": [ { - "role": "install", + "role": "register", "command": "\"chat.pluginLocations\": { \"\": true }" } ], - "location": "the plugin directory itself; a marketplace plugin instead installs through the Extensions view filter @agentPlugins" + "location": "the plugin directory itself — the setting registers the emitted tree where it lies and nothing is copied; a marketplace plugin instead installs through the Extensions view filter @agentPlugins" }, "discovery": { "required": [ @@ -1137,7 +1148,7 @@ ], "shadowedBy": [], "evidence": [ - "2026-09-06: https://code.visualstudio.com/docs/agent-customization/agent-plugins states \"If you manually clone or download a plugin, you can register it with the chat.pluginLocations setting. This setting maps local plugin directory paths to an enabled or disabled state\", so the emitted directory is loaded in place with no copy step.", + "2026-09-06: https://code.visualstudio.com/docs/agent-customization/agent-plugins#use-local-plugins states \"If you manually clone or download a plugin, you can register it with the chat.pluginLocations setting. This setting maps local plugin directory paths to an enabled or disabled state. Set the value to `true` to enable the plugin, or `false` to keep it registered but disabled\", so the emitted directory is registered in place with no copy step — the recorded action is a registration, not an install-managed copy.", "2026-09-06: the same page publishes no manifest-precedence order and calls .plugin/plugin.json \"Legacy OpenPlugin\", so the behavior of a root that carries both a canonical plugin.json and another client's manifest is unstated — unlike GitHub Copilot CLI, which orders .plugin/plugin.json ahead of the root manifest.", "2026-09-06: no `code --install-plugin`-style CLI verb for agent plugins is documented; installation is the setting above or the Extensions view." ] diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index 5ba2cfd35..bf8bcd848 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -348,10 +348,13 @@ const CLIENT_INSTALL_ANCHORS: readonly string[] = Object.freeze(['install', 'reg /** * Where the recorded install command takes the artifact from. `local-directory` * is only for a client whose own documentation installs a directory path; - * `marketplace` records a client that publishes no verified local form, so the - * install surface never prints an unproven recipe against the emitted bundle. + * `marketplace` and `repository` record a client that publishes no verified + * local form, so the install surface never prints an unproven recipe against + * the emitted bundle. An indexed marketplace name and a Git `owner/repository` + * are separate inputs wherever a client's own documentation separates them. */ -const CLIENT_INSTALL_SOURCES: readonly string[] = Object.freeze(['local-directory', 'marketplace']); +const CLIENT_INSTALL_SOURCES: readonly string[] = + Object.freeze(['local-directory', 'marketplace', 'repository']); /** One authored client row from a pinned table's `clients` block. */ export interface ClientCompatibilityTableEntry { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index e97292a3a..76a5d2ef7 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -256,6 +256,21 @@ const clientTierSentence = (record: ClientCompatibilityRecord): string => { } }; +/** + * How a source other than a local directory is named, so an indexed + * marketplace entry and a Git repository are not printed as the same claim. + */ +const clientSourceSentence = (record: ClientCompatibilityRecord): string => { + switch (record.install!.source) { + case 'marketplace': + return 'a marketplace'; + case 'repository': + return 'a Git repository'; + default: + throw new TypeError(`Unknown client install source ${JSON.stringify(record.install!.source)} for ${record.id}.`); + } +}; + /** Whether this build wrote the recorded path, directory or file. */ const planContains = (planned: readonly string[], path: string): boolean => planned.some((entry) => entry === path || entry.startsWith(`${path}/`)); @@ -304,8 +319,9 @@ const clientLine = (planned: readonly string[]) => (record: ClientCompatibilityR // An install command for a bundle it reads nothing of is not an install. anchor === undefined || reads.length === 0 ? '' - : record.install!.source === 'marketplace' - ? ` Install (no local-directory install is verified for this artifact): \`${anchor.command}\`.` + : record.install!.source !== 'local-directory' + ? ` Install from ${clientSourceSentence(record)} (no local-directory install is verified for this` + + ` artifact): \`${anchor.command}\`.` // Registration points the client at the emitted tree; nothing is copied. : anchor.role === 'register' ? ` Register: \`${anchor.command}\`.` diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index ba1a6f8e2..3c7a54816 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -293,10 +293,116 @@ it('reads a shadow this build actually wrote as fact, not as a hypothetical', () // A client with no verified local install prints its own marketplace command, // which its documentation shows carrying the trust flag. expect(install).toContain( - 'Install (no local-directory install is verified for this artifact):' + 'Install from a marketplace (no local-directory install is verified for this artifact):' + ' `grok plugin install --trust`.', ); expect(install).not.toContain('grok plugin install ./'); + // Copilot CLI checks the root manifest before .claude-plugin/plugin.json, so + // the composite root it shares with Claude is still read from the root (#728). + expect(clientLineFor(install!, 'GitHub Copilot CLI')).toBe( + '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) installs this' + + ' bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.' + + ' Install: `copilot plugin install `. Not loaded: hooks.' + + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.', + ); +}); + +/** The one rendered line for a client, so a claim is checked where it is made. */ +const clientLineFor = (install: string, name: string): string => + install.split('\n').find((line) => line.startsWith(`- **${name}**`))!; + +// The four records #728 corrects, rendered against every inventory a portable +// bundle can have: the tier sentence, the paths this build wrote, the action +// with the role its own documentation gives it, the source that action takes +// the artifact from, and the narrowing of a document this build actually wrote. +it.each([ + { + inventory: 'no component', + mcpServers: [], + reads: 'Reads: `plugin.json`.', + // Cline copies the skill tree; a bundle without one has nothing to copy. + skills: [], + skillsClient: 'This bundle emits none of the paths it reads, so there is nothing to install there.', + withMcpDocument: false, + }, + { + inventory: 'skills only', + mcpServers: [], + reads: 'Reads: `plugin.json`, `skills`.', + skills: [portableSkill], + skillsClient: 'Reads: `skills`. Install: `cp -R skills/ ~/.cline/skills/`.', + withMcpDocument: false, + }, + { + inventory: 'MCP only', + mcpServers: [portableServer], + reads: 'Reads: `mcp.json`, `plugin.json`.', + skills: [], + skillsClient: 'This bundle emits none of the paths it reads, so there is nothing to install there.', + withMcpDocument: true, + }, + { + inventory: 'skills and MCP', + mcpServers: [portableServer], + reads: 'Reads: `mcp.json`, `plugin.json`, `skills`.', + skills: [portableSkill], + skillsClient: 'Reads: `skills`. Install: `cp -R skills/ ~/.cline/skills/`.', + withMcpDocument: true, + }, +])('renders the corrected client records against a portable bundle with $inventory', (expected) => { + const install = writesFor('portable', { + ...modelFor('portable'), + mcpServers: expected.mcpServers, + skills: expected.skills, + }).get('INSTALL.md')!; + + // Copilot CLI: a local-directory install, and the one manifest location its + // published order puts ahead of the emitted root. + expect(clientLineFor(install, 'GitHub Copilot CLI')).toBe( + '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) installs this' + + ` bundle as one plugin. ${expected.reads}` + + ' Install: `copilot plugin install `. Not loaded: hooks.' + + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.', + ); + // VS Code registers the emitted directory where it lies; nothing is copied. + expect(clientLineFor(install, 'VS Code (Copilot agent plugins)')).toBe( + '- **VS Code (Copilot agent plugins)** (code.visualstudio.com/docs/agent-customization/agent-plugins,' + + ` page footer 9/2/2026, retrieved 2026-09-06) installs this bundle as one plugin. ${expected.reads}` + + ' Register: `"chat.pluginLocations": { "": true }`.' + + ' Not loaded: placeholders, hooks.', + ); + expect(install).not.toContain('Install: `"chat.pluginLocations"'); + // Hermes takes a Git repository, not an indexed marketplace name, and the + // recorded command leaves the plugin disabled. + expect(clientLineFor(install, 'Hermes Agent')).toBe( + '- **Hermes Agent** (hermes-agent.nousresearch.com developer guide retrieved 2026-09-06; no version is' + + ` printed on the page) installs this bundle as one plugin. ${expected.reads}` + + ' Install from a Git repository (no local-directory install is verified for this artifact):' + + ' `hermes plugins install / --no-enable`. Not loaded: hooks.', + ); + expect(install).not.toContain('Install from a marketplace (no local-directory install is verified for' + + ' this artifact): `hermes plugins install'); + // Cline's own action is a copy, so its role stays an install (#728 §3). + expect(clientLineFor(install, 'Cline')).toBe( + '- **Cline** (@cline/cli 0.0.13 exercised 2026-09-06; docs.cline.bot retrieved 2026-09-06' + + ' (@cline/sdk 0.0.82)) loads the components it recognizes without reading the manifest.' + + ` ${expected.skillsClient} Not loaded: manifest, mcp, placeholders, hooks.`, + ); + expect(install).not.toContain('Register: `cp -R skills/'); + + // A narrowing of the MCP document is only a limit here once this build wrote + // that document: the stdio subset Copilot was proven on, and CodeWhale's + // remote policy, which needs a declaration ordinary portable output omits. + const copilotStdio = ' - Partial `mcp`: 2026-09-06: the probe listed one stdio server: no streamable-http' + + ' server from an emitted mcp.json was registered, launched, or authenticated on 1.0.83'; + const codewhaleRemote = ' - Partial `mcp`: 2026-09-06: CodeWhale narrows the standard at the plugin boundary.'; + const codewhaleHosts = 'declare exactly the normalized endpoint host set in capabilities.network_hosts.' + + ' That declaration rides in extensions["net.codewhale"], which this projection writes only when the' + + ' author authors portable.extensions'; + for (const claim of [copilotStdio, codewhaleRemote, codewhaleHosts]) { + if (expected.withMcpDocument) expect(install).toContain(claim); + else expect(install).not.toContain(claim); + } }); it('documents recorded Agent Plugins clients for the portable profile', () => { diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 80f65cb12..24800a426 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -697,6 +697,53 @@ it('emits the artifact paths every recorded client reads, and none of the manife expect(clients.find((client) => client.id === 'antigravity')?.discovery.required).toEqual([]); }); +/** + * The corrections #728 asks for, read back from the pinned table: precedence + * that its upstream publishes, evidence held to the subset it proves, an action + * whose role follows the behavior, and the source that action really takes. + */ +it('holds the corrected client records to the source each one is pinned to', () => { + const clients = clientCompatibilityFrom('portable', capabilityTable.clients); + const client = (id: string) => clients.find((record) => record.id === id)!; + + // Copilot CLI checks .plugin/plugin.json before the emitted root manifest and + // the emitted root before .claude-plugin/plugin.json, and its published order + // is for the manifest file alone. + expect(client('copilot-cli').discovery.shadowedBy).toEqual([{ path: '.plugin/plugin.json', surfaces: ['manifest'] }]); + expect(client('copilot-cli').discovery.evidence.join('\n')).toContain('checked in this order'); + // A listed stdio server is not a launched remote one. + expect(client('copilot-cli').surfaces.mcp).toMatchObject({ state: 'degraded' }); + expect(client('copilot-cli').surfaces.mcp!.reason).toContain('no streamable-http server from an emitted mcp.json' + + ' was registered, launched, or authenticated'); + // The deprecation of the local install and the unlaunched server stay visible. + expect(client('copilot-cli').discovery.evidence.join('\n')).toContain('Direct plugin installs (repos, URLs, local paths) are deprecated'); + expect(client('copilot-cli').surfaces.placeholders).toMatchObject({ state: 'degraded' }); + + // CodeWhale's plugin boundary restricts a remote server beyond the env rule, + // and the host declaration it requires is not ordinary portable output. + expect(client('codewhale').surfaces.mcp!.reason).toContain('redirects must stay on the reviewed origin'); + expect(client('codewhale').surfaces.mcp!.reason).toContain('capabilities.network_hosts'); + expect(client('codewhale').surfaces.mcp!.reason).toContain('extensions["net.codewhale"]'); + + // Registering a directory in place is not installing a copy of it. + expect(client('vs-code').install).toMatchObject({ + actions: [{ command: '"chat.pluginLocations": { "": true }', role: 'register' }], + source: 'local-directory', + }); + // Cline's action copies the tree, so its role follows the behavior, not the wording. + expect(client('cline').install).toMatchObject({ + actions: [{ command: 'cp -R skills/ ~/.cline/skills/', role: 'install' }], + source: 'local-directory', + }); + + // A Git repository is not an indexed marketplace entry. + expect(client('hermes-agent').install).toMatchObject({ + actions: [{ command: 'hermes plugins install / --no-enable', role: 'install' }], + source: 'repository', + }); + expect(client('hermes-agent').discovery.evidence.join('\n')).toContain('never touch the index'); +}); + it('records every client once, since JSON keeps only the last of a repeated key', async () => { // A record added twice is not a duplicate that a reader can spot: the parser // drops every copy but the last, so the earlier one is text nothing reads. @@ -772,6 +819,13 @@ it('refuses a client record that claims a tier its own rows do not support', () expect(() => clientCompatibilityFrom('portable', record({ install: { actions: [{ command: ' ', role: 'install' }], source: 'local-directory' }, }))).toThrow(/install action with no verbatim command/u); + // A Git repository is a recorded source; an unlisted spelling of it is not. + expect(() => clientCompatibilityFrom('portable', record({ + install: { actions: [{ command: 'demo plugins install /', role: 'install' }], source: 'repository' }, + }))).not.toThrow(); + expect(() => clientCompatibilityFrom('portable', record({ + install: { actions: [{ command: 'demo plugins install ', role: 'install' }], source: 'git' }, + }))).toThrow(/install block with source "git" \(expected local-directory or marketplace or repository\)/u); // A client that reads nothing this artifact emits cannot install it locally. expect(() => clientCompatibilityFrom('portable', record({ discovery: { evidence: ['2026-09-06: read from the vendor docs.'] }, diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 294e6877c..953b7d342 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -144,7 +144,7 @@ const messages = { 'The `plugin` section of each table, flattened to dotted capability paths and grouped by top-level key. Boolean entries record a component the adapter emits; entries with a state carry the reason the host evidence supports or withholds it. Evidence notes stay in the JSON files.', clients: 'Recorded third-party clients', clientsIntro: - 'The `clients` section of each table: agents that read the artifact this target already emits, pinned to their own documentation on the date it was read. These clients are not target adapters — nothing about them changes what the compiler writes — so every row is evidence about a reader of the existing artifact, never a projection. The tier says what the client loads: `agent-plugins` loads the emitted package as one plugin, `skills` loads the components it recognizes without reading the manifest, and `none` loads nothing from it as published. Reading a document and running what it configures are separate rows: `mcp` records that the client reads the emitted `mcp.json` as MCP configuration, while `placeholders` records that it expands the reserved `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` and provides them to the process it spawns. Precedence is per file and per surface — a file that wins for `mcp` leaves the skill tree discovered, and only a manifest that wins replaces the plugin. An install action carries the role its own documentation gives it, and a client with no verified local-directory install of this artifact is recorded against its marketplace source instead. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', + 'The `clients` section of each table: agents that read the artifact this target already emits, pinned to their own documentation on the date it was read. These clients are not target adapters — nothing about them changes what the compiler writes — so every row is evidence about a reader of the existing artifact, never a projection. The tier says what the client loads: `agent-plugins` loads the emitted package as one plugin, `skills` loads the components it recognizes without reading the manifest, and `none` loads nothing from it as published. Reading a document and running what it configures are separate rows: `mcp` records that the client reads the emitted `mcp.json` as MCP configuration, while `placeholders` records that it expands the reserved `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` and provides them to the process it spawns. Precedence is per file and per surface — a file that wins for `mcp` leaves the skill tree discovered, and only a manifest that wins replaces the plugin. An install action carries the role its own documentation gives it — `install` copies the artifact into the client\'s own store, `register` points the client at the emitted tree where it lies — and a client with no verified local-directory install of this artifact is recorded against the source its documentation does publish, an indexed `marketplace` name or a Git `repository`. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', clientSurfaces: 'Client surfaces', clientDiscovery: 'Client discovery', headers: { @@ -152,6 +152,7 @@ const messages = { tier: 'Tier', observed: 'Observed', install: 'Install actions', + clientSource: 'Install source', surface: 'Surface', required: 'Paths it reads', shadowedBy: 'Shadowed by (per surface)', @@ -264,7 +265,7 @@ const messages = { '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', clients: '已记录的第三方客户端', clientsIntro: - '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 表示它不读取清单、只加载自己能识别的组件,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色;对本产物没有已验证的本地目录安装形式的客户端,只按其市场来源记录。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 表示它不读取清单、只加载自己能识别的组件,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色——`install` 会把产物复制进客户端自己的存储,`register` 只是让客户端就地读取产出的目录树;对本产物没有已验证的本地目录安装形式的客户端,按其文档确实公布的来源记录:索引化的 `marketplace` 名称,或 Git `repository`。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', clientSurfaces: '客户端界面', clientDiscovery: '客户端发现', headers: { @@ -272,6 +273,7 @@ const messages = { tier: '层级', observed: '观测依据', install: '安装操作', + clientSource: '安装来源', surface: '界面', required: '读取的路径', shadowedBy: '被以下文件遮蔽(按界面)', @@ -652,12 +654,15 @@ function renderHosts(hosts: readonly HostCapabilityTable[], m: Messages): string ); sections.push( table( - [m.headers.client, m.headers.host, m.headers.tier, m.headers.observed, m.headers.install], + [m.headers.client, m.headers.host, m.headers.tier, m.headers.observed, m.headers.clientSource, m.headers.install], clients.map(({ host, record }) => [ escapeProse(record.name), code(host.host), code(record.tier), escapeProse(record.observed), + // Where the recorded command takes the artifact from: a registered + // local directory, an indexed marketplace name, or a Git repository. + record.install === undefined ? m.notApplicable : code(record.install.source), record.install === undefined ? m.notApplicable : record.install.actions From be5eef842c9e5b7240177f3eed468aa3b28e6005 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 04:08:49 +0000 Subject: [PATCH 2/4] fix(portable): hold the Copilot MCP location and CodeWhale remote failure to their sources The published file-locations table covers the manifest order alone: skills default to skills/ and MCP configuration is .mcp.json or .github/mcp.json, so .mcp.json is recorded as taking the MCP surface and the emitted root mcp.json stays proven only by the probe that ran without it. A CodeWhale bundle whose remote endpoint has no matching capabilities.network_hosts fails validation rather than loading with a narrowed MCP surface, and a register action is now refused against anything but a local directory. --- .../portable-client-record-corrections.md | 12 +++++++----- .../adapters/capabilities/portable-1.0.0.json | 12 +++++++++--- .../src/adapters/capability-state.ts | 11 ++++++++++- .../tests/install-surface.test.ts | 19 +++++++++++++------ .../tests/portable-adapter.test.ts | 12 +++++++++++- 5 files changed, 50 insertions(+), 16 deletions(-) diff --git a/.changeset/portable-client-record-corrections.md b/.changeset/portable-client-record-corrections.md index 3bd2e29b9..dda3eec26 100644 --- a/.changeset/portable-client-record-corrections.md +++ b/.changeset/portable-client-record-corrections.md @@ -6,8 +6,9 @@ Correct four portable client records in the pinned capability table and the `INSTALL.md` and host-reference text rendered from them: GitHub Copilot CLI records `.plugin/plugin.json` as the one manifest location its published order puts ahead of the emitted root (the emitted root still wins over -`.claude-plugin/plugin.json`) and narrows its `mcp` row to the listed stdio -server it was proven on; CodeWhale's `mcp` row names the remote endpoint, +`.claude-plugin/plugin.json`) and `.mcp.json` as the MCP location it publishes +for a plugin, and narrows its `mcp` row to the listed stdio server it was +proven on; CodeWhale's `mcp` row names the remote endpoint, header, secret-URL, redirect and `capabilities.network_hosts` restrictions its plugin boundary imposes, and that the host declaration rides in `extensions["net.codewhale"]`, which only an authored `portable.extensions` @@ -15,6 +16,7 @@ emits; VS Code's `chat.pluginLocations` action carries the `register` role, since it loads the directory in place (Cline keeps `install`, because its action copies the tree); and Hermes records the new `repository` install source for `hermes plugins install / --no-enable`, a Git repository -rather than an indexed marketplace name. A non-local install now names its -source in the generated instructions, and the generated host reference gains an -install-source column in both locales. (#728) +rather than an indexed marketplace name. A `register` action is now refused +against anything but a local directory, a non-local install names its source in +the generated instructions, and the generated host reference gains an +install-source column in both locales. (#732) 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 86579b6ac..020730132 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 @@ -698,9 +698,9 @@ "mcp": { "evidence": [ "2026-09-06: docs/MCP.md (commit 806d93e826ee615b702629775f0ec3dd19c97854, 2026-08-25) supports all three transports the 1.0.0 MCP schema defines, including the stdio and streamable-http forms this projection emits.", - "2026-09-06: docs/MCP.md#plugin-contributed (commit 19d34a5fb6c07b34e0b7234beb74a1cf1969efb4) states the bundle boundary verbatim — \"unknown fields and ambiguous transports fail closed; stdio environment values must be exact environment-source references; remote literal headers and secret-bearing URLs are rejected; declared network hosts must exactly match the normalized endpoint host set; and redirects remain on the reviewed origin\" — and docs/PLUGIN_BUNDLES.md states that \"`capabilities`, `when`, and `display_name` ride in `extensions[\"net.codewhale\"]`\" and that \"`capabilities.network_hosts` is not a future surface: it is enforced today, and must exactly match the normalized host set of the bundle's remote MCP endpoints\". Its validation section states the remote form verbatim: \"Remote MCP URLs must use HTTPS, except for explicit loopback HTTP endpoints. They cannot contain user information, a query, or a fragment. Literal headers are rejected: authentication must name a source environment variable through `env_headers` or `bearer_token_env_var`.\"" + "2026-09-06: docs/MCP.md#plugin-contributed-mcp (commit 19d34a5fb6c07b34e0b7234beb74a1cf1969efb4) states the bundle boundary verbatim — \"unknown fields and ambiguous transports fail closed; stdio environment values must be exact environment-source references; remote literal headers and secret-bearing URLs are rejected; declared network hosts must exactly match the normalized endpoint host set; and redirects remain on the reviewed origin\" — and docs/PLUGIN_BUNDLES.md states that \"`capabilities`, `when`, and `display_name` ride in `extensions[\"net.codewhale\"]`\" and that \"`capabilities.network_hosts` is not a future surface: it is enforced today, and must exactly match the normalized host set of the bundle's remote MCP endpoints\". Its validation section states the remote form verbatim: \"Remote MCP URLs must use HTTPS, except for explicit loopback HTTP endpoints. They cannot contain user information, a query, or a fragment. Literal headers are rejected: authentication must name a source environment variable through `env_headers` or `bearer_token_env_var`.\"" ], - "reason": "2026-09-06: CodeWhale narrows the standard at the plugin boundary. Its env rule — \"Local stdio environment entries must use exact ${SOURCE_ENV} references\" — rejects an emitted env value that is anything other than one whole variable reference. A remote server emitted into mcp.json is narrower still: the URL must be HTTPS (or explicit loopback HTTP) with no user information, query, or fragment, a literal header is rejected in favor of CodeWhale's own env_headers or bearer_token_env_var keys, redirects must stay on the reviewed origin, and the bundle must declare exactly the normalized endpoint host set in capabilities.network_hosts. That declaration rides in extensions[\"net.codewhale\"], which this projection writes only when the author authors portable.extensions, so a remote server emitted without it has no accepted host declaration there.", + "reason": "2026-09-06: CodeWhale narrows the standard at the plugin boundary. Its env rule — \"Local stdio environment entries must use exact ${SOURCE_ENV} references\" — rejects an emitted env value that is anything other than one whole variable reference. A remote server emitted into mcp.json is narrower still: the URL must be HTTPS (or explicit loopback HTTP) with no user information, query, or fragment, a literal header is rejected in favor of CodeWhale's own env_headers or bearer_token_env_var keys, redirects must stay on the reviewed origin, and the bundle must declare exactly the normalized endpoint host set in capabilities.network_hosts. That declaration rides in extensions[\"net.codewhale\"], which this projection writes only when the author authors portable.extensions; a remote server emitted without it is a validation error, and \"an active bundle must be … free of validation errors\", so the whole bundle stays inactive there until the author declares the matching host set.", "state": "degraded" }, "placeholders": { @@ -748,13 +748,19 @@ "surfaces": [ "manifest" ] + }, + { + "path": ".mcp.json", + "surfaces": [ + "mcp" + ] } ], "evidence": [ "2026-09-06: `copilot plugin install /tmp/hostcli/fixture` on 1.0.83 answered \"Plugin \\\"probe-plugin\\\" installed successfully. Installed 1 skill.\" for a hand-built Agent Plugins package (root plugin.json with the 1.0.0 $schema, skills/review/SKILL.md, mcp.json), and `copilot plugins list` then reported the plugin, the plugin-contributed MCP server, and the plugin-contributed skill from that one manifest.", "2026-09-06: the same install printed \"Warning: Direct plugin installs (repos, URLs, local paths) are deprecated. Only plugin@marketplace installs will be supported in a future release\", so the local-path command above is a deprecated path on 1.0.83.", "2026-09-06: the installed copy is a copy, not a link: ~/.copilot/config.json recorded cache_path ~/.copilot/installed-plugins/_direct/fixture with source {\"source\":\"local\",\"path\":\"...\"}.", - "2026-09-06: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference#file-locations orders the manifest locations \".plugin/plugin.json, plugin.json, .github/plugin/plugin.json, or .claude-plugin/plugin.json (checked in this order)\", so only .plugin/plugin.json wins over the emitted root manifest; the emitted root plugin.json is checked before .claude-plugin/plugin.json, so a composite root that also carries the Claude manifest is still read from the root. The order is published for the manifest file alone: the skill tree and mcp.json stay at their fixed plugin-root locations." + "2026-09-06: https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-plugin-reference#file-locations orders the manifest locations \".plugin/plugin.json, plugin.json, .github/plugin/plugin.json, or .claude-plugin/plugin.json (checked in this order)\", so only .plugin/plugin.json wins over the emitted root manifest; the emitted root plugin.json is checked before .claude-plugin/plugin.json, so a composite root that also carries the Claude manifest is still read from the root. That order covers the manifest file alone. The same table lists the other components separately — \"Skills | skills/ (default, overridable in manifest)\" and \"MCP configuration | .mcp.json, .github/mcp.json\" — so the emitted root mcp.json is proven only by the 1.0.83 probe above, which ran with no .mcp.json present, and a root that also carries .mcp.json is recorded as taking the MCP surface." ] }, "surfaces": { diff --git a/packages/agent-bundle/src/adapters/capability-state.ts b/packages/agent-bundle/src/adapters/capability-state.ts index bf8bcd848..8485223a2 100644 --- a/packages/agent-bundle/src/adapters/capability-state.ts +++ b/packages/agent-bundle/src/adapters/capability-state.ts @@ -496,9 +496,18 @@ const clientInstall = ( } return Object.freeze({ command: action.command, role: action.role! }); }); - if (validated.filter((action) => CLIENT_INSTALL_ANCHORS.includes(action.role)).length !== 1) { + const anchors = validated.filter((action) => CLIENT_INSTALL_ANCHORS.includes(action.role)); + if (anchors.length !== 1) { throw new CapabilityStateError(`The pinned ${target} table gives client ${id} an install block without exactly one ${CLIENT_INSTALL_ANCHORS.join(' or ')} action.`); } + // Registration names the emitted tree where it lies, so it is a local + // directory by definition: a registered marketplace or repository entry + // would render as an install of something this artifact is not. + if (anchors[0]!.role === 'register' && install.source !== 'local-directory') { + throw new CapabilityStateError( + `The pinned ${target} table gives client ${id} a register action against a ${install.source} source (expected local-directory).`, + ); + } return Object.freeze({ actions: Object.freeze(validated), ...(install.location === undefined ? {} : { location: install.location }), diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 3c7a54816..e88cf0d66 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -298,13 +298,18 @@ it('reads a shadow this build actually wrote as fact, not as a hypothetical', () ); expect(install).not.toContain('grok plugin install ./'); // Copilot CLI checks the root manifest before .claude-plugin/plugin.json, so - // the composite root it shares with Claude is still read from the root (#728). + // the composite root it shares with Claude is still read from the root — but + // .mcp.json is its own published MCP location, so that surface moves (#728). expect(clientLineFor(install!, 'GitHub Copilot CLI')).toBe( '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) installs this' - + ' bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.' + + ' bundle as one plugin. Reads: `plugin.json`, `skills`.' + ' Install: `copilot plugin install `. Not loaded: hooks.' + + ' This build also writes `.mcp.json`, which it uses for mcp instead.' + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.', ); + // The stdio narrowing is a limit on the emitted document, and that document + // is not the one Copilot reads here. + expect(install).not.toContain(' - Partial `mcp`: 2026-09-06: the probe listed one stdio server'); }); /** The one rendered line for a client, so a claim is checked where it is made. */ @@ -356,13 +361,15 @@ it.each([ skills: expected.skills, }).get('INSTALL.md')!; - // Copilot CLI: a local-directory install, and the one manifest location its - // published order puts ahead of the emitted root. + // Copilot CLI: a local-directory install, the one manifest location its + // published order puts ahead of the emitted root, and the MCP location it + // publishes for a plugin, neither of which a portable-only build writes. expect(clientLineFor(install, 'GitHub Copilot CLI')).toBe( '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) installs this' + ` bundle as one plugin. ${expected.reads}` + ' Install: `copilot plugin install `. Not loaded: hooks.' - + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.', + + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.' + + ' A root that also carries `.mcp.json` uses it for mcp and still reads the rest.', ); // VS Code registers the emitted directory where it lies; nothing is copied. expect(clientLineFor(install, 'VS Code (Copilot agent plugins)')).toBe( @@ -398,7 +405,7 @@ it.each([ const codewhaleRemote = ' - Partial `mcp`: 2026-09-06: CodeWhale narrows the standard at the plugin boundary.'; const codewhaleHosts = 'declare exactly the normalized endpoint host set in capabilities.network_hosts.' + ' That declaration rides in extensions["net.codewhale"], which this projection writes only when the' - + ' author authors portable.extensions'; + + ' author authors portable.extensions; a remote server emitted without it is a validation error'; for (const claim of [copilotStdio, codewhaleRemote, codewhaleHosts]) { if (expected.withMcpDocument) expect(install).toContain(claim); else expect(install).not.toContain(claim); diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 24800a426..430fe4759 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -709,8 +709,14 @@ it('holds the corrected client records to the source each one is pinned to', () // Copilot CLI checks .plugin/plugin.json before the emitted root manifest and // the emitted root before .claude-plugin/plugin.json, and its published order // is for the manifest file alone. - expect(client('copilot-cli').discovery.shadowedBy).toEqual([{ path: '.plugin/plugin.json', surfaces: ['manifest'] }]); + // Its published locations are per component: the manifest order above, and + // .mcp.json as a plugin's MCP configuration, which the probe ran without. + expect(client('copilot-cli').discovery.shadowedBy).toEqual([ + { path: '.plugin/plugin.json', surfaces: ['manifest'] }, + { path: '.mcp.json', surfaces: ['mcp'] }, + ]); expect(client('copilot-cli').discovery.evidence.join('\n')).toContain('checked in this order'); + expect(client('copilot-cli').discovery.evidence.join('\n')).toContain('MCP configuration | .mcp.json, .github/mcp.json'); // A listed stdio server is not a launched remote one. expect(client('copilot-cli').surfaces.mcp).toMatchObject({ state: 'degraded' }); expect(client('copilot-cli').surfaces.mcp!.reason).toContain('no streamable-http server from an emitted mcp.json' @@ -826,6 +832,10 @@ it('refuses a client record that claims a tier its own rows do not support', () expect(() => clientCompatibilityFrom('portable', record({ install: { actions: [{ command: 'demo plugins install ', role: 'install' }], source: 'git' }, }))).toThrow(/install block with source "git" \(expected local-directory or marketplace or repository\)/u); + // Registering the emitted tree where it lies is a local-directory action. + expect(() => clientCompatibilityFrom('portable', record({ + install: { actions: [{ command: 'demo plugins register /', role: 'register' }], source: 'repository' }, + }))).toThrow(/register action against a repository source \(expected local-directory\)/u); // A client that reads nothing this artifact emits cannot install it locally. expect(() => clientCompatibilityFrom('portable', record({ discovery: { evidence: ['2026-09-06: read from the vendor docs.'] }, From 1f5614ccc253a084d8126cbba8611b773bfa0774 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 04:28:31 +0000 Subject: [PATCH 3/4] fix(portable): say a client loads the bundle, and record what an install copies The agent-plugins tier sentence said every such client installs the bundle, which contradicted the register action VS Code actually publishes; the recorded action now carries that distinction alone. The generated reference says an install copies the artifact or the one component the client recognizes, since a skills-tier install copies only that component. The inventory matrix asserts CodeWhale's endpoint, header, secret-URL and redirect limits, its inactive- bundle consequence, and Copilot's unspawned-server placeholder row. --- packages/agent-bundle/src/install/surface.ts | 4 ++- .../tests/install-surface.test.ts | 34 ++++++++++++------- website/plugins/generated-reference.ts | 4 +-- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 76a5d2ef7..880a8ff74 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -244,7 +244,9 @@ const portableClients: readonly ClientCompatibilityRecord[] = const clientTierSentence = (record: ClientCompatibilityRecord): string => { switch (record.tier) { case 'agent-plugins': - return 'installs this bundle as one plugin'; + // "Loads", not "installs": the recorded action below says whether the + // client copies the bundle or reads it where it lies. + return 'loads this bundle as one plugin'; case 'skills': // Not "skills only": a skills-tier client is one that does not read the // manifest, and several of them read the MCP document as well. diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index e88cf0d66..0fd57691f 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -192,7 +192,7 @@ const portableServer = { // portable bundle can have. The claim is the emitted plan, not the record. it.each([ { - agentPlugins: 'installs this bundle as one plugin. Reads: `plugin.json`.', + agentPlugins: 'loads this bundle as one plugin. Reads: `plugin.json`.', inventory: 'no component', swivalPartial: false, swivalTier: 'loads the components it recognizes without reading the manifest. This bundle emits none of' @@ -203,7 +203,7 @@ it.each([ + ' the paths it reads, so there is nothing to install there.', }, { - agentPlugins: 'installs this bundle as one plugin. Reads: `plugin.json`, `skills`.', + agentPlugins: 'loads this bundle as one plugin. Reads: `plugin.json`, `skills`.', inventory: 'skills only', swivalPartial: true, swivalTier: 'loads the components it recognizes without reading the manifest. Reads: `skills`.' @@ -215,7 +215,7 @@ it.each([ + ' Install: `qoder plugins install --scope user`.', }, { - agentPlugins: 'installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`.', + agentPlugins: 'loads this bundle as one plugin. Reads: `mcp.json`, `plugin.json`.', inventory: 'MCP only', swivalPartial: false, // Swival's registration hands over the skill tree alone, so an MCP-only @@ -228,7 +228,7 @@ it.each([ + ' Install: `qoder plugins install --scope user`.', }, { - agentPlugins: 'installs this bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.', + agentPlugins: 'loads this bundle as one plugin. Reads: `mcp.json`, `plugin.json`, `skills`.', inventory: 'skills and MCP', swivalPartial: true, swivalTier: 'loads the components it recognizes without reading the manifest. Reads: `skills`.' @@ -279,7 +279,7 @@ it('reads a shadow this build actually wrote as fact, not as a hypothetical', () }).get('INSTALL.md'); expect(install).toContain('- **Devin CLI** (Agent Plugins 1.0.0; docs retrieved 2026-09-06,' - + ' plugins documented as closed beta) installs this bundle as one plugin, but this build also' + + ' plugins documented as closed beta) loads this bundle as one plugin, but this build also' + ' writes `.claude-plugin/plugin.json`, which it reads as the plugin instead.'); expect(install).toContain('- **Qoder CLI** (docs retrieved 2026-09-06; no CLI version is published on' + ' any page) loads the components it recognizes without reading the manifest. Reads: `skills`.' @@ -301,7 +301,7 @@ it('reads a shadow this build actually wrote as fact, not as a hypothetical', () // the composite root it shares with Claude is still read from the root — but // .mcp.json is its own published MCP location, so that surface moves (#728). expect(clientLineFor(install!, 'GitHub Copilot CLI')).toBe( - '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) installs this' + '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) loads this' + ' bundle as one plugin. Reads: `plugin.json`, `skills`.' + ' Install: `copilot plugin install `. Not loaded: hooks.' + ' This build also writes `.mcp.json`, which it uses for mcp instead.' @@ -365,7 +365,7 @@ it.each([ // published order puts ahead of the emitted root, and the MCP location it // publishes for a plugin, neither of which a portable-only build writes. expect(clientLineFor(install, 'GitHub Copilot CLI')).toBe( - '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) installs this' + '- **GitHub Copilot CLI** (@github/copilot 1.0.83, installed and exercised 2026-09-06) loads this' + ` bundle as one plugin. ${expected.reads}` + ' Install: `copilot plugin install `. Not loaded: hooks.' + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.' @@ -374,7 +374,7 @@ it.each([ // VS Code registers the emitted directory where it lies; nothing is copied. expect(clientLineFor(install, 'VS Code (Copilot agent plugins)')).toBe( '- **VS Code (Copilot agent plugins)** (code.visualstudio.com/docs/agent-customization/agent-plugins,' - + ` page footer 9/2/2026, retrieved 2026-09-06) installs this bundle as one plugin. ${expected.reads}` + + ` page footer 9/2/2026, retrieved 2026-09-06) loads this bundle as one plugin. ${expected.reads}` + ' Register: `"chat.pluginLocations": { "": true }`.' + ' Not loaded: placeholders, hooks.', ); @@ -383,7 +383,7 @@ it.each([ // recorded command leaves the plugin disabled. expect(clientLineFor(install, 'Hermes Agent')).toBe( '- **Hermes Agent** (hermes-agent.nousresearch.com developer guide retrieved 2026-09-06; no version is' - + ` printed on the page) installs this bundle as one plugin. ${expected.reads}` + + ` printed on the page) loads this bundle as one plugin. ${expected.reads}` + ' Install from a Git repository (no local-directory install is verified for this artifact):' + ' `hermes plugins install / --no-enable`. Not loaded: hooks.', ); @@ -402,11 +402,21 @@ it.each([ // remote policy, which needs a declaration ordinary portable output omits. const copilotStdio = ' - Partial `mcp`: 2026-09-06: the probe listed one stdio server: no streamable-http' + ' server from an emitted mcp.json was registered, launched, or authenticated on 1.0.83'; + // Recording the configuration a client keeps is not proof that it ran it. + const copilotUnspawned = ' - Partial `placeholders`: 2026-09-06: the probe proved the configuration Copilot' + + ' records, not a spawned server'; const codewhaleRemote = ' - Partial `mcp`: 2026-09-06: CodeWhale narrows the standard at the plugin boundary.'; + const codewhaleEndpoint = 'A remote server emitted into mcp.json is narrower still: the URL must be HTTPS' + + ' (or explicit loopback HTTP) with no user information, query, or fragment, a literal header is' + + " rejected in favor of CodeWhale's own env_headers or bearer_token_env_var keys, redirects must stay" + + ' on the reviewed origin'; + // The missing host declaration fails the bundle, it does not narrow it. const codewhaleHosts = 'declare exactly the normalized endpoint host set in capabilities.network_hosts.' + ' That declaration rides in extensions["net.codewhale"], which this projection writes only when the' - + ' author authors portable.extensions; a remote server emitted without it is a validation error'; - for (const claim of [copilotStdio, codewhaleRemote, codewhaleHosts]) { + + ' author authors portable.extensions; a remote server emitted without it is a validation error, and' + + ' "an active bundle must be … free of validation errors", so the whole bundle stays inactive there' + + ' until the author declares the matching host set.'; + for (const claim of [copilotStdio, copilotUnspawned, codewhaleRemote, codewhaleEndpoint, codewhaleHosts]) { if (expected.withMcpDocument) expect(install).toContain(claim); else expect(install).not.toContain(claim); } @@ -424,7 +434,7 @@ it('documents recorded Agent Plugins clients for the portable profile', () => { expect(install).toContain('### Other recorded clients'); expect(install).toContain('**Devin CLI**'); expect(install).toContain('`devin plugins install `'); - expect(install).toContain('installs this bundle as one plugin'); + expect(install).toContain('loads this bundle as one plugin'); expect(install).toContain('Not loaded: hooks.'); expect(install).toContain( 'A root that also carries `.devin-plugin/plugin.json` is read as that plugin instead.', diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 953b7d342..14ea8d863 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -144,7 +144,7 @@ const messages = { 'The `plugin` section of each table, flattened to dotted capability paths and grouped by top-level key. Boolean entries record a component the adapter emits; entries with a state carry the reason the host evidence supports or withholds it. Evidence notes stay in the JSON files.', clients: 'Recorded third-party clients', clientsIntro: - 'The `clients` section of each table: agents that read the artifact this target already emits, pinned to their own documentation on the date it was read. These clients are not target adapters — nothing about them changes what the compiler writes — so every row is evidence about a reader of the existing artifact, never a projection. The tier says what the client loads: `agent-plugins` loads the emitted package as one plugin, `skills` loads the components it recognizes without reading the manifest, and `none` loads nothing from it as published. Reading a document and running what it configures are separate rows: `mcp` records that the client reads the emitted `mcp.json` as MCP configuration, while `placeholders` records that it expands the reserved `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` and provides them to the process it spawns. Precedence is per file and per surface — a file that wins for `mcp` leaves the skill tree discovered, and only a manifest that wins replaces the plugin. An install action carries the role its own documentation gives it — `install` copies the artifact into the client\'s own store, `register` points the client at the emitted tree where it lies — and a client with no verified local-directory install of this artifact is recorded against the source its documentation does publish, an indexed `marketplace` name or a Git `repository`. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', + 'The `clients` section of each table: agents that read the artifact this target already emits, pinned to their own documentation on the date it was read. These clients are not target adapters — nothing about them changes what the compiler writes — so every row is evidence about a reader of the existing artifact, never a projection. The tier says what the client loads: `agent-plugins` loads the emitted package as one plugin, `skills` loads the components it recognizes without reading the manifest, and `none` loads nothing from it as published. Reading a document and running what it configures are separate rows: `mcp` records that the client reads the emitted `mcp.json` as MCP configuration, while `placeholders` records that it expands the reserved `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` and provides them to the process it spawns. Precedence is per file and per surface — a file that wins for `mcp` leaves the skill tree discovered, and only a manifest that wins replaces the plugin. An install action carries the role its own documentation gives it — `install` copies the artifact, or the one component the client recognizes, into its own store, while `register` points the client at the emitted tree where it lies — and a client with no verified local-directory install of this artifact is recorded against the source its documentation does publish, an indexed `marketplace` name or a Git `repository`. A surface without evidence is `unavailable` with a dated reason, and the reason names exactly what the client would need instead.', clientSurfaces: 'Client surfaces', clientDiscovery: 'Client discovery', headers: { @@ -265,7 +265,7 @@ const messages = { '每张表的 `plugin` 部分,按点分能力路径展开并按顶层键分组。布尔条目表示适配器会发出的组件;带状态的条目记录宿主证据支持或保留该能力的原因。证据说明保留在 JSON 文件中。', clients: '已记录的第三方客户端', clientsIntro: - '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 表示它不读取清单、只加载自己能识别的组件,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色——`install` 会把产物复制进客户端自己的存储,`register` 只是让客户端就地读取产出的目录树;对本产物没有已验证的本地目录安装形式的客户端,按其文档确实公布的来源记录:索引化的 `marketplace` 名称,或 Git `repository`。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', + '每张表的 `clients` 部分:会读取该目标已经产出的构件的其他代理,按其自身文档以及阅读文档的日期固定记录。这些客户端不是目标适配器——它们不会改变编译器写出的任何内容——因此每一行都是关于既有构件读取方的证据,而不是一种投影。tier 表示客户端加载什么:`agent-plugins` 把产出的包作为一个插件加载,`skills` 表示它不读取清单、只加载自己能识别的组件,`none` 表示按当前产出形态它什么都不加载。读取文档与运行文档所配置的内容是两行不同的记录:`mcp` 表示客户端会把产出的 `mcp.json` 当作 MCP 配置读取,而 `placeholders` 表示它会展开保留占位符 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` 并把它们提供给所启动的进程。优先级按文件、按界面生效——某个文件在 `mcp` 上胜出并不影响技能树被发现,只有清单胜出才会替换整个插件。安装操作带有其自身文档给定的角色——`install` 会把产物、或客户端唯一能识别的那个组件复制进它自己的存储,而 `register` 只是让客户端就地读取产出的目录树;对本产物没有已验证的本地目录安装形式的客户端,按其文档确实公布的来源记录:索引化的 `marketplace` 名称,或 Git `repository`。没有证据的界面一律为 `unavailable` 并附带带日期的原因,原因中明确写出该客户端实际需要的是什么。', clientSurfaces: '客户端界面', clientDiscovery: '客户端发现', headers: { From d9cfb26af42463204d9f2e33bcb2143e6a269de9 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 7 Sep 2026 04:43:00 +0000 Subject: [PATCH 4/4] test(portable): assert the composite hides Copilot's placeholder narrowing too --- packages/agent-bundle/tests/install-surface.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 0fd57691f..0266ee4ad 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -307,9 +307,11 @@ it('reads a shadow this build actually wrote as fact, not as a hypothetical', () + ' This build also writes `.mcp.json`, which it uses for mcp instead.' + ' A root that also carries `.plugin/plugin.json` uses it for manifest and still reads the rest.', ); - // The stdio narrowing is a limit on the emitted document, and that document - // is not the one Copilot reads here. + // Both narrowings are limits on the emitted document, and that document is + // not the one Copilot reads here. expect(install).not.toContain(' - Partial `mcp`: 2026-09-06: the probe listed one stdio server'); + expect(install).not.toContain(' - Partial `placeholders`: 2026-09-06: the probe proved the configuration' + + ' Copilot records'); }); /** The one rendered line for a client, so a claim is checked where it is made. */