diff --git a/.changeset/portable-agent-plugins-evidence.md b/.changeset/portable-agent-plugins-evidence.md new file mode 100644 index 000000000..e62089a3e --- /dev/null +++ b/.changeset/portable-agent-plugins-evidence.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +portable: pin Agent Plugins 1.0.0 adoption evidence — specification repository commit, native-client roster, a repeatable installer/filesystem/pinned-schema conformance proof against an isolated Cursor home, and a Cursor 3.18.25 IDE plugin-loader dogfood audit (discovery, skill and MCP surfacing, stdio handshake, and three observed placeholder-expansion conformance gaps) — and refresh the install surface wording. diff --git a/docs/assets/agent-plugins-cursor-proof/2026-09-02-customize-plugins-panel.png b/docs/assets/agent-plugins-cursor-proof/2026-09-02-customize-plugins-panel.png new file mode 100644 index 000000000..11da0b6d0 Binary files /dev/null and b/docs/assets/agent-plugins-cursor-proof/2026-09-02-customize-plugins-panel.png differ diff --git a/docs/assets/agent-plugins-cursor-proof/2026-09-02-customize-search-discovery.png b/docs/assets/agent-plugins-cursor-proof/2026-09-02-customize-search-discovery.png new file mode 100644 index 000000000..360382ab8 Binary files /dev/null and b/docs/assets/agent-plugins-cursor-proof/2026-09-02-customize-search-discovery.png differ diff --git a/docs/assets/agent-plugins-cursor-proof/2026-09-02-plugin-detail-mcp-skill.png b/docs/assets/agent-plugins-cursor-proof/2026-09-02-plugin-detail-mcp-skill.png new file mode 100644 index 000000000..ddf6f5481 Binary files /dev/null and b/docs/assets/agent-plugins-cursor-proof/2026-09-02-plugin-detail-mcp-skill.png differ diff --git a/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md b/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md new file mode 100644 index 000000000..7ab51c26e --- /dev/null +++ b/docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md @@ -0,0 +1,74 @@ +# Cursor IDE dogfood proof — portable Agent Plugins target + +Date: 2026-09-02. Observed client: Cursor 3.18.25 stable, commit +`280eca2911f1774689696e5f1efa5a4f97a87af3`, Linux x64 AppImage, run as an +isolated instance (dedicated `HOME`, dedicated `--user-data-dir`, Xvfb +display). Package under test: the `portable` target artifact of the +host-install fixture (`plugin.json` name `host-install-proof`, version +`1.0.0`), installed with the artifact's own emitted `install.mjs` into the +isolated home's `~/.cursor/plugins/local/host-install-proof`. + +This audit records what the real Cursor IDE plugin loader was observed doing. +It is separate from — and stronger on loader behavior than — the repeatable +`host-install` test proof, which validates installer execution, filesystem +shape, and pinned-schema conformance only and deliberately does not claim any +IDE observation. + +## What the IDE established + +1. **Discovery as an Agent Plugin.** After a restart the Customize page lists + the package as plugin "Host Install Proof" with a `Local` badge; searching + `host-install` returns the plugin and its skill. + ![Customize search discovery](../assets/agent-plugins-cursor-proof/2026-09-02-customize-search-discovery.png) +2. **Skill discovery.** The `probe` skill is listed with the exact + description string from the emitted `skills/probe/SKILL.md` frontmatter. +3. **MCP configuration discovery and launch attempt.** The plugin detail page + shows `MCPs 1 (probe)` and `Skills 1 (probe)`; Cursor did not merely parse + `mcp.json`, it spawned the configured stdio server. + ![Plugin detail](../assets/agent-plugins-cursor-proof/2026-09-02-plugin-detail-mcp-skill.png) +4. **Honest absence of unsupported surfaces.** The detail page renders only + MCPs and Skills sections for this plugin; no rules, commands, hooks, or + subagents are attributed to it, matching the portable capability table. + ![Plugins panel](../assets/agent-plugins-cursor-proof/2026-09-02-customize-plugins-panel.png) +5. **Successful stdio handshake.** With a launchable server configuration + (see gaps below) the IDE log records `Successfully connected to stdio + server` / `connection:connect_success` with a stable heartbeat + (`~/.config/Cursor/logs//mcp-server-plugin-host-install-proof-probe.log`). + +## Cursor 3.18.25 conformance gaps against Agent Plugins 1.0 + +All three were isolated by mutating only the installed copy's `mcp.json` +between IDE restarts and reading the per-server IDE logs. Spec citations are +to (1.0.0, repository commit +`ff8ab5e392cc87bd88d87c060815a87490e51003`). + +1. **`${PLUGIN_ROOT}` is not expanded in `cwd`.** §7.2.1 requires `args`, + `env`, and `cwd` to support `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` expansion, + and the specification's own stdio example uses `"cwd": "${PLUGIN_ROOT}"`. + Cursor passes the literal string as the working directory, so spawn fails + with a misleading `spawn node ENOENT` — even for an absolute `command` + with an existing binary. +2. **`${PLUGIN_ROOT}` is not expanded in `args`.** With `cwd` removed the + server process starts, but Node receives the literal + `${PLUGIN_ROOT}/mcp/.mjs` and exits with `MODULE_NOT_FOUND` + (the literal resolves against the home directory). +3. **Omitted `cwd` does not default to the plugin root.** §7.2.1: "When + `cwd` is omitted, clients MUST use the plugin root as the subprocess + working directory." Observed default was the user's home directory. + +Supplementary observation: replacing the spec placeholder with Cursor's +proprietary `${CURSOR_PLUGIN_ROOT}` in `args` produced a successful +connection with a stable heartbeat, confirming the loader has a working +expansion pipeline that is simply not wired to the standard's placeholder +names for this format. Bare `command` names (e.g. `node`) resolve normally +once `cwd` is valid. + +## Consequence for the portable adapter + +The portable emission (`cwd: "${PLUGIN_ROOT}"`, plugin-root-relative `args`) +is exactly what the specification prescribes and stays unchanged. Until +Cursor implements §7.2.1/§9.2 expansion for Agent Plugins, stdio MCP servers +from any spec-conformant portable package fail to launch on Cursor even +though the plugin, its skills, and its MCP configuration are all discovered +and surfaced correctly. This is tracked as adoption-issue evidence, not as an +adapter defect. diff --git a/docs/framework-mode.md b/docs/framework-mode.md index fb995957c..274797d04 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -117,6 +117,18 @@ and install through their public plugin CLIs; Cursor bundles use the documented `~/.cursor/plugins/local/` location because Cursor exposes marketplace management but no non-interactive plugin install verb. +The `portable` target emits the [Agent Plugins open standard](https://agent-plugins.org) +(specification 1.0.0), with schema hashes and the specification repository +revision pinned in `src/adapters/schemas/portable/PROVENANCE.json`. Cursor loads +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`. + The framework CLI performs those same operations: ```sh 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 6eaff3310..e0aa434e8 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 @@ -1,5 +1,10 @@ { "install": { + "evidence": [ + "2026-09-01: Cursor loads Agent Plugins natively from ~/.cursor/plugins/local/ (https://cursor.com/docs/plugins).", + "2026-09-01: Native launch clients are ChatGPT, Codex, Cursor, GitHub Copilot, Kiro, and VS Code (https://agent-plugins.org).", + "2026-09-01: Claude Code is not a native client; Agent Plugins installations there require CLI translation." + ], "reason": "Portable is a distribution profile, not a host runtime with one universal plugin installation location.", "source": "https://agent-plugins.org/", "state": "unavailable" @@ -15,6 +20,11 @@ }, "host": "portable", "mcp": { + "evidence": [ + "2026-09-02: Cursor 3.18.25 (stable, 280eca2911f1) discovers the emitted plugin, its skill, and its mcp.json from ~/.cursor/plugins/local and spawns the configured stdio server (docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md).", + "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." + ], "pathTokens": { "args": [ "${PLUGIN_DATA}", diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 2c62d784c..5507be5af 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -58,7 +58,7 @@ const schemaValidator = createAdapterValidator(); const validatePlugin = schemaValidator.compile(pluginSchema); const validateMcp = schemaValidator.compile(mcpSchema); const metadata = Object.freeze({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', observedVersion: capabilityTable.observedSpecificationVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.version), }); diff --git a/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json index 80db4cefc..8898fc4cb 100644 --- a/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/portable/PROVENANCE.json @@ -1,6 +1,6 @@ { "normativeTextWinsOnConflict": true, - "retrievedAt": "2026-08-14", + "retrievedAt": "2026-09-01", "schemas": { "mcp.schema.json": { "bytes": 3408, @@ -13,6 +13,11 @@ "url": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" } }, + "specRepository": { + "commit": "ff8ab5e392cc87bd88d87c060815a87490e51003", + "committedAt": "2026-08-19T16:34:23Z", + "url": "https://github.com/agentplugins/agent-plugins-spec" + }, "specification": "https://agent-plugins.org/specification", "version": "1.0.0" } diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 89ce954ec..ed89ce424 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -66,8 +66,10 @@ const portableInstructions = (): string[] => [ '## Portable Agent Plugin', '', 'Portable is a distribution profile, not a host runtime with one universal install location.', - 'This bundle follows Agent Plugins 1.0 and can be copied into a compatible host. Cursor supports', - 'that format directly, so the bundled installer provides a concrete local install path:', + 'This bundle follows the Agent Plugins open standard (Agent Plugins 1.0.0, https://agent-plugins.org).', + 'Cursor loads this format natively from `~/.cursor/plugins/local/`; restart Cursor or run', + '`Developer: Reload Window` after copying it. Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT', + 'are also native clients. The bundled installer provides the Cursor local copy:', '', '```sh', 'node ./install.mjs', diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 9645edc53..3f4b91e84 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -51,7 +51,7 @@ it('records exact immutable metadata for every built-in target', () => { const registry = createDefaultRegistry(); expect(registryMetadata(registry, 'portable')).toEqual({ - adapterRevision: '1.2.0', + adapterRevision: '1.3.0', observedVersion: '1.0.0', schemas: [ { @@ -214,6 +214,21 @@ it('records observed capability versions and rehashes schema snapshots against p type: 'string', }); } + if (target === 'portable') { + expect(capabilityTable.install).toMatchObject({ + evidence: [ + expect.stringContaining('Cursor loads Agent Plugins natively'), + expect.stringContaining('ChatGPT, Codex, Cursor, GitHub Copilot, Kiro, and VS Code'), + expect.stringContaining('Claude Code is not a native client'), + ], + state: 'unavailable', + }); + expect(provenance.specRepository).toEqual({ + commit: 'ff8ab5e392cc87bd88d87c060815a87490e51003', + committedAt: '2026-08-19T16:34:23Z', + url: 'https://github.com/agentplugins/agent-plugins-spec', + }); + } } }); 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 new file mode 100644 index 000000000..0337d77c4 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/agent-bundle.config.ts @@ -0,0 +1,17 @@ +export default { + hooks: { + sessionStart: { handler: './src/hooks/session-start.ts' }, + }, + mcp: { + servers: { + probe: {}, + }, + }, + plugin: { + description: 'Proves portable installer and filesystem/schema conformance.', + name: 'host-install-portable-proof', + version: '1.0.0', + }, + skills: ['skills/probe'], + targets: ['portable'], +}; diff --git a/packages/agent-bundle/tests/fixtures/host-install-portable/package.json b/packages/agent-bundle/tests/fixtures/host-install-portable/package.json new file mode 100644 index 000000000..6ccd2b41e --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/package.json @@ -0,0 +1,6 @@ +{ + "name": "host-install-portable-proof-fixture", + "private": true, + "type": "module", + "version": "1.0.0" +} diff --git a/packages/agent-bundle/tests/fixtures/host-install-portable/skills/probe/SKILL.md b/packages/agent-bundle/tests/fixtures/host-install-portable/skills/probe/SKILL.md new file mode 100644 index 000000000..ed018d6ef --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/skills/probe/SKILL.md @@ -0,0 +1,8 @@ +--- +name: probe +description: Confirm that the portable host-install proof fixture was discovered. +--- + +# Probe + +Report that the portable host-install proof fixture is available. diff --git a/packages/agent-bundle/tests/fixtures/host-install-portable/src/hooks/session-start.ts b/packages/agent-bundle/tests/fixtures/host-install-portable/src/hooks/session-start.ts new file mode 100644 index 000000000..e1ee8eb9f --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/src/hooks/session-start.ts @@ -0,0 +1,4 @@ +export default () => ({ + additionalContext: 'The portable host-install proof fixture is available.', + outcome: 'continue' as const, +}); diff --git a/packages/agent-bundle/tests/fixtures/host-install-portable/src/mcp/probe.ts b/packages/agent-bundle/tests/fixtures/host-install-portable/src/mcp/probe.ts new file mode 100644 index 000000000..cf0ae9644 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/host-install-portable/src/mcp/probe.ts @@ -0,0 +1,6 @@ +import { McpServer } from '@modelcontextprotocol/server'; + +export default () => new McpServer({ + name: 'host-install-portable-proof', + version: '1.0.0', +}); diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 00d5a5818..8386c3b4f 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -4,11 +4,14 @@ import { afterAll, beforeAll, expect, it } from '@rstest/core'; import { buildHostInstallFixture, + buildPortableHostInstallFixture, disposeHostInstallFixture, runClaudeHostInstallProof, runCodexHostInstallProof, runCursorHostInstallProof, + runPortableHostInstallProof, type BuiltHostInstallFixture, + type BuiltPortableHostInstallFixture, } from './support/host-install.ts'; import { HOST_INSTALL_PROOF_LEVEL, @@ -32,13 +35,20 @@ const claudePluginIt = claudeAvailable ? it : it.skip; const codexPluginIt = codexAvailable ? it : it.skip; let fixture: BuiltHostInstallFixture | undefined; +let portableFixture: BuiltPortableHostInstallFixture | undefined; beforeAll(async () => { - fixture = await buildHostInstallFixture({ environment: process.env }); + [fixture, portableFixture] = await Promise.all([ + buildHostInstallFixture({ environment: process.env }), + buildPortableHostInstallFixture({ environment: process.env }), + ]); }, 180_000); afterAll(async () => { - if (fixture !== undefined) await disposeHostInstallFixture(fixture); + await Promise.all([ + fixture === undefined ? Promise.resolve() : disposeHostInstallFixture(fixture), + portableFixture === undefined ? Promise.resolve() : disposeHostInstallFixture(portableFixture), + ]); }); const builtFixture = (): BuiltHostInstallFixture => { @@ -46,6 +56,13 @@ const builtFixture = (): BuiltHostInstallFixture => { return fixture; }; +const builtPortableFixture = (): BuiltPortableHostInstallFixture => { + if (portableFixture === undefined) { + throw new Error(`[${proofLabel}] portable fixture build did not complete.`); + } + return portableFixture; +}; + const expectHygienicReport = (report: unknown): void => { expect(JSON.stringify(report), proofLabel).not.toMatch( /(?:API_KEY|AUTH_TOKEN|ACCESS_TOKEN|authorization|credential|password|secret|sk-[A-Za-z0-9_-]{16,}|\/home\/|\/Users\/|\/tmp\/|stdout|stderr)/iu, @@ -155,3 +172,41 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' }); expectHygienicReport(report); }, 180_000); + +it( + 'installs the emitted Agent Plugins 1.0.0 package into an isolated Cursor home and validates it against the pinned spec schemas (filesystem/schema conformance; not an IDE-loading proof)', + async () => { + const report = await runPortableHostInstallProof( + builtPortableFixture(), + { environment: process.env }, + ); + + expect(report, proofLabel).toEqual({ + destination: '.cursor/plugins/local/host-install-portable-proof', + documents: { + mcp: 'schema-valid', + plugin: 'schema-valid', + }, + hooks: 'not-emitted', + host: 'cursor', + install: { first: 'installed', second: 'already-installed', version: '1.0.0' }, + pluginVariables: { + allowedLocations: 'args/env values/cwd only', + locations: [ + 'mcp.json#/mcpServers/probe/cwd', + 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', + ], + reservedEnvKeys: 'absent', + resolvedAtInstall: false, + sessionEvidence: 'unavailable: Cursor loads Agent Plugins only at restart or window reload; no non-interactive plugin-loading session surface', + }, + proofLevel: 'host-install (emitted install.mjs + isolated Cursor home filesystem + pinned Agent Plugins 1.0.0 schemas; NOT IDE plugin-loader evidence)', + proofScope: 'installer+filesystem+pinned-schema conformance against an isolated Cursor home; IDE plugin-loader behavior not observed by this test', + skill: '.cursor/plugins/local/host-install-portable-proof/skills/probe/SKILL.md', + specVersion: '1.0.0', + status: 'passed', + }); + expectHygienicReport(report); + }, + 180_000, +); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 1a71a241b..27fec432a 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -86,6 +86,17 @@ it('emits a standalone safe-copy installer only for Cursor-compatible fallback p } }); +it('documents native Agent Plugins clients for the portable profile', () => { + const install = writesFor('portable').get('INSTALL.md'); + + expect(install).toContain( + 'the Agent Plugins open standard (Agent Plugins 1.0.0, https://agent-plugins.org)', + ); + expect(install).toContain('`~/.cursor/plugins/local/`'); + expect(install).toContain('Developer: Reload Window'); + expect(install).toContain('Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT'); +}); + it('documents every real host path from the composite profile', () => { const install = writesFor('plugin').get('INSTALL.md'); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index f3f229ace..cf34b8289 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -1,16 +1,19 @@ import { execFile as executeFile } from 'node:child_process'; -import { access, cp, mkdir, mkdtemp, readFile, realpath, rm, symlink } from 'node:fs/promises'; +import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; +import portableMcpSchema from '../../src/adapters/schemas/portable/mcp.schema.json' with { type: 'json' }; +import portablePluginSchema from '../../src/adapters/schemas/portable/plugin.schema.json' with { type: 'json' }; import { cursorHooksValidator, cursorMcpValidator, cursorPluginValidator, } from '../../src/adapters/cursor.ts'; +import { createAdapterValidator } from '../../src/adapters/types.ts'; import { isInsideOrEqual } from '../../src/core/paths.ts'; import { validateCodexOpenaiYaml } from '../../src/schemas/skill-hosts/contract.ts'; import { @@ -29,11 +32,23 @@ const fixturesRoot = join(packageRoot, 'tests', 'fixtures'); const cli = join(packageRoot, 'dist', 'cli.js'); const plugin = 'host-install-proof'; const marketplace = 'host-install-proof-marketplace'; +const portablePlugin = 'host-install-portable-proof'; const tokenPlugin = 'host-install-token-proof'; const version = '1.0.0'; const proofLevel = proofLevelLabel(HOST_INSTALL_PROOF_LEVEL); +const portableProofLevel = + 'host-install (emitted install.mjs + isolated Cursor home filesystem + pinned Agent Plugins 1.0.0 schemas; NOT IDE plugin-loader evidence)'; const cursorPluginRootVariable = '${CURSOR_PLUGIN_ROOT}'; +const portablePluginDataVariable = '${PLUGIN_DATA}'; +const portablePluginRootVariable = '${PLUGIN_ROOT}'; +const portableMcpSchemaIdentifier = + 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; +const portablePluginSchemaIdentifier = + 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; const skillSidecarPath = join('skills', 'probe', 'agents', 'openai.yaml'); +const portableSchemaValidator = createAdapterValidator(); +const portableMcpValidator = portableSchemaValidator.compile(portableMcpSchema); +const portablePluginValidator = portableSchemaValidator.compile(portablePluginSchema); /** Opt-in for the one billable `claude -p` invocation the session proof makes. */ export const CLAUDE_SESSION_OPT_IN = 'AGENT_BUNDLE_HOST_INSTALL_CLAUDE_SESSION'; @@ -94,6 +109,10 @@ export interface BuiltHostInstallTokenFixture extends BuiltFixtureProject { readonly loweredSkillMarkdown: string; } +export interface BuiltPortableHostInstallFixture extends BuiltFixtureProject { + readonly portableBundle: string; +} + export interface HostInstallCommand { readonly cwd?: string; readonly executable: string; @@ -174,6 +193,33 @@ export interface CursorHostInstallReport { readonly status: 'passed'; } +export interface PortableHostInstallReport { + readonly destination: string; + readonly documents: { + readonly mcp: 'schema-valid'; + readonly plugin: 'schema-valid'; + }; + readonly hooks: 'not-emitted'; + readonly host: 'cursor'; + readonly install: { + readonly first: 'installed'; + readonly second: 'already-installed'; + readonly version: '1.0.0'; + }; + readonly pluginVariables: { + readonly allowedLocations: 'args/env values/cwd only'; + readonly locations: readonly string[]; + readonly reservedEnvKeys: 'absent'; + readonly resolvedAtInstall: false; + readonly sessionEvidence: 'unavailable: Cursor loads Agent Plugins only at restart or window reload; no non-interactive plugin-loading session surface'; + }; + readonly proofLevel: string; + readonly proofScope: 'installer+filesystem+pinned-schema conformance against an isolated Cursor home; IDE plugin-loader behavior not observed by this test'; + readonly skill: string; + readonly specVersion: '1.0.0'; + readonly status: 'passed'; +} + export interface ClaudeTokenSessionReport { readonly claudeVersion: string; readonly host: 'claude'; @@ -421,6 +467,20 @@ export const buildHostInstallTokenFixture = async (options: { }); }; +export const buildPortableHostInstallFixture = async (options: { + readonly environment: Readonly; +}): Promise => { + const built = await buildFixtureProject({ + bundleNames: ['portable'], + environment: options.environment, + fixture: 'host-install-portable', + }); + return Object.freeze({ + ...built, + portableBundle: join(built.artifactRoot, 'portable'), + }); +}; + export const disposeHostInstallFixture = async (fixture: BuiltFixtureProject): Promise => { await rm(fixture.root, { force: true, recursive: true }); }; @@ -738,6 +798,190 @@ export const runCursorHostInstallProof = async ( } }; +export const runPortableHostInstallProof = async ( + fixture: BuiltPortableHostInstallFixture, + options: HostInstallProofOptions, +): Promise => { + const home = await mkdtemp(join(tmpdir(), 'agent-bundle-host-install-portable-')); + try { + await mkdir(join(home, '.cursor'), { recursive: true }); + const environment = isolatedEnvironment(options.environment, { HOME: home }); + const installer = join(fixture.portableBundle, 'install.mjs'); + const install = async (state: 'Already installed' | 'Installed'): Promise => { + const result = await run(process.execPath, [installer], { + cwd: fixture.portableBundle, + environment, + }); + assertProof( + result.exitCode === 0, + `Portable emitted installer failed: ${commandDetail(result)}`, + ); + assertProof( + result.stdout.trim().startsWith(`${state} ${portablePlugin}@${version} at `), + `Portable emitted installer did not report ${state.toLowerCase()}.`, + ); + }; + + await install('Installed'); + const destination = join(home, '.cursor', 'plugins', 'local', portablePlugin); + const pluginDocument = await readJson( + join(destination, 'plugin.json'), + 'Portable installed plugin manifest', + ); + assertProof( + portablePluginValidator(pluginDocument), + `Portable plugin manifest failed its pinned schema: ${JSON.stringify(portablePluginValidator.errors)}`, + ); + const mcpDocument = await readJson( + join(destination, 'mcp.json'), + 'Portable installed MCP document', + ); + assertProof( + portableMcpValidator(mcpDocument), + `Portable MCP document failed its pinned schema: ${JSON.stringify(portableMcpValidator.errors)}`, + ); + + const pluginManifest = record(pluginDocument); + const mcpManifest = record(mcpDocument); + assertProof(pluginManifest !== undefined, 'Portable plugin manifest was not a JSON object.'); + assertProof(mcpManifest !== undefined, 'Portable MCP document was not a JSON object.'); + assertProof( + pluginManifest.$schema === portablePluginSchemaIdentifier, + 'Portable plugin manifest did not declare the canonical Agent Plugins 1.0.0 schema.', + ); + assertProof( + mcpManifest.$schema === portableMcpSchemaIdentifier, + 'Portable MCP document did not declare the canonical Agent Plugins 1.0.0 schema.', + ); + const schemaVersion = (identifier: string): string | undefined => + /^https:\/\/agent-plugins\.org\/schemas\/([^/]+)\//u.exec(identifier)?.[1]; + assertProof( + schemaVersion(pluginManifest.$schema) === schemaVersion(mcpManifest.$schema), + 'Portable plugin and MCP documents declared different Agent Plugins versions.', + ); + assertProof( + schemaVersion(pluginManifest.$schema) === version, + `Portable documents did not declare Agent Plugins ${version}.`, + ); + assertProof( + pluginManifest.name === portablePlugin && pluginManifest.version === version, + 'Portable plugin manifest did not carry the fixture identity.', + ); + + const mcpServers = record(mcpManifest.mcpServers); + assertProof(mcpServers !== undefined, 'Portable MCP document had no server map.'); + for (const [serverName, serverValue] of Object.entries(mcpServers)) { + const server = record(serverValue); + assertProof(server !== undefined, `Portable MCP server ${serverName} was not an object.`); + if (server.type !== 'stdio') continue; + const serverEnvironment = record(server.env); + if (serverEnvironment === undefined) continue; + assertProof( + !Object.keys(serverEnvironment).some((key) => key === 'PLUGIN_ROOT' || key === 'PLUGIN_DATA'), + `Portable MCP server ${serverName} used an Agent Plugins reserved environment key.`, + ); + } + + const escapePointerSegment = (segment: string): string => + segment.replaceAll('~', '~0').replaceAll('/', '~1'); + const placeholderLocations: string[] = []; + const visit = (value: unknown, pointer: string, documentName: string): void => { + if (typeof value === 'string') { + if ( + value.includes(portablePluginRootVariable) + || value.includes(portablePluginDataVariable) + ) { + assertProof( + /^\/mcpServers\/[^/]+\/(?:args\/\d+|cwd|env\/[^/]+)$/u.test(pointer), + `Portable placeholder occurred outside args, environment values, or cwd at ${documentName}#${pointer}.`, + ); + placeholderLocations.push(`${documentName}#${pointer}`); + } + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => visit(entry, `${pointer}/${String(index)}`, documentName)); + return; + } + const valueRecord = record(value); + if (valueRecord === undefined) return; + for (const [key, entry] of Object.entries(valueRecord)) { + visit(entry, `${pointer}/${escapePointerSegment(key)}`, documentName); + } + }; + visit(pluginDocument, '', 'plugin.json'); + visit(mcpDocument, '', 'mcp.json'); + assertProof( + placeholderLocations.length > 0, + 'Portable installed documents carried no Agent Plugins path placeholders.', + ); + + const skillsRoot = join(destination, 'skills'); + const skillDirectories = (await readdir(skillsRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .sort((left, right) => left.name.localeCompare(right.name)); + assertProof(skillDirectories.length > 0, 'Portable install had no immediate Skill directories.'); + const skillPaths: string[] = []; + for (const directory of skillDirectories) { + const skillPath = join(skillsRoot, directory.name, 'SKILL.md'); + const metadata = await lstat(skillPath).catch(() => + fail(`Portable Skill directory ${directory.name} had no SKILL.md.`)); + assertProof( + metadata.isFile() && !metadata.isSymbolicLink(), + `Portable Skill directory ${directory.name} did not contain a regular SKILL.md.`, + ); + skillPaths.push(normalizedRelative(home, skillPath)); + } + assertProof( + skillPaths.length === 1, + 'Portable proof fixture did not install exactly one discovered Skill.', + ); + + for (const hookPath of [join(destination, 'hooks'), join(destination, 'hooks.json')]) { + const hookMetadata = await lstat(hookPath).catch((error: unknown) => { + const code = record(error)?.code; + if (code === 'ENOENT') return undefined; + throw error; + }); + assertProof( + hookMetadata === undefined, + 'Portable artifact emitted a hooks surface that Agent Plugins 1.0.0 does not define.', + ); + } + + await install('Already installed'); + + return Object.freeze({ + destination: normalizedRelative(home, destination), + documents: Object.freeze({ + mcp: 'schema-valid', + plugin: 'schema-valid', + }), + hooks: 'not-emitted', + host: 'cursor', + install: Object.freeze({ + first: 'installed', + second: 'already-installed', + version, + }), + pluginVariables: Object.freeze({ + allowedLocations: 'args/env values/cwd only', + locations: Object.freeze(placeholderLocations), + reservedEnvKeys: 'absent', + resolvedAtInstall: false, + sessionEvidence: 'unavailable: Cursor loads Agent Plugins only at restart or window reload; no non-interactive plugin-loading session surface', + }), + proofLevel: portableProofLevel, + proofScope: 'installer+filesystem+pinned-schema conformance against an isolated Cursor home; IDE plugin-loader behavior not observed by this test', + skill: skillPaths[0], + specVersion: version, + status: 'passed', + }); + } finally { + await rm(home, { force: true, recursive: true }); + } +}; + interface SessionMarkers { readonly arguments?: string; readonly pluginRoot?: string;