From 485880ce5acdae1710b8eb69e561e3576a19e081 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 23:42:54 +0000 Subject: [PATCH 1/4] feat(install): expand Agent Plugins placeholders for the Cursor copy at install time; doctor AB7325 proves it (#426) --- .../426-cursor-agent-plugins-expansion.md | 5 + ...26-09-03-agent-plugins-cursor-ide-proof.md | 34 +++ docs/diagnostics.md | 25 +- docs/framework-mode.md | 20 +- packages/agent-bundle/README.md | 11 + .../adapters/capabilities/portable-1.0.0.json | 15 +- .../portable-plugin-validation.ts | 42 ++- .../install/cursor-agent-plugins-launch.ts | 267 ++++++++++++++++++ packages/agent-bundle/src/install/doctor.ts | 31 +- packages/agent-bundle/src/install/receipt.ts | 38 +++ packages/agent-bundle/src/install/surface.ts | 97 ++++++- packages/agent-bundle/tests/doctor.test.ts | 127 +++++++++ .../tests/host-install-proof.test.ts | 8 +- .../tests/install-surface.test.ts | 148 +++++++++- .../tests/support/host-install.ts | 96 ++++++- .../en/guide/distribution/installation.mdx | 18 +- website/docs/en/reference/cli.mdx | 4 +- .../zh/guide/distribution/installation.mdx | 11 + website/docs/zh/reference/cli.mdx | 3 +- 19 files changed, 963 insertions(+), 37 deletions(-) create mode 100644 .changeset/426-cursor-agent-plugins-expansion.md create mode 100644 packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts diff --git a/.changeset/426-cursor-agent-plugins-expansion.md b/.changeset/426-cursor-agent-plugins-expansion.md new file mode 100644 index 000000000..063118af0 --- /dev/null +++ b/.changeset/426-cursor-agent-plugins-expansion.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Expand Agent Plugins placeholders for Cursor at install time. The `install.mjs` emitted with a `portable` bundle now rewrites `mcp.json` in the `~/.cursor/plugins/local/` copy — `${PLUGIN_ROOT}` to the absolute plugin root, `${PLUGIN_DATA}` to `~/.cursor/agent-bundle/plugin-data/` (created), an omitted `cwd` to the plugin root, plugin-relative `./` commands to absolute paths, and `PLUGIN_ROOT`/`PLUGIN_DATA` into every stdio server's `env` — because Cursor 3.18.25 performs none of that resolution and every spec-shaped stdio server failed to spawn there. The bundle itself is untouched, the pre-expansion document is recorded in the install receipt (`cursorExpansion`), reruns stay idempotent and older unexpanded copies are replaced on the next run. `agent-bundle doctor --host cursor` validates the Agent Plugins contract (`AB7320`) against the recorded document and adds `AB7325` (`expanded` / `unexpanded` / `drifted`) for the launch proof; `cursor`-target bundles are never rewritten. (#469) diff --git a/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md b/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md index 6c09be01d..490900ddc 100644 --- a/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md +++ b/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md @@ -152,3 +152,37 @@ correct; only the §7.2.1/§9 path contract is missing. time-gated on a Cursor release; the harness above (`/tmp/w426/iso-setup.sh` shape: five probes + the emitted pack, reload, read the three log surfaces) is the procedure to repeat. + +## 5. Framework-side support landed after this run (install-time expansion) + +Recorded 2026-09-03 (later the same day; the observations above are unchanged). +The maintainer's direction is that a host-side gap the framework can absorb +without contacting the vendor is absorbed. Every failing item in §3 is a path +Cursor hands to `spawn` verbatim, and the control run proved that verbatim +absolute paths connect. The emitted portable `install.mjs` therefore performs +the §7.2.1/§9 resolution itself, for the Cursor copy only: + +- Detection: root `plugin.json` with an `https://agent-plugins.org/schemas/` + `$schema` and no `.cursor-plugin/plugin.json`. A `cursor`-target bundle is + never rewritten. +- Rewrite of `mcp.json` in `~/.cursor/plugins/local/`, every `stdio` + server: `${PLUGIN_ROOT}` → the absolute plugin root; `${PLUGIN_DATA}` → + `~/.cursor/agent-bundle/plugin-data/` (created before install, spec + §9.1); omitted `cwd` → the plugin root; `./` in `command` or `cwd` → + resolved against the plugin root; `PLUGIN_ROOT` / `PLUGIN_DATA` added to + `env`. Remote servers and skills-only packs are copied byte-identically. +- The bundle stays as §4 says (spec shape, portable). The artifact inventory + hashes the expanded form so reruns are `Already installed`, same-version + rebuilds are `Replaced`, and an older unexpanded receipt-managed copy is + replaced on the next run. The receipt gains `cursorExpansion` + `{ pluginRoot, pluginData, documents: { "mcp.json": } }`. +- `agent-bundle doctor --host cursor` validates the Agent Plugins contract + (`AB7320`) against the shipped document from the receipt and proves the + expansion (`AB7325`: `expanded`, `unexpanded` for a copy still in spec + shape, `drifted` for a moved/duplicated/edited copy). +- Provenance is `derived` everywhere it is reported + (`portable-1.0.0.json` `plugin.cursorLocalInstall.placeholderExpansion`); + the `mcp.pathTokens` rows keep describing the bundle. The §4 re-run + procedure is unchanged: when a Cursor build expands the placeholders itself, + the installer's rewrite becomes redundant, not wrong, and the capability row + can then cite native expansion. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index ec3a91a17..11fc20311 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -36,7 +36,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7325` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), and host load refusal (`AB7325`; see below). | +| `AB7300`–`AB7326` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), and the Cursor Agent Plugins launch proof (`AB7326`; see below). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | @@ -878,3 +878,26 @@ The JSON report exposes the same facts: `hosts[].inventory.findings[].errors`, `hosts[].bundle.errors`, and `hosts[].bundle.comparison.errors`. The text report prints the comparison as `installed copy: load failed (installed , refused by the host: )`. + +## Read-only Doctor Cursor Agent Plugins launch proof (`AB7325`) + +Cursor 3.18.25 loads Agent Plugins 1.0.0 packages from +`~/.cursor/plugins/local/` but spawns their stdio servers without +expanding `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` in `args`, `env` values, or +`cwd`, without providing the reserved `PLUGIN_ROOT` / `PLUGIN_DATA` variables +(spec §9.1), with an omitted `cwd` defaulting to the home directory, and with +plugin-relative `./` commands resolved against the workspace folder (spec +§7.2.1); see `docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md`. The +emitted portable `install.mjs` therefore rewrites `mcp.json` in the Cursor copy +only — absolute plugin root, `~/.cursor/agent-bundle/plugin-data/` +(created) for the data directory, plugin-root `cwd`, resolved `./` command, and +`PLUGIN_ROOT` / `PLUGIN_DATA` in every stdio server's environment — and records +the substituted values plus the pre-expansion document in the install receipt +(`cursorExpansion`). Doctor validates the Agent Plugins contract (`AB7320`) +against that recorded document and proves the expansion against the installed +bytes. Provenance is always `derived`: nothing here claims Cursor expands the +placeholders itself. + +| Code | Severity | Meaning | Recovery | +| --- | --- | --- | --- | +| `AB7325` | info / warning / error | Info (`launch.state = expanded`): the receipt's expansion still describes the installed copy — same plugin root, existing data directory, no placeholder left, absolute `cwd` and plugin-root `command`/`args` paths that exist, `PLUGIN_ROOT` / `PLUGIN_DATA` equal to the recorded values. Warning (`unexpanded`): an Agent Plugins install without a recorded expansion whose stdio servers still rely on the spec forms Cursor does not resolve (the message lists the forms per server); Cursor reports `spawn … ENOENT` / `MODULE_NOT_FOUND` for them. Error (`drifted`, entry `corrupt`): the recorded expansion names another plugin root (the copy was moved or duplicated), the data directory or an expanded path no longer exists, or the environment no longer carries the recorded values. Packages without stdio servers, and copies already carrying absolute paths with the §9.1 variables, produce no finding. | Reinstall with the bundle's emitted `install.mjs` at the copy's current location; the Cursor-target (`.cursor-plugin/plugin.json`) bundle is never rewritten and is not subject to this check. | diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 101d74682..83cebcd63 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -493,8 +493,19 @@ plus the `AB6038` provenance note), and by `doctor` for installed Cursor local plugins that declare the standard's `$schema` (`AB7320`; see `docs/diagnostics.md`). A dogfood proof against the real Cursor IDE plugin loader (discovery, skill listing, MCP -launch, and three observed Cursor 3.18.25 placeholder-expansion conformance -gaps) is recorded in `docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md`. +launch, and the observed Cursor 3.18.25 placeholder-expansion conformance +gaps) is recorded in `docs/audits/2026-09-02-agent-plugins-cursor-ide-proof.md` +and `docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md`. Because Cursor +expands none of `${PLUGIN_ROOT}` / `${PLUGIN_DATA}`, provides no §9.1 +variables, defaults an omitted `cwd` to the home directory and resolves `./` +commands against the workspace, the emitted portable `install.mjs` performs +that expansion itself in the `~/.cursor/plugins/local/` copy of +`mcp.json` (absolute plugin root, `~/.cursor/agent-bundle/plugin-data/` +as `PLUGIN_DATA`, plugin-root `cwd`, resolved `./` command, `PLUGIN_ROOT` / +`PLUGIN_DATA` in each stdio server's `env`), keeps the shipped document in the +install receipt (`cursorExpansion`), and `doctor` proves the expansion with +`AB7325` while validating the Agent Plugins contract against the shipped +document. The bundle stays spec-conformant; the provenance is `derived`. The framework CLI performs those same operations: @@ -550,4 +561,7 @@ plugin's manifest hook registration as `registered`, `stale` (a `~/.cursor/hooks.json` also points into a plugin (duplicate delivery) or is unparsable; `AB7324` reports a staged marketplace as imported or still awaiting the Customize step, and `doctor --from` resolves a marketplace-mode -bundle to that staged copy instead of reporting it missing. +bundle to that staged copy instead of reporting it missing. For Agent Plugins +installs, `AB7325` reports the installer's placeholder expansion as +`expanded`, `unexpanded` (spec forms Cursor cannot launch), or `drifted` +(moved, duplicated, or edited after the expansion was recorded). diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 70785152a..6d3288174 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -195,6 +195,17 @@ Cursor manage the plugin as a marketplace install; `agent-bundle doctor --host cursor` reports hook registration (`AB7322`), duplicate user-level delivery (`AB7323`), and marketplace import state (`AB7324`). +The `portable` target's `install.mjs` copies the Agent Plugins package to the +same `~/.cursor/plugins/local/` location and, because Cursor 3.18.25 +expands none of the standard's placeholders (`${PLUGIN_ROOT}` / +`${PLUGIN_DATA}` in `args`, `env`, `cwd`; no `PLUGIN_ROOT` / `PLUGIN_DATA` +variables; omitted `cwd` → home directory; `./` commands → workspace folder), +rewrites `mcp.json` in that copy only with absolute paths +(`PLUGIN_DATA` = `~/.cursor/agent-bundle/plugin-data/`, created), keeps +the shipped document in the install receipt (`cursorExpansion`), and +`agent-bundle doctor --host cursor` proves the expansion (`AB7325`). The bundle +itself stays spec-conformant for other Agent Plugins clients. + Cursor installation is user-scoped. Claude also accepts `--scope project` and `--scope local`; Codex is user-scoped. A source-free artifact root is accepted by `--from` when it contains the selected host target directory. 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 3f3a03860..cab2f9919 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 @@ -76,7 +76,8 @@ "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.", - "2026-09-03: Re-verified on the same Cursor 3.18.25 build (realCommit 280eca2911f1…af3; no newer stable release): the cwd, args, and default-cwd gaps reproduce, and single-variable probes additionally show ${PLUGIN_ROOT}/${PLUGIN_DATA} unexpanded in env values (spec 9.2), the reserved PLUGIN_ROOT/PLUGIN_DATA subprocess variables absent (spec 9.1), and plugin-relative ./ commands resolved against the workspace folder (spec 7.2.1); recorded with log excerpts and captures in docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md; not submitted to Cursor by maintainer decision." + "2026-09-03: Re-verified on the same Cursor 3.18.25 build (realCommit 280eca2911f1…af3; no newer stable release): the cwd, args, and default-cwd gaps reproduce, and single-variable probes additionally show ${PLUGIN_ROOT}/${PLUGIN_DATA} unexpanded in env values (spec 9.2), the reserved PLUGIN_ROOT/PLUGIN_DATA subprocess variables absent (spec 9.1), and plugin-relative ./ commands resolved against the workspace folder (spec 7.2.1); recorded with log excerpts and captures in docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md; not submitted to Cursor by maintainer decision.", + "2026-09-03: framework-side support for the Cursor gap (#426): the emitted install.mjs detects an Agent Plugins pack (root plugin.json with an agent-plugins.org $schema, no .cursor-plugin/plugin.json) and rewrites mcp.json in the ~/.cursor/plugins/local/ copy only — ${PLUGIN_ROOT} → the absolute plugin root, ${PLUGIN_DATA} → ~/.cursor/agent-bundle/plugin-data/ (created by the installer), an omitted cwd → the plugin root, ./ commands → absolute paths under it, and PLUGIN_ROOT/PLUGIN_DATA added to every stdio server's env — because Cursor's spawn honours absolute command, args, cwd and env verbatim (the control run that connected). The bundle stays spec-conformant; the pre-expansion mcp.json is kept in the install receipt (cursorExpansion) and Doctor validates it against the Agent Plugins contract while AB7325 proves the expanded paths (expanded / unexpanded / drifted). Provenance: derived — Cursor still expands nothing itself, so the pathTokens rows above describe the bundle, not what Cursor resolves." ], "legacySse": { "reason": "2026-09-02: Agent Plugins 1.0.0 §7.2.1 defines the deprecated HTTP+SSE variant as OPTIONAL for clients; the compiler emits only the required modern transports and rejects a legacy transport with AB4339 (https://agent-plugins.org/specification).", @@ -151,6 +152,18 @@ }, "observedSpecificationVersion": "1.0.0", "plugin": { + "cursorLocalInstall": { + "placeholderExpansion": { + "evidence": [ + "2026-09-02/03: Cursor 3.18.25 (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md) fails every spec-shaped stdio launch (cwd \"${PLUGIN_ROOT}\" → spawn node ENOENT; args \"${PLUGIN_ROOT}/…\" → MODULE_NOT_FOUND under $HOME; omitted cwd → $HOME; env values and the §9.1 variables unexpanded/absent; \"./…\" commands resolved against the workspace) while the same server with absolute paths connects (connect_success, stable heartbeat).", + "2026-09-03: the emitted install.mjs performs that expansion for the Cursor copy at install time and records it in the receipt; tests/install-surface.test.ts (bundle untouched, copy expanded, idempotent rerun, same-version re-expansion, older unexpanded copies repaired, skills-only and Cursor Plugin bundles untouched), tests/doctor.test.ts (AB7325 unexpanded → expanded → drifted → moved) and the portable host-install proof (isolated Cursor home, Doctor AB7325 info) pin it." + ], + "installer": "install.mjs (emitted with the portable bundle)", + "provenance": "derived", + "reason": "Cursor 3.18.25 expands no Agent Plugins placeholder, provides no PLUGIN_ROOT/PLUGIN_DATA, defaults an omitted cwd to the home directory and resolves ./ commands against the workspace (spec §7.2.1/§9); the emitted installer rewrites mcp.json in the ~/.cursor/plugins/local/ copy with absolute paths (PLUGIN_DATA = ~/.cursor/agent-bundle/plugin-data/, created), keeps the shipped document in the receipt, and Doctor proves the expanded paths (AB7325). Never claimed as Cursor behaviour: the bundle itself still relies on the consuming client.", + "state": "supported" + } + }, "extensionDirectories": { "reason": "2026-09-02: Agent Plugins 1.0.0 §8.2 reserves top-level reverse-domain directories for client-owned files; the compiler emits no client extension directory because no pinned client publishes a file-based namespace contract for this format (https://agent-plugins.org/specification).", "state": "unavailable" diff --git a/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts index dc9d000f1..b2abda065 100644 --- a/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts @@ -49,6 +49,14 @@ export interface PortablePluginValidationReport { } export interface ValidatePortablePluginFilesOptions { + /** + * Document text validated in place of the on-disk bytes, by plugin-relative + * path. Doctor passes the pre-expansion `mcp.json` an emitted `install.mjs` + * recorded for a Cursor copy, whose on-disk document carries the absolute + * paths Cursor needs and is Agent Plugins-conformant only in this form. + * The file must still exist as a regular file at the plugin root. + */ + readonly documents?: Readonly>>; readonly pluginDirectory: string; readonly target: string; } @@ -146,6 +154,7 @@ const pluginRelativeTarget = (pluginDirectory: string, value: string): string | const readDocuments = async ( pluginDirectory: string, target: string, + overrides: Readonly>>, ): Promise => { const pluginDirectory = resolve(options.pluginDirectory); const [documents, skills, symlinks] = await Promise.all([ - readDocuments(pluginDirectory, options.target), + readDocuments(pluginDirectory, options.target, options.documents ?? {}), skillDiagnostics(pluginDirectory, options.target), symlinkDiagnostics(pluginDirectory, options.target), ]); @@ -440,7 +454,11 @@ export const validatePortablePlugin = async ( ); const diagnostics = freezeDiagnostics([ transparency, - ...await validatePortablePluginFiles({ pluginDirectory, target: options.target }), + ...await validatePortablePluginFiles({ + ...(options.documents === undefined ? {} : { documents: options.documents }), + pluginDirectory, + target: options.target, + }), ]); return Object.freeze({ diagnostics, diff --git a/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts b/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts new file mode 100644 index 000000000..758fc3228 --- /dev/null +++ b/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts @@ -0,0 +1,267 @@ +import { readFile, stat } from 'node:fs/promises'; +import { isAbsolute, resolve, sep } from 'node:path'; + +import { portablePlaceholderPattern } from '../adapters/portable-mcp-rules.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { freezeDiagnostics } from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; +import { readInstallReceipt, type InstallReceiptCursorExpansion } from './receipt.ts'; + +/** + * Read-only Doctor proof that an Agent Plugins package installed under + * `~/.cursor/plugins/local` can launch its stdio servers on Cursor (#426). + * + * Observed on Cursor 3.18.25 (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md): + * the loader spawns an Agent Plugins stdio server without expanding + * `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` in `args`, `env` values or `cwd`, + * without providing the reserved `PLUGIN_ROOT` / `PLUGIN_DATA` variables + * (spec §9.1), with an omitted `cwd` defaulting to the home directory and + * with plugin-relative `./` commands resolved against the workspace folder + * (spec §7.2.1). The emitted `install.mjs` expands those forms itself in the + * Cursor copy of `mcp.json` and records what it substituted in the install + * receipt (`cursorExpansion`). Doctor reports `expanded` when the recorded + * expansion still describes the installed copy, `drifted` when it does not + * (moved, copied, or edited after install), and `unexpanded` when an Agent + * Plugins install still carries the spec forms Cursor cannot launch. + */ + +export type CursorAgentPluginsLaunchState = 'drifted' | 'expanded' | 'unexpanded'; + +export interface CursorAgentPluginsLaunch { + readonly pluginData?: string; + readonly pluginRoot?: string; + /** stdio servers examined, in document order. */ + readonly servers: readonly string[]; + readonly state: CursorAgentPluginsLaunchState; +} + +export interface CursorAgentPluginsLaunchInspection { + readonly diagnostics: readonly Diagnostic[]; + /** + * The pre-expansion `mcp.json` the installer recorded, for the Agent Plugins + * byte lane: the on-disk document is conformant only in that form. + */ + readonly documents?: Readonly<{ readonly 'mcp.json': string }>; + readonly launch?: CursorAgentPluginsLaunch; +} + +export interface InspectCursorAgentPluginsLaunchOptions { + /** Compare recorded and installed paths case-insensitively (Windows). */ + readonly caseInsensitivePaths?: boolean; +} + +const DIAGNOSTIC_CODE = 'AB7325'; +const REINSTALL_RECOVERY = + "Reinstall the package with its bundle's emitted `install.mjs`, which expands the Agent Plugins placeholders for the Cursor copy " + + '(absolute plugin root and data directory, plugin-root `cwd`, resolved `./` command, `PLUGIN_ROOT`/`PLUGIN_DATA` environment) and records them in the receipt.'; + +const finding = (message: string, recovery: string, severity: Diagnostic['severity']): Diagnostic => + Object.freeze({ code: DIAGNOSTIC_CODE, message, recovery, severity, target: 'cursor' }); + +const isRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + +type FileKind = 'directory' | 'file' | 'missing' | 'other'; + +const fileKind = async (path: string): Promise => { + try { + const metadata = await stat(path); + if (metadata.isDirectory()) return 'directory'; + if (metadata.isFile()) return 'file'; + return 'other'; + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'ELOOP')) return 'missing'; + throw error; + } +}; + +interface StdioServer { + readonly args: readonly unknown[]; + readonly command: unknown; + readonly cwd: unknown; + readonly env: Readonly>; + readonly name: string; +} + +const stdioServers = (document: unknown): readonly StdioServer[] => { + if (!isRecord(document) || !isRecord(document['mcpServers'])) return Object.freeze([]); + const servers: StdioServer[] = []; + for (const [name, server] of Object.entries(document['mcpServers'])) { + if (!isRecord(server) || server['type'] !== 'stdio') continue; + servers.push(Object.freeze({ + args: Array.isArray(server['args']) ? Object.freeze([...server['args']]) : Object.freeze([]), + command: server['command'], + cwd: server['cwd'], + env: isRecord(server['env']) ? server['env'] : Object.freeze({}), + name, + })); + } + return Object.freeze(servers); +}; + +const hasPlaceholder = (value: unknown): boolean => typeof value === 'string' && portablePlaceholderPattern.test(value); + +/** The spec forms an unexpanded server relies on, as Cursor 3.18.25 leaves them. */ +const unexpandedForms = (server: StdioServer): readonly string[] => { + const forms: string[] = []; + if (typeof server.command === 'string' && server.command.startsWith('./')) { + forms.push('plugin-relative `./` command resolved against the workspace folder instead of the plugin root (spec §7.2.1)'); + } + const placeholderFields: string[] = []; + if (hasPlaceholder(server.command)) placeholderFields.push('command'); + if (server.args.some(hasPlaceholder)) placeholderFields.push('args'); + if (Object.values(server.env).some(hasPlaceholder)) placeholderFields.push('env'); + if (hasPlaceholder(server.cwd)) placeholderFields.push('cwd'); + if (placeholderFields.length > 0) { + forms.push(`\${PLUGIN_ROOT}/\${PLUGIN_DATA} left unexpanded in ${placeholderFields.join(', ')} (spec §9.2)`); + } + if (server.cwd === undefined) { + forms.push('omitted `cwd` defaulted to the home directory instead of the plugin root (spec §7.2.1)'); + } else if (typeof server.cwd === 'string' && server.cwd.startsWith('./')) { + forms.push('plugin-relative `./` cwd not resolved against the plugin root (spec §7.2.1)'); + } + if (typeof server.env['PLUGIN_ROOT'] !== 'string' || typeof server.env['PLUGIN_DATA'] !== 'string') { + forms.push('reserved `PLUGIN_ROOT`/`PLUGIN_DATA` variables not provided to the subprocess (spec §9.1)'); + } + return Object.freeze(forms); +}; + +const samePath = (left: string, right: string, caseInsensitive: boolean): boolean => { + const normalize = (value: string): string => { + const resolved = resolve(value); + return caseInsensitive ? resolved.toLowerCase() : resolved; + }; + return normalize(left) === normalize(right); +}; + +const isUnder = (path: string, root: string, caseInsensitive: boolean): boolean => { + const normalize = (value: string): string => (caseInsensitive ? value.toLowerCase() : value); + const resolvedRoot = normalize(resolve(root)); + const resolvedPath = normalize(resolve(path)); + return resolvedPath === resolvedRoot || resolvedPath.startsWith(resolvedRoot.endsWith(sep) ? resolvedRoot : `${resolvedRoot}${sep}`); +}; + +/** Every way the installed copy no longer matches what the receipt says was expanded for it. */ +const driftIssues = async ( + pluginRoot: string, + expansion: InstallReceiptCursorExpansion, + servers: readonly StdioServer[], + caseInsensitive: boolean, +): Promise => { + const issues: string[] = []; + if (!samePath(expansion.pluginRoot, pluginRoot, caseInsensitive)) { + issues.push(`the receipt expanded PLUGIN_ROOT to ${JSON.stringify(expansion.pluginRoot)} but the package is installed at ${JSON.stringify(pluginRoot)}`); + } + if (!isAbsolute(expansion.pluginData)) { + issues.push(`the receipt records a relative PLUGIN_DATA ${JSON.stringify(expansion.pluginData)}`); + } else if ((await fileKind(expansion.pluginData)) !== 'directory') { + issues.push(`the PLUGIN_DATA directory ${JSON.stringify(expansion.pluginData)} does not exist`); + } + for (const server of servers) { + const at = (field: string): string => `mcpServers/${server.name}/${field}`; + if (hasPlaceholder(server.command) || server.args.some(hasPlaceholder) || Object.values(server.env).some(hasPlaceholder) || hasPlaceholder(server.cwd)) { + issues.push(`${at('')} still carries an Agent Plugins placeholder`); + } + if (typeof server.command === 'string') { + if (server.command.startsWith('./')) { + issues.push(`${at('command')} ${JSON.stringify(server.command)} was not resolved against the plugin root`); + } else if (isAbsolute(server.command) && (await fileKind(server.command)) !== 'file') { + issues.push(`${at('command')} ${JSON.stringify(server.command)} is not a regular file`); + } + } + if (typeof server.cwd !== 'string' || !isAbsolute(server.cwd)) { + issues.push(`${at('cwd')} is not an absolute directory`); + } else if ((await fileKind(server.cwd)) !== 'directory') { + issues.push(`${at('cwd')} ${JSON.stringify(server.cwd)} does not exist`); + } + for (const [index, argument] of server.args.entries()) { + if (typeof argument !== 'string' || !isAbsolute(argument) || !isUnder(argument, expansion.pluginRoot, caseInsensitive)) continue; + if ((await fileKind(argument)) === 'missing') { + issues.push(`${at(`args/${String(index)}`)} ${JSON.stringify(argument)} does not exist under the plugin root`); + } + } + if (server.env['PLUGIN_ROOT'] !== expansion.pluginRoot) { + issues.push(`${at('env/PLUGIN_ROOT')} does not equal the expanded plugin root`); + } + if (server.env['PLUGIN_DATA'] !== expansion.pluginData) { + issues.push(`${at('env/PLUGIN_DATA')} does not equal the expanded data directory`); + } + } + return Object.freeze(issues); +}; + +export const inspectCursorAgentPluginsLaunch = async ( + pluginRoot: string, + options: InspectCursorAgentPluginsLaunchOptions = {}, +): Promise => { + const caseInsensitive = options.caseInsensitivePaths === true; + let expansion: InstallReceiptCursorExpansion | undefined; + try { + expansion = (await readInstallReceipt(pluginRoot))?.cursorExpansion; + } catch { + // An unreadable or non-regular receipt is the install-comparison lane's finding; here it is simply absent. + expansion = undefined; + } + const original = expansion?.documents['mcp.json']; + const documents = original === undefined ? undefined : Object.freeze({ 'mcp.json': original }); + const mcpPath = resolve(pluginRoot, 'mcp.json'); + // The Agent Plugins byte lane owns a missing, non-regular, or unparsable document. + if ((await fileKind(mcpPath)) !== 'file') { + return Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); + } + let document: unknown; + try { + document = JSON.parse(await readFile(mcpPath, 'utf8')) as unknown; + } catch { + return Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); + } + const servers = stdioServers(document); + if (servers.length === 0) { + return Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); + } + const names = Object.freeze(servers.map((server) => server.name)); + if (expansion === undefined) { + // Without a recorded expansion, only servers that still rely on client-side resolution are a finding; + // a copy expanded by other means (absolute paths, cwd, §9.1 variables in place) launches as it is. + const unexpanded = servers + .map((server) => ({ forms: unexpandedForms(server), name: server.name })) + .filter((server) => server.forms.length > 0); + if (unexpanded.length === 0) return Object.freeze({ diagnostics: Object.freeze([]) }); + const detail = unexpanded.map((server) => `${server.name}: ${server.forms.join('; ')}`).join(' | '); + const unexpandedNames = unexpanded.map((server) => JSON.stringify(server.name)).join(', '); + return Object.freeze({ + diagnostics: freezeDiagnostics([finding( + `Cursor plugin entry ${JSON.stringify(pluginRoot)} is an Agent Plugins package whose stdio server${unexpanded.length === 1 ? '' : 's'} ` + + `${unexpandedNames} depend on client-side Agent Plugins 1.0.0 resolution that Cursor 3.18.25 does not perform ` + + `(observed 2026-09-03, docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md), so Cursor fails the spawn (\`spawn … ENOENT\` / MODULE_NOT_FOUND): ${detail}.`, + REINSTALL_RECOVERY, + 'warning', + )]), + launch: Object.freeze({ servers: names, state: 'unexpanded' }), + }); + } + const issues = await driftIssues(pluginRoot, expansion, servers, caseInsensitive); + const launchBase = Object.freeze({ pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot, servers: names }); + if (issues.length > 0) { + return Object.freeze({ + diagnostics: freezeDiagnostics([finding( + `Cursor plugin entry ${JSON.stringify(pluginRoot)} recorded an Agent Plugins placeholder expansion that no longer describes the installed copy: ${issues.join('; ')}.`, + `${REINSTALL_RECOVERY} A copy moved or edited after install must be reinstalled at its current location.`, + 'error', + )]), + ...(documents === undefined ? {} : { documents }), + launch: Object.freeze({ ...launchBase, state: 'drifted' }), + }); + } + return Object.freeze({ + diagnostics: freezeDiagnostics([finding( + `Cursor plugin entry ${JSON.stringify(pluginRoot)} is an Agent Plugins package whose stdio server${servers.length === 1 ? '' : 's'} ` + + `${names.map((name) => JSON.stringify(name)).join(', ')} were expanded for Cursor at install (provenance: derived; Cursor 3.18.25 expands no Agent Plugins placeholder itself): ` + + `PLUGIN_ROOT=${JSON.stringify(expansion.pluginRoot)}, PLUGIN_DATA=${JSON.stringify(expansion.pluginData)}; every expanded path resolves and the pre-expansion mcp.json is validated against the Agent Plugins 1.0.0 contract.`, + 'No action needed; reinstall with the emitted `install.mjs` after moving the copy.', + 'info', + )]), + ...(documents === undefined ? {} : { documents }), + launch: Object.freeze({ ...launchBase, state: 'expanded' }), + }); +}; diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index c2296913b..5a5aec805 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -40,6 +40,11 @@ import { type InstalledTreeOwnership, type TreeInventory, } from './receipt.ts'; +import { + inspectCursorAgentPluginsLaunch, + type CursorAgentPluginsLaunch, + type CursorAgentPluginsLaunchInspection, +} from './cursor-agent-plugins-launch.ts'; import { type CursorHooksRegistration, type CursorStagingGit, @@ -103,6 +108,8 @@ export interface DoctorFinding { readonly errors?: readonly string[]; /** Cursor plugin hook registration proof (`.cursor-plugin/plugin.json` installs only). */ readonly hooks?: CursorHooksRegistration; + /** Agent Plugins stdio launch proof for Cursor (root `plugin.json` installs with stdio servers only). */ + readonly launch?: CursorAgentPluginsLaunch; readonly marketplace?: string; readonly manifest?: string; readonly name?: string; @@ -587,6 +594,7 @@ const installedCursorStaticIssues = async ( installed: InstalledCursorManifest, path: string, installRoot: string, + launch: CursorAgentPluginsLaunchInspection | undefined, ): Promise => { if (installed.manifest === cursorManifestCandidates[0]) { return validateCursorPluginFiles({ containmentRoot: installRoot, pluginDirectory: path, target: 'cursor' }); @@ -597,8 +605,14 @@ const installedCursorStaticIssues = async ( target: 'cursor', }); if (!isAgentPluginsManifest(installed)) return symlinks; + // The Cursor copy of an expanded package is conformant only as the bundle shipped it (AB7325 proves the expansion). + const documents = launch?.documents; const [portable, containment] = await Promise.all([ - validatePortablePluginFiles({ pluginDirectory: path, target: 'portable' }), + validatePortablePluginFiles({ + ...(documents === undefined ? {} : { documents }), + pluginDirectory: path, + target: 'portable', + }), symlinks, ]); return Object.freeze([...portable, ...containment]); @@ -753,7 +767,10 @@ const cursorInventory = async ( )); continue; } - const staticIssues = await installedCursorStaticIssues(manifest, path, installRoot); + const launch = isAgentPluginsManifest(manifest) + ? await inspectCursorAgentPluginsLaunch(path, { caseInsensitivePaths: platform === 'win32' }) + : undefined; + const staticIssues = await installedCursorStaticIssues(manifest, path, installRoot, launch); const staticDiagnostics = staticValidationDiagnostics( 'AB7320', 'cursor', @@ -764,7 +781,8 @@ const cursorInventory = async ( diagnostics.push(diagnostic( 'AB7320', `Cursor plugin entry ${JSON.stringify(path)} is a root plugin.json declaring ${JSON.stringify(manifest.schema)}, ` + - 'which Cursor loads as an Agent Plugins package; Doctor validated it against the pinned Agent Plugins 1.0.0 contract.', + 'which Cursor loads as an Agent Plugins package; Doctor validated it against the pinned Agent Plugins 1.0.0 contract' + + (launch?.documents === undefined ? '.' : ' using the pre-expansion mcp.json its install receipt recorded.'), 'Rebuild the portable bundle from valid source bytes if the Agent Plugins contract reports errors.', 'info', 'cursor', @@ -781,6 +799,7 @@ const cursorInventory = async ( )); } diagnostics.push(...staticDiagnostics); + if (launch !== undefined) diagnostics.push(...launch.diagnostics); const durableState = await inspectDurableState(path, 'cursor'); if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); const hooks = manifest.manifest === cursorManifestCandidates[0] @@ -791,10 +810,14 @@ const cursorInventory = async ( ...(durableState === undefined ? {} : { durableState }), entry, ...(hooks === undefined ? {} : { hooks: hooks.registration }), + ...(launch?.launch === undefined ? {} : { launch: launch.launch }), manifest: manifest.manifest, name: manifest.name, path, - state: staticDiagnostics.some((entry) => entry.severity === 'error') ? 'corrupt' : 'installed', + // A drifted expansion means Cursor spawns paths that no longer exist: the install is corrupt, not merely stale. + state: staticDiagnostics.some((entry) => entry.severity === 'error') || launch?.launch?.state === 'drifted' + ? 'corrupt' + : 'installed', ...(manifest.version === undefined ? {} : { version: manifest.version }), }); } diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 341729d9d..71ec5bafa 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -53,8 +53,24 @@ export const isPreservedRuntimeRoot = (name: string): boolean => */ export const installSurfaceMarkerFiles: readonly string[] = Object.freeze(['INSTALL.md', 'install.mjs']); +/** + * Recorded by the emitted `install.mjs` when it installs an Agent Plugins + * pack into `~/.cursor/plugins/local`: Cursor 3.18.25 expands none of the + * Agent Plugins placeholders, so the installer rewrites `mcp.json` in the + * Cursor copy and keeps the pre-expansion document here for Doctor. + */ +export interface InstallReceiptCursorExpansion { + /** Pre-expansion document text by plugin-relative path (`mcp.json`). */ + readonly documents: Readonly>; + /** Absolute directory substituted for `${PLUGIN_DATA}` and exported as `PLUGIN_DATA`. */ + readonly pluginData: string; + /** Absolute plugin root substituted for `${PLUGIN_ROOT}` and exported as `PLUGIN_ROOT`. */ + readonly pluginRoot: string; +} + export interface InstallReceipt { readonly contentHash: string; + readonly cursorExpansion?: InstallReceiptCursorExpansion; /** * Directories the installer created (POSIX-relative, sorted). Only these * are ever pruned when they empty out; a directory that existed before the @@ -305,6 +321,26 @@ export const isReceiptPath = (value: unknown): value is string => const isReceiptFileList = (value: unknown): value is readonly string[] => Array.isArray(value) && value.every(isReceiptPath); +/** A malformed expansion record reads as absent; the receipt itself stays valid. */ +const readCursorExpansion = (value: unknown): InstallReceiptCursorExpansion | undefined => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const documents = record['documents']; + if ( + typeof record['pluginRoot'] !== 'string' || record['pluginRoot'].length === 0 || + typeof record['pluginData'] !== 'string' || record['pluginData'].length === 0 || + documents === null || typeof documents !== 'object' || Array.isArray(documents) || + !Object.entries(documents as Record).every(([path, text]) => isReceiptPath(path) && typeof text === 'string') + ) { + return undefined; + } + return Object.freeze({ + documents: Object.freeze({ ...(documents as Record) }), + pluginData: record['pluginData'], + pluginRoot: record['pluginRoot'], + }); +}; + /** * Reads the receipt at a plugin root; malformed or unsafe receipts read as * absent. A receipt that is not a regular file (a symbolic link, a FIFO, a @@ -335,8 +371,10 @@ export const readInstallReceipt = async (destination: string): Promise [ 'node ./install.mjs', '```', '', + '### Cursor placeholder expansion', + '', + 'Cursor 3.18.25 spawns the stdio servers of an Agent Plugins package without expanding', + '`${PLUGIN_ROOT}` / `${PLUGIN_DATA}` in `args`, `env` values, or `cwd`, without providing the', + 'reserved `PLUGIN_ROOT` / `PLUGIN_DATA` variables (spec §9.1), with an omitted `cwd` defaulting to', + 'the home directory, and with plugin-relative `./` commands resolved against the workspace folder', + '(spec §7.2.1). The installer therefore rewrites `mcp.json` in the Cursor copy only: the plugin root', + `becomes \`~/.cursor/plugins/local/\`, the data directory \`~/.cursor/agent-bundle/plugin-data/\``, + '(created by the installer), an omitted `cwd` becomes the plugin root, `./` commands resolve against', + 'it, and every stdio server gains `PLUGIN_ROOT` / `PLUGIN_DATA` in its environment. The bundle itself', + `stays spec-conformant; the pre-expansion document is kept in \`${installReceiptFile}\` (\`cursorExpansion\`),`, + 'and `agent-bundle doctor --host cursor` verifies the expanded paths (`AB7325`). Nothing is changed for', + 'other Agent Plugins clients, which expand the placeholders themselves.', + '', '### Reinstall after a same-version rebuild', '', `The installer records an install receipt (\`${installReceiptFile}\`) and replaces its owned files in`, @@ -211,6 +225,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "const marketplaceRoot = join(cursorRoot, 'agent-bundle', 'marketplaces');", 'const marketplaceRepo = join(marketplaceRoot, pluginName);', "const marketplacePlugin = join(marketplaceRepo, 'plugins', pluginName);", + "// Agent Plugins 1.0.0 §9.1 PLUGIN_DATA for the Cursor copy: a writable, install-independent directory the installer creates.", + "const pluginData = join(cursorRoot, 'agent-bundle', 'plugin-data', pluginName);", "const usage = 'Usage: node install.mjs [--mode local|marketplace] [--replace|--force] [--help]';", '', 'let replace = false;', @@ -258,8 +274,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " hash.update('\\0');", '};', '', - '// Deterministic tree walk: symlinks and special files refused, the root receipt skipped.', - 'const inventory = async (root) => {', + '// Deterministic tree walk: symlinks and special files refused, the root receipt skipped. `transform`', + '// maps a file\'s bytes to what the Cursor copy will hold (the Agent Plugins mcp.json expansion below), so', + '// the artifact hash describes the installed form and reruns compare like for like.', + 'const inventory = async (root, transform) => {', ' const rootMetadata = await lstat(root);', " if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) throw unsupported('.');", " const hash = createHash('sha256');", @@ -277,7 +295,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const posixPath = toPosix(relative);', " if ((sep === '/' && relative.includes('\\\\')) || !safeRelative(posixPath)) throw unsupported(sep === '/' ? relative : posixPath);", ' files.push(posixPath);', - ' hashEntry(hash, relative, metadata, await readFile(absolute));', + ' const bytes = await readFile(absolute);', + ' hashEntry(hash, relative, metadata, transform === undefined ? bytes : transform(posixPath, bytes));', ' };', ' for (const entry of (await readdir(root)).sort((left, right) => left.localeCompare(right))) {', ' if (entry === receiptFile) {', @@ -392,9 +411,69 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' return true;', '};', '', + '// Agent Plugins packs (root plugin.json with an agent-plugins.org $schema, no .cursor-plugin/plugin.json).', + '// Observed on Cursor 3.18.25 (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md): the loader spawns their', + '// stdio servers without expanding ${PLUGIN_ROOT}/${PLUGIN_DATA} in args, env values or cwd, without providing', + '// the reserved PLUGIN_ROOT/PLUGIN_DATA variables (spec 9.1), with an omitted cwd defaulting to the home directory', + '// and with ./ commands resolved against the workspace folder (spec 7.2.1). The installer therefore expands those', + '// forms itself in the Cursor copy of mcp.json and records the expansion in the receipt (provenance: derived).', + "const agentPluginsSchemaPrefix = 'https://agent-plugins.org/schemas/';", + 'const isAgentPluginsPack = async (root) => {', + " if (await exists(join(root, '.cursor-plugin', 'plugin.json'))) return false;", + ' try {', + " const manifest = JSON.parse(await readFile(join(root, 'plugin.json'), 'utf8'));", + " return manifest !== null && typeof manifest === 'object' && typeof manifest.$schema === 'string' && manifest.$schema.startsWith(agentPluginsSchemaPrefix);", + " } catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR' || error instanceof SyntaxError) return false; throw error; }", + '};', + "const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);", + "const expandPlaceholders = (value) => value.replaceAll('${PLUGIN_ROOT}', destination).replaceAll('${PLUGIN_DATA}', pluginData);", + '// A plugin-relative ./ path resolves against the plugin root (spec 7.2.1); anything else is expanded in place.', + "const expandPath = (value) => value.startsWith('./') ? join(destination, value.slice(2)) : expandPlaceholders(value);", + '// Every stdio server rewritten into the form Cursor launches; undefined when the document has none to expand', + '// (skills-only or remote-only packs stay byte-identical to the bundle).', + 'const expandAgentPluginsMcp = (text) => {', + ' let document;', + ' try { document = JSON.parse(text); } catch { return undefined; }', + ' if (!isRecord(document) || !isRecord(document.mcpServers)) return undefined;', + ' let expanded = false;', + ' const servers = {};', + ' for (const [name, server] of Object.entries(document.mcpServers)) {', + " if (!isRecord(server) || server.type !== 'stdio' || typeof server.command !== 'string') { servers[name] = server; continue; }", + ' expanded = true;', + ' const env = {};', + " for (const [key, value] of Object.entries(isRecord(server.env) ? server.env : {})) env[key] = typeof value === 'string' ? expandPlaceholders(value) : value;", + ' env.PLUGIN_ROOT = destination;', + ' env.PLUGIN_DATA = pluginData;', + ' servers[name] = {', + ' ...server,', + ' command: expandPath(server.command),', + " ...(Array.isArray(server.args) ? { args: server.args.map((argument) => typeof argument === 'string' ? expandPlaceholders(argument) : argument) } : {}),", + " cwd: typeof server.cwd === 'string' ? expandPath(server.cwd) : destination,", + ' env,', + ' };', + ' }', + ' if (!expanded) return undefined;', + " return `${JSON.stringify({ ...document, mcpServers: servers }, null, 2)}\\n`;", + '};', + 'const agentPluginsPack = await isAgentPluginsPack(source);', + 'const expansion = await (async () => {', + ' if (!agentPluginsPack) return undefined;', + ' let text;', + " try { text = await readFile(join(source, 'mcp.json'), 'utf8'); }", + " catch (error) { if (error?.code === 'ENOENT' || error?.code === 'EISDIR') return undefined; throw error; }", + ' const expanded = expandAgentPluginsMcp(text);', + " return expanded === undefined ? undefined : { documents: { 'mcp.json': text }, expanded, pluginData, pluginRoot: destination };", + '})();', + '// The Cursor copy differs from the bundle in exactly that one file; the artifact inventory hashes the expanded', + '// form so receipts, no-op reruns and replacement compare the bundle with what the copy must hold.', + "const cursorTransform = expansion === undefined ? undefined : (relativePath, bytes) => relativePath === 'mcp.json' ? Buffer.from(expansion.expanded, 'utf8') : bytes;", + '', '// directories: the ones the installer created (all of them on a fresh install); only those are ever pruned.', 'const receiptFor = (tree, directories = directoriesOf(tree.files)) => JSON.stringify({', ' contentHash: tree.hash,', + ' // The pre-expansion document bytes and the values substituted, so Doctor validates the Agent Plugins', + ' // contract against what the bundle shipped and proves the expansion against what Cursor spawns.', + ' ...(expansion === undefined ? {} : { cursorExpansion: { documents: expansion.documents, pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot } }),', ' directories,', ' files: tree.files,', ' format: receiptFormat,', @@ -417,6 +496,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " return relativePath === '' || content.has(toPosix(relativePath));", ' };', ' await cp(source, root, { errorOnExist: true, filter, force: false, recursive: true, verbatimSymlinks: true });', + " if (expansion !== undefined) await writeFile(join(root, 'mcp.json'), expansion.expanded, 'utf8');", ' const staged = await inventory(root);', " await writeFile(join(root, receiptFile), receiptFor(staged), 'utf8');", ' return { inventory: staged, parent, root };', @@ -584,13 +664,20 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '}', '', 'await mkdir(installRoot, { recursive: true });', - 'const artifact = await inventory(source);', + '// Spec 9.1: the data directory exists before any plugin subprocess is launched.', + 'if (expansion !== undefined) await mkdir(pluginData, { recursive: true });', + 'const artifact = await inventory(source, cursorTransform);', + 'const reportExpansion = () => {', + ' if (expansion === undefined) return;', + ' console.log(`Expanded Agent Plugins placeholders for Cursor in mcp.json: PLUGIN_ROOT=${destination} PLUGIN_DATA=${pluginData} (Cursor does not expand them; recorded in ${receiptFile})`);', + '};', '', 'if (!(await exists(destination))) {', ' const staged = await stage(artifact);', ' try {', ' await rename(staged.root, destination);', ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' reportExpansion();', ' } finally {', ' await rm(staged.parent, { force: true, recursive: true });', ' }', @@ -646,6 +733,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' await rm(temporary, { force: true });', ' }', ' console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', + ' reportExpansion();', ' process.exit(0);', ' }', ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', @@ -728,6 +816,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " await writeFile(join(staged.root, receiptFile), receiptFor(staged.inventory, directories), 'utf8');", ' await rename(join(staged.root, receiptFile), join(destination, receiptFile));', ' console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', + ' reportExpansion();', '} finally {', ' await rm(staged.parent, { force: true, recursive: true });', '}', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 1918d96d2..f9e66db58 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -6,6 +6,8 @@ import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import type { TargetArtifactWrite } from '../src/adapters/types.ts'; import { runCli } from '../src/cli.ts'; import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; import { installBundle } from '../src/install/install.ts'; @@ -310,6 +312,131 @@ it('validates root plugin.json installs that declare an Agent Plugins schema aga } }); +it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the emitted installer\'s expansion is verified, drift is corrupt (AB7325)', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + const pluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; + const mcpSchema = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; + const ab7325 = (report: DoctorReport) => report.diagnostics.filter((entry) => entry.code === 'AB7325'); + const ab7320Errors = (report: DoctorReport) => report.diagnostics.filter((entry) => entry.code === 'AB7320' && entry.severity === 'error'); + const doctor = () => runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] }); + try { + // 1. The spec-shaped pack from the 2026-09-03 observations, copied in by hand: every form Cursor leaves unresolved. + await writeJson(join(installRoot, 'spec-shape', 'plugin.json'), { $schema: pluginSchema, name: 'spec-shape', version: '1.0.0' }); + await writeJson(join(installRoot, 'spec-shape', 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { + launcher: { args: [], command: './mcp/launch.sh', type: 'stdio' }, + probe: { args: ['${PLUGIN_ROOT}/mcp/report.mjs'], command: 'node', cwd: '${PLUGIN_ROOT}', env: { PROBE_DATA: '${PLUGIN_DATA}' }, type: 'stdio' }, + remote: { type: 'streamable-http', url: 'https://example.test/mcp' }, + }, + }); + await mkdir(join(installRoot, 'spec-shape', 'mcp'), { recursive: true }); + await writeFile(join(installRoot, 'spec-shape', 'mcp', 'report.mjs'), 'process.stdin.resume();\n'); + await writeFile(join(installRoot, 'spec-shape', 'mcp', 'launch.sh'), '#!/bin/sh\n', { mode: 0o755 }); + const unexpanded = await doctor(); + expect(ab7320Errors(unexpanded)).toEqual([]); + expect(ab7325(unexpanded)).toEqual([expect.objectContaining({ + message: expect.stringContaining('"launcher", "probe" depend on client-side Agent Plugins 1.0.0 resolution that Cursor 3.18.25 does not perform'), + recovery: expect.stringContaining('emitted `install.mjs`'), + severity: 'warning', + })]); + const [unexpandedMessage] = ab7325(unexpanded).map((entry) => entry.message); + expect(unexpandedMessage).toContain('launcher: plugin-relative `./` command resolved against the workspace folder'); + expect(unexpandedMessage).toContain('omitted `cwd` defaulted to the home directory'); + expect(unexpandedMessage).toContain('probe: ${PLUGIN_ROOT}/${PLUGIN_DATA} left unexpanded in args, env, cwd (spec §9.2)'); + expect(unexpandedMessage).toContain('reserved `PLUGIN_ROOT`/`PLUGIN_DATA` variables not provided'); + expect(hostReport(unexpanded, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ launch: { servers: ['launcher', 'probe'], state: 'unexpanded' }, name: 'spec-shape', state: 'installed' }), + ]); + await rm(join(installRoot, 'spec-shape'), { recursive: true }); + + // 2. The same pack installed by the emitted install.mjs: expanded, recorded, and verified — the Agent Plugins + // contract is checked against the bundle's document, so the absolute paths and §9.1 keys in the copy are no error. + const bundle = join(fixture.root, 'portable-bundle'); + const installerSource = createDefaultRegistry().get('portable').plan({ + extensions: {}, + hooks: [], + mcpServers: [], + metadata: { + id: 'plugin:expanded', + name: 'expanded', + provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' }, + version: '1.0.0', + }, + runtime: { node: '22.19.0' }, + scripts: [], + skills: [], + targets: [{ id: 'target:portable', name: 'portable', provenance: { kind: 'config', sourcePath: '/project/agent-bundle.config.ts' } }], + }).entries.find((entry): entry is TargetArtifactWrite => entry.kind === 'write' && entry.relativePath === 'install.mjs'); + if (installerSource === undefined) throw new Error('portable plan emitted no install.mjs'); + await mkdir(join(bundle, 'mcp'), { recursive: true }); + await writeFile(join(bundle, 'install.mjs'), installerSource.content); + await writeFile(join(bundle, 'INSTALL.md'), '# Install expanded\n'); + await writeJson(join(bundle, 'plugin.json'), { $schema: pluginSchema, name: 'expanded', version: '1.0.0' }); + await writeJson(join(bundle, 'mcp.json'), { + $schema: mcpSchema, + mcpServers: { + launcher: { args: [], command: './mcp/launch.sh', type: 'stdio' }, + probe: { args: ['${PLUGIN_ROOT}/mcp/report.mjs'], command: 'node', cwd: '${PLUGIN_ROOT}', env: { PROBE_DATA: '${PLUGIN_DATA}' }, type: 'stdio' }, + }, + }); + await writeFile(join(bundle, 'mcp', 'report.mjs'), 'process.stdin.resume();\n'); + await writeFile(join(bundle, 'mcp', 'launch.sh'), '#!/bin/sh\n', { mode: 0o755 }); + await new Promise((resolveInstall, reject) => { + const child = spawn(process.execPath, [join(bundle, 'install.mjs')], { cwd: bundle, env: { ...process.env, HOME: fixture.home }, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.on('error', reject); + child.on('exit', (code) => code === 0 ? resolveInstall() : reject(new Error(`install.mjs exited ${String(code)}: ${output}`))); + }); + const destination = join(installRoot, 'expanded'); + const pluginData = join(fixture.home, '.cursor', 'agent-bundle', 'plugin-data', 'expanded'); + const expanded = await doctor(); + expect(ab7320Errors(expanded)).toEqual([]); + expect(expanded.diagnostics.filter((entry) => entry.code === 'AB7320' && entry.severity === 'info').map((entry) => entry.message)).toEqual([ + expect.stringContaining('using the pre-expansion mcp.json its install receipt recorded'), + ]); + expect(ab7325(expanded)).toEqual([expect.objectContaining({ + message: expect.stringContaining(`were expanded for Cursor at install (provenance: derived; Cursor 3.18.25 expands no Agent Plugins placeholder itself): PLUGIN_ROOT=${JSON.stringify(destination)}, PLUGIN_DATA=${JSON.stringify(pluginData)}`), + severity: 'info', + })]); + expect(hostReport(expanded, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ launch: { pluginData, pluginRoot: destination, servers: ['launcher', 'probe'], state: 'expanded' }, name: 'expanded', state: 'installed' }), + ]); + + // 3. Drift: the data directory disappears and a referenced script is removed — Cursor would spawn paths that do not exist. + await rm(pluginData, { recursive: true }); + await rm(join(destination, 'mcp', 'report.mjs')); + const drifted = await doctor(); + expect(ab7325(drifted)).toEqual([expect.objectContaining({ + message: expect.stringContaining('no longer describes the installed copy'), + recovery: expect.stringContaining('reinstalled at its current location'), + severity: 'error', + })]); + const [driftMessage] = ab7325(drifted).map((entry) => entry.message); + expect(driftMessage).toContain(`the PLUGIN_DATA directory ${JSON.stringify(pluginData)} does not exist`); + expect(driftMessage).toContain(`mcpServers/probe/args/0 ${JSON.stringify(join(destination, 'mcp', 'report.mjs'))} does not exist under the plugin root`); + expect(hostReport(drifted, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ launch: expect.objectContaining({ state: 'drifted' }), name: 'expanded', state: 'corrupt' }), + ]); + + // 4. A copy moved to another plugin directory carries a receipt expanded for its old root. + await mkdir(pluginData, { recursive: true }); + await writeFile(join(destination, 'mcp', 'report.mjs'), 'process.stdin.resume();\n'); + await cp(destination, join(installRoot, 'moved'), { recursive: true }); + await rm(destination, { recursive: true }); + const moved = await doctor(); + expect(ab7325(moved).map((entry) => entry.severity)).toEqual(['error']); + expect(ab7325(moved)[0]?.message).toContain(`the receipt expanded PLUGIN_ROOT to ${JSON.stringify(destination)} but the package is installed at ${JSON.stringify(join(installRoot, 'moved'))}`); + // The moved copy's on-disk mcp.json is still validated against the recorded bundle document, not its expanded bytes. + expect(ab7320Errors(moved)).toEqual([]); + } finally { + await fixture.cleanup(); + } +}, 60_000); + it('accepts a versionless Cursor inventory manifest as installed', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 151b060ea..bd0648318 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -490,12 +490,18 @@ it( manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config', pluginVariables: { allowedLocations: 'args/env values/cwd only', + cursorExpansion: { + doctor: 'AB7325 expanded', + installedCopy: 'PLUGIN_ROOT/PLUGIN_DATA absolute, cwd = plugin root, PLUGIN_ROOT/PLUGIN_DATA env set, no placeholder left', + pluginData: '.cursor/agent-bundle/plugin-data/host-install-portable-proof', + receipt: 'cursorExpansion records the bundle mcp.json verbatim', + }, locations: [ 'mcp.json#/mcpServers/probe/cwd', 'mcp.json#/mcpServers/probe/env/AGENT_BUNDLE_PLUGIN_ROOT', ], reservedEnvKeys: 'absent', - resolvedAtInstall: false, + resolvedAtInstall: true, 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)', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index c5fe5185b..ecd1be74b 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { promisify } from 'node:util'; @@ -130,8 +130,154 @@ it('documents native Agent Plugins clients for the portable profile', () => { expect(install).toContain('`~/.cursor/plugins/local/`'); expect(install).toContain('Developer: Reload Window'); expect(install).toContain('Codex, VS Code, GitHub Copilot, Kiro, and ChatGPT'); + // The Cursor-only placeholder expansion is documented where the installer is (#426). + expect(install).toContain('### Cursor placeholder expansion'); + expect(install).toContain('`~/.cursor/agent-bundle/plugin-data/`'); + expect(install).toContain('`AB7325`'); + expect(install).toContain('The bundle itself'); }); +/** The spec-shaped Agent Plugins pack from the 2026-09-03 Cursor observations (#426), every failing form at once. */ +const agentPluginsMcp = { + $schema: 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json', + mcpServers: { + launcher: { args: [], command: './mcp/launch.sh', type: 'stdio' }, + probe: { + args: ['${PLUGIN_ROOT}/mcp/report.mjs', '--cache', '${PLUGIN_DATA}/cache'], + command: 'node', + cwd: '${PLUGIN_ROOT}', + env: { AGENT_BUNDLE_PLUGIN_ROOT: '${PLUGIN_ROOT}', PROBE_DATA: '${PLUGIN_DATA}', PROBE_ROOT: '${PLUGIN_ROOT}' }, + type: 'stdio', + }, + remote: { type: 'streamable-http', url: 'https://example.com/mcp' }, + }, +}; + +it('emitted install.mjs expands Agent Plugins placeholders for the Cursor copy only, records them in the receipt, and stays idempotent', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-install-mjs-portable-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const destination = join(home, '.cursor', 'plugins', 'local', 'install-fixture'); + const pluginData = join(home, '.cursor', 'agent-bundle', 'plugin-data', 'install-fixture'); + const installer = join(bundle, 'install.mjs'); + const mcpText = `${JSON.stringify(agentPluginsMcp, null, 2)}\n`; + try { + const writes = writesFor('portable'); + await mkdir(join(bundle, 'mcp'), { recursive: true }); + await mkdir(join(home, '.cursor'), { recursive: true }); + await Promise.all([ + writeFile(installer, writes.get('install.mjs') ?? ''), + writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), + writeFile(join(bundle, 'plugin.json'), JSON.stringify({ + $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json', + name: 'install-fixture', + version: '1.2.3', + })), + writeFile(join(bundle, 'mcp.json'), mcpText), + writeFile(join(bundle, 'mcp', 'report.mjs'), 'process.stdin.resume();\n'), + writeFile(join(bundle, 'mcp', 'launch.sh'), '#!/usr/bin/env bash\nexec node "$(dirname "$0")/report.mjs"\n', { mode: 0o755 }), + ]); + + const first = await run(installer, [], home); + expect(first).toMatchObject({ code: 0, stderr: '' }); + expect(first.stdout).toContain('Installed install-fixture@1.2.3'); + expect(first.stdout).toContain(`Expanded Agent Plugins placeholders for Cursor in mcp.json: PLUGIN_ROOT=${destination} PLUGIN_DATA=${pluginData}`); + // The bundle is untouched and stays spec-conformant; only the Cursor copy is rewritten. + expect(await readFile(join(bundle, 'mcp.json'), 'utf8')).toBe(mcpText); + expect(JSON.parse(await readFile(join(destination, 'mcp.json'), 'utf8'))).toEqual({ + $schema: agentPluginsMcp.$schema, + mcpServers: { + launcher: { + args: [], + command: join(destination, 'mcp', 'launch.sh'), + cwd: destination, + env: { PLUGIN_DATA: pluginData, PLUGIN_ROOT: destination }, + type: 'stdio', + }, + probe: { + args: [join(destination, 'mcp', 'report.mjs'), '--cache', join(pluginData, 'cache')], + command: 'node', + cwd: destination, + env: { + AGENT_BUNDLE_PLUGIN_ROOT: destination, + PLUGIN_DATA: pluginData, + PLUGIN_ROOT: destination, + PROBE_DATA: pluginData, + PROBE_ROOT: destination, + }, + type: 'stdio', + }, + remote: { type: 'streamable-http', url: 'https://example.com/mcp' }, + }, + }); + expect((await stat(pluginData)).isDirectory()).toBe(true); + // The receipt hashes the installed (expanded) form and keeps the bundle's document for Doctor. + const receipt = await readInstallReceipt(destination); + expect(receipt).toMatchObject({ + contentHash: (await treeInventory(destination)).hash, + cursorExpansion: { documents: { 'mcp.json': mcpText }, pluginData, pluginRoot: destination }, + plugin: 'install-fixture', + version: '1.2.3', + }); + expect(receipt?.contentHash).not.toBe((await treeInventory(bundle)).hash); + + // Idempotent: the rerun compares the bundle's expanded form with the copy and finds nothing to do. + const again = await run(installer, [], home); + expect(again).toMatchObject({ code: 0, stderr: '' }); + expect(again.stdout).toContain('Already installed install-fixture@1.2.3'); + expect(again.stdout).not.toContain('Expanded Agent Plugins'); + expect(await readInstallReceipt(destination)).toEqual(receipt); + + // A same-version rebuild replaces owned files and re-expands the rebuilt document. + await writeFile(join(bundle, 'mcp.json'), mcpText.replace('--cache', '--store')); + const replaced = await run(installer, [], home); + expect(replaced).toMatchObject({ code: 0, stderr: '' }); + expect(replaced.stdout).toContain('Replaced install-fixture@1.2.3'); + expect(replaced.stdout).toContain('Expanded Agent Plugins placeholders for Cursor'); + const rebuilt = JSON.parse(await readFile(join(destination, 'mcp.json'), 'utf8')) as { mcpServers: { probe: { args: string[] } } }; + expect(rebuilt.mcpServers.probe.args).toEqual([join(destination, 'mcp', 'report.mjs'), '--store', join(pluginData, 'cache')]); + expect((await readInstallReceipt(destination))?.cursorExpansion?.documents['mcp.json']).toBe(mcpText.replace('--cache', '--store')); + + // An unexpanded copy left by an older installer (receipt without the expansion) is same-version content + // drift of a receipt-managed copy: replaced automatically, so a plain rerun repairs it. + await writeFile(join(destination, 'mcp.json'), mcpText.replace('--cache', '--store')); + const staleReceipt = JSON.parse(await readFile(join(destination, installReceiptFile), 'utf8')) as Record; + const { cursorExpansion: _expansion, ...unexpandedReceipt } = staleReceipt; + await writeFile(join(destination, installReceiptFile), JSON.stringify({ + ...unexpandedReceipt, + contentHash: (await treeInventory(destination)).hash, + })); + const repaired = await run(installer, [], home); + expect(repaired).toMatchObject({ code: 0, stderr: '' }); + expect(repaired.stdout).toContain('Replaced install-fixture@1.2.3'); + expect((await readInstallReceipt(destination))?.cursorExpansion?.pluginRoot).toBe(destination); + + // A skills-only Agent Plugins pack (no stdio server) is copied byte-identically and records no expansion. + await rm(destination, { force: true, recursive: true }); + await writeFile(join(bundle, 'mcp.json'), `${JSON.stringify({ + $schema: agentPluginsMcp.$schema, + mcpServers: { remote: agentPluginsMcp.mcpServers.remote }, + }, null, 2)}\n`); + const remoteOnly = await run(installer, [], home); + expect(remoteOnly).toMatchObject({ code: 0, stderr: '' }); + expect(remoteOnly.stdout).not.toContain('Expanded Agent Plugins'); + expect((await readInstallReceipt(destination))?.cursorExpansion).toBeUndefined(); + expect((await readInstallReceipt(destination))?.contentHash).toBe((await treeInventory(bundle)).hash); + + // A Cursor Plugin bundle beside a root plugin.json is never rewritten: the expansion is for Agent Plugins packs only. + await rm(destination, { force: true, recursive: true }); + await writeFile(join(bundle, 'mcp.json'), mcpText); + await mkdir(join(bundle, '.cursor-plugin'), { recursive: true }); + await writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })); + const cursorPlugin = await run(installer, [], home); + expect(cursorPlugin).toMatchObject({ code: 0, stderr: '' }); + expect(cursorPlugin.stdout).not.toContain('Expanded Agent Plugins'); + expect(await readFile(join(destination, 'mcp.json'), 'utf8')).toBe(mcpText); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 60_000); + 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 d29b989b7..209bd215b 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -38,6 +38,7 @@ import type { ArtifactEpoch } from '../../src/dev/types.ts'; import { startDevServer } from '../../src/dev/workbench-server.ts'; import { runDoctor } from '../../src/install/doctor.ts'; import { installBundle, type InstallHost } from '../../src/install/install.ts'; +import { readInstallReceipt } from '../../src/install/receipt.ts'; import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, @@ -323,11 +324,22 @@ export interface PortableHostInstallReport { readonly version: '1.0.0'; }; readonly manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config'; + /** + * The bundle's `mcp.json` is the spec-conformant document (`locations`, + * `reservedEnvKeys` describe it); the Cursor copy is its install-time + * expansion, since Cursor 3.18.25 expands no Agent Plugins placeholder (#426). + */ readonly pluginVariables: { readonly allowedLocations: 'args/env values/cwd only'; + readonly cursorExpansion: { + readonly doctor: 'AB7325 expanded'; + readonly installedCopy: 'PLUGIN_ROOT/PLUGIN_DATA absolute, cwd = plugin root, PLUGIN_ROOT/PLUGIN_DATA env set, no placeholder left'; + readonly pluginData: string; + readonly receipt: 'cursorExpansion records the bundle mcp.json verbatim'; + }; readonly locations: readonly string[]; readonly reservedEnvKeys: 'absent'; - readonly resolvedAtInstall: false; + readonly resolvedAtInstall: true; readonly sessionEvidence: 'unavailable: Cursor loads Agent Plugins only at restart or window reload; no non-interactive plugin-loading session surface'; }; readonly proofLevel: string; @@ -1445,6 +1457,7 @@ export const runPortableHostInstallProof = async ( await install('Installed'); const destination = join(home, '.cursor', 'plugins', 'local', portablePlugin); + const pluginData = join(home, '.cursor', 'agent-bundle', 'plugin-data', portablePlugin); const pluginDocument = await readJson( join(destination, 'plugin.json'), 'Portable installed plugin manifest', @@ -1453,14 +1466,56 @@ export const runPortableHostInstallProof = async ( 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', - ); + // The bundle's mcp.json is the spec-conformant document; the Cursor copy is its install-time expansion + // (Cursor 3.18.25 expands no Agent Plugins placeholder itself), recorded verbatim in the receipt. + const bundleMcpText = await readFile(join(fixture.portableBundle, 'mcp.json'), 'utf8'); + const mcpDocument = JSON.parse(bundleMcpText) as unknown; assertProof( portableMcpValidator(mcpDocument), `Portable MCP document failed its pinned schema: ${JSON.stringify(portableMcpValidator.errors)}`, ); + const receipt = await readInstallReceipt(destination); + assertProof(receipt !== undefined, 'Portable emitted installer wrote no receipt.'); + assertProof( + receipt.cursorExpansion !== undefined && + receipt.cursorExpansion.pluginRoot === destination && + receipt.cursorExpansion.pluginData === pluginData && + receipt.cursorExpansion.documents['mcp.json'] === bundleMcpText, + `Portable receipt did not record the Cursor expansion of the bundle mcp.json: ${JSON.stringify(receipt.cursorExpansion)}`, + ); + const pluginDataMetadata = await lstat(pluginData).catch(() => fail('Portable installer did not create the PLUGIN_DATA directory.')); + assertProof(pluginDataMetadata.isDirectory(), 'Portable PLUGIN_DATA is not a directory.'); + const installedMcp = record(await readJson(join(destination, 'mcp.json'), 'Portable installed MCP document')); + assertProof(installedMcp !== undefined, 'Portable installed MCP document was not a JSON object.'); + const installedServers = record(installedMcp.mcpServers); + assertProof(installedServers !== undefined, 'Portable installed MCP document had no server map.'); + let expandedStdioServers = 0; + for (const [serverName, serverValue] of Object.entries(installedServers)) { + const server = record(serverValue); + assertProof(server !== undefined, `Portable installed MCP server ${serverName} was not an object.`); + if (server.type !== 'stdio') continue; + expandedStdioServers += 1; + const text = JSON.stringify(server); + assertProof( + !text.includes(portablePluginRootVariable) && !text.includes(portablePluginDataVariable), + `Portable installed MCP server ${serverName} still carries an Agent Plugins placeholder Cursor would not expand.`, + ); + assertProof(server.cwd === destination, `Portable installed MCP server ${serverName} cwd is not the plugin root.`); + const environment = record(server.env); + assertProof( + environment?.PLUGIN_ROOT === destination && environment.PLUGIN_DATA === pluginData, + `Portable installed MCP server ${serverName} lacks the expanded PLUGIN_ROOT/PLUGIN_DATA variables.`, + ); + assertProof( + environment.AGENT_BUNDLE_PLUGIN_ROOT === destination, + `Portable installed MCP server ${serverName} AGENT_BUNDLE_PLUGIN_ROOT was not expanded to the plugin root.`, + ); + for (const argument of Array.isArray(server.args) ? server.args : []) { + if (typeof argument !== 'string' || !argument.startsWith(`${destination}${sep}`)) continue; + await access(argument).catch(() => fail(`Portable installed MCP server ${serverName} argument ${argument} does not exist.`)); + } + } + assertProof(expandedStdioServers > 0, 'Portable fixture emitted no stdio server to expand.'); const pluginManifest = record(pluginDocument); const mcpManifest = record(mcpDocument); @@ -1579,11 +1634,32 @@ export const runPortableHostInstallProof = async ( ); } - const contractDiagnostics = await validatePortablePluginFiles({ pluginDirectory: destination, target: 'portable' }); + // The byte lane validates the shipped document (the receipt copy), exactly as Doctor does for an expanded install. + const contractDiagnostics = await validatePortablePluginFiles({ + documents: { 'mcp.json': bundleMcpText }, + pluginDirectory: destination, + target: 'portable', + }); assertProof( contractDiagnostics.length === 0, `Portable installed bytes failed the pinned Agent Plugins byte lane: ${JSON.stringify(contractDiagnostics)}`, ); + const doctorReport = await runDoctor({ home, hosts: ['cursor'] }); + const launchFindings = doctorReport.diagnostics.filter((entry) => entry.code === 'AB7325'); + assertProof( + launchFindings.length === 1 && launchFindings[0]?.severity === 'info' && launchFindings[0].message.includes('were expanded for Cursor at install'), + `Doctor did not prove the Cursor expansion (AB7325): ${JSON.stringify(launchFindings)}`, + ); + assertProof( + !doctorReport.diagnostics.some((entry) => entry.code === 'AB7320' && entry.severity === 'error'), + `Doctor reported Agent Plugins contract errors for the expanded install: ${JSON.stringify(doctorReport.diagnostics.filter((entry) => entry.code === 'AB7320'))}`, + ); + const portableFinding = doctorReport.hosts.find((entry) => entry.host === 'cursor')?.inventory.findings + .find((entry) => entry.name === portablePlugin); + assertProof( + portableFinding?.state === 'installed' && portableFinding.launch?.state === 'expanded', + `Doctor inventory did not report the portable install as expanded: ${JSON.stringify(portableFinding)}`, + ); await install('Already installed'); @@ -1619,9 +1695,15 @@ export const runPortableHostInstallProof = async ( manifestMetadata: 'author/homepage/repository/license/keywords/extensions emitted from portable config', pluginVariables: Object.freeze({ allowedLocations: 'args/env values/cwd only', + cursorExpansion: Object.freeze({ + doctor: 'AB7325 expanded', + installedCopy: 'PLUGIN_ROOT/PLUGIN_DATA absolute, cwd = plugin root, PLUGIN_ROOT/PLUGIN_DATA env set, no placeholder left', + pluginData: normalizedRelative(home, pluginData), + receipt: 'cursorExpansion records the bundle mcp.json verbatim', + }), locations: Object.freeze(placeholderLocations), reservedEnvKeys: 'absent', - resolvedAtInstall: false, + resolvedAtInstall: true, sessionEvidence: 'unavailable: Cursor loads Agent Plugins only at restart or window reload; no non-interactive plugin-loading session surface', }), proofLevel: portableProofLevel, diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 379d16a45..f63a3db07 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -42,7 +42,22 @@ node ./install.mjs ``` Its staged copy is idempotent for identical content and refuses version or content collisions. It -never invokes `sudo` and never changes `PATH`. Artifact validation rejects a built-in target +never invokes `sudo` and never changes `PATH`. + +For a `portable` (Agent Plugins 1.0.0) bundle the installer also expands the standard's +placeholders in the Cursor copy of `mcp.json`, because Cursor 3.18.25 does not: it leaves +`${PLUGIN_ROOT}` / `${PLUGIN_DATA}` unexpanded in `args`, `env` values, and `cwd`, provides no +`PLUGIN_ROOT` / `PLUGIN_DATA` variables (spec §9.1), defaults an omitted `cwd` to the home +directory, and resolves plugin-relative `./` commands against the workspace folder (spec §7.2.1), +so every spec-shaped stdio server fails to spawn. The Cursor copy gets the absolute plugin root, +`~/.cursor/agent-bundle/plugin-data/` (created by the installer) as the data directory, a +plugin-root `cwd`, resolved `./` commands, and `PLUGIN_ROOT` / `PLUGIN_DATA` in each stdio +server's environment. The bundle itself is untouched and stays spec-conformant for other Agent +Plugins clients; the pre-expansion document is kept in the install receipt (`cursorExpansion`), +and `agent-bundle doctor --host cursor` proves the expansion with `AB7325`. Nothing here is +Cursor behaviour: the provenance is `derived`, and a `cursor`-target bundle is never rewritten. + +Artifact validation rejects a built-in target whose required install surface is missing, so a bundle cannot ship without the installer it promises. @@ -110,6 +125,7 @@ installed (or byte-identical existing) copy carries `errors`, instead of reporti | `AB7322` | info / error | Info when an installed Cursor plugin registers plugin-scoped hooks from its manifest and every command's script exists under the plugin root; error when the declared hooks file is missing or malformed, or an executed script is absent. | | `AB7323` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory, so Cursor would deliver that hook twice; manifest registration alone is sufficient. | | `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). | +| `AB7325` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. | | `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | ## Next diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 27b07357b..a865f17d5 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -111,7 +111,9 @@ the copy with `errors` in `claude plugin list --json` and refused to load it; th text verbatim), or `not-installed` (`AB7307`). For Cursor it also proves plugin-scoped hook registration from the installed manifest (`AB7322`), warns when `~/.cursor/hooks.json` would deliver a plugin hook a second time (`AB7323`), and tracks a -staged marketplace from `unregistered` to `registered` once Cursor imports it (`AB7324`). +staged marketplace from `unregistered` to `registered` once Cursor imports it (`AB7324`). For an +Agent Plugins install it proves the emitted installer's placeholder expansion — `expanded`, +`unexpanded` (spec forms Cursor 3.18.25 cannot launch), or `drifted` (`AB7325`). ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 3f64cb764..db1772115 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -40,6 +40,16 @@ node ./install.mjs ``` 它的分阶段复制对内容相同的情况是幂等的,并会拒绝版本或内容冲突。它绝不调用 `sudo`,也绝不修改 `PATH`。 + +对于 `portable`(Agent Plugins 1.0.0)捆绑包,安装器还会在 `mcp.json` 的 Cursor 副本中展开该标准的占位符, +因为 Cursor 3.18.25 自己不会展开:它把 `args`、`env` 值和 `cwd` 中的 `${PLUGIN_ROOT}` / `${PLUGIN_DATA}` +原样留下,不提供 `PLUGIN_ROOT` / `PLUGIN_DATA` 变量(规范 §9.1),把省略的 `cwd` 默认为主目录,并把相对插件的 +`./` 命令解析到工作区文件夹(规范 §7.2.1),因此每个符合规范的 stdio 服务器都无法启动。Cursor 副本会得到绝对的 +插件根、作为数据目录的 `~/.cursor/agent-bundle/plugin-data/`(由安装器创建)、指向插件根的 `cwd`、已解析的 +`./` 命令,以及每个 stdio 服务器环境中的 `PLUGIN_ROOT` / `PLUGIN_DATA`。捆绑包本身不被改动,对其他 Agent Plugins +客户端仍然符合规范;展开前的文档保存在安装回执中(`cursorExpansion`),`agent-bundle doctor --host cursor` 以 +`AB7325` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而 `cursor` target 的捆绑包绝不会被重写。 + 产物校验会拒绝缺少必需安装表面的内置 target,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 ## 相对包的安装器 bin @@ -97,6 +107,7 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list | `AB7322` | info / error | 已安装的 Cursor 插件从清单注册了插件级 hook 且每条命令的脚本都存在于插件根之下时为 info;声明的 hooks 文件缺失或格式错误、或被执行的脚本不存在时为 error。 | | `AB7323` | warning | `~/.cursor/hooks.json` 注册了指向某个已安装插件目录的命令,Cursor 会把该 hook 投递两次;仅靠清单注册就已足够。 | | `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 | +| `AB7325` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 | | `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | ## 下一步 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 0d5071919..9bab42d52 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -107,7 +107,8 @@ agent-bundle install [--from ] [--scope ] [--mode Date: Thu, 3 Sep 2026 23:43:37 +0000 Subject: [PATCH 2/4] chore(changeset): reference #482 --- .changeset/426-cursor-agent-plugins-expansion.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/426-cursor-agent-plugins-expansion.md b/.changeset/426-cursor-agent-plugins-expansion.md index 063118af0..a60468a0e 100644 --- a/.changeset/426-cursor-agent-plugins-expansion.md +++ b/.changeset/426-cursor-agent-plugins-expansion.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Expand Agent Plugins placeholders for Cursor at install time. The `install.mjs` emitted with a `portable` bundle now rewrites `mcp.json` in the `~/.cursor/plugins/local/` copy — `${PLUGIN_ROOT}` to the absolute plugin root, `${PLUGIN_DATA}` to `~/.cursor/agent-bundle/plugin-data/` (created), an omitted `cwd` to the plugin root, plugin-relative `./` commands to absolute paths, and `PLUGIN_ROOT`/`PLUGIN_DATA` into every stdio server's `env` — because Cursor 3.18.25 performs none of that resolution and every spec-shaped stdio server failed to spawn there. The bundle itself is untouched, the pre-expansion document is recorded in the install receipt (`cursorExpansion`), reruns stay idempotent and older unexpanded copies are replaced on the next run. `agent-bundle doctor --host cursor` validates the Agent Plugins contract (`AB7320`) against the recorded document and adds `AB7325` (`expanded` / `unexpanded` / `drifted`) for the launch proof; `cursor`-target bundles are never rewritten. (#469) +Expand Agent Plugins placeholders for Cursor at install time. The `install.mjs` emitted with a `portable` bundle now rewrites `mcp.json` in the `~/.cursor/plugins/local/` copy — `${PLUGIN_ROOT}` to the absolute plugin root, `${PLUGIN_DATA}` to `~/.cursor/agent-bundle/plugin-data/` (created), an omitted `cwd` to the plugin root, plugin-relative `./` commands to absolute paths, and `PLUGIN_ROOT`/`PLUGIN_DATA` into every stdio server's `env` — because Cursor 3.18.25 performs none of that resolution and every spec-shaped stdio server failed to spawn there. The bundle itself is untouched, the pre-expansion document is recorded in the install receipt (`cursorExpansion`), reruns stay idempotent and older unexpanded copies are replaced on the next run. `agent-bundle doctor --host cursor` validates the Agent Plugins contract (`AB7320`) against the recorded document and adds `AB7325` (`expanded` / `unexpanded` / `drifted`) for the launch proof; `cursor`-target bundles are never rewritten. (#482) From cf2fba8a461e80f44879b6bbca50138e3c455a7d Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 00:06:24 +0000 Subject: [PATCH 3/4] fix(doctor): AB7325 recomputes the recorded expansion and requires the installed mcp.json to match it byte for byte (review) --- docs/diagnostics.md | 2 +- .../install/cursor-agent-plugins-launch.ts | 122 ++++++++++++++---- packages/agent-bundle/src/install/surface.ts | 3 +- packages/agent-bundle/tests/doctor.test.ts | 34 +++++ 4 files changed, 131 insertions(+), 30 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 11fc20311..30bd09f0b 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -900,4 +900,4 @@ placeholders itself. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB7325` | info / warning / error | Info (`launch.state = expanded`): the receipt's expansion still describes the installed copy — same plugin root, existing data directory, no placeholder left, absolute `cwd` and plugin-root `command`/`args` paths that exist, `PLUGIN_ROOT` / `PLUGIN_DATA` equal to the recorded values. Warning (`unexpanded`): an Agent Plugins install without a recorded expansion whose stdio servers still rely on the spec forms Cursor does not resolve (the message lists the forms per server); Cursor reports `spawn … ENOENT` / `MODULE_NOT_FOUND` for them. Error (`drifted`, entry `corrupt`): the recorded expansion names another plugin root (the copy was moved or duplicated), the data directory or an expanded path no longer exists, or the environment no longer carries the recorded values. Packages without stdio servers, and copies already carrying absolute paths with the §9.1 variables, produce no finding. | Reinstall with the bundle's emitted `install.mjs` at the copy's current location; the Cursor-target (`.cursor-plugin/plugin.json`) bundle is never rewritten and is not subject to this check. | +| `AB7325` | info / warning / error | Info (`launch.state = expanded`): the receipt's expansion still describes the installed copy — same plugin root, existing data directory, no placeholder left, absolute `cwd` and plugin-root `command`/`args` paths that exist, `PLUGIN_ROOT` / `PLUGIN_DATA` equal to the recorded values. Warning (`unexpanded`): an Agent Plugins install without a recorded expansion whose stdio servers still rely on the spec forms Cursor does not resolve (the message lists the forms per server); Cursor reports `spawn … ENOENT` / `MODULE_NOT_FOUND` for them. Error (`drifted`, entry `corrupt`): the installed `mcp.json` is not byte-identical to the expansion Doctor recomputes from the recorded document (edited, replaced, or removed after install), the recorded expansion names another plugin root (the copy was moved or duplicated), the data directory or an expanded path no longer exists, or the environment no longer carries the recorded values. Only a byte-identical copy has its recorded document validated by `AB7320`; a drifted copy is validated as the bytes on disk. Packages without stdio servers, and copies already carrying absolute paths with the §9.1 variables, produce no finding. | Reinstall with the bundle's emitted `install.mjs` at the copy's current location; the Cursor-target (`.cursor-plugin/plugin.json`) bundle is never rewritten and is not subject to this check. | diff --git a/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts b/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts index 758fc3228..44377c88c 100644 --- a/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts +++ b/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts @@ -1,5 +1,5 @@ import { readFile, stat } from 'node:fs/promises'; -import { isAbsolute, resolve, sep } from 'node:path'; +import { isAbsolute, join, resolve, sep } from 'node:path'; import { portablePlaceholderPattern } from '../adapters/portable-mcp-rules.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -190,6 +190,63 @@ const driftIssues = async ( return Object.freeze(issues); }; +/** + * The expansion the emitted `install.mjs` performs, reproduced byte for byte + * so Doctor can recompute what the Cursor copy of a recorded document must + * hold. `undefined` when the document has no stdio server to expand. Any + * change here must be mirrored in `surface.ts` (`expandAgentPluginsMcp`); the + * Doctor test that installs through the real emitted installer pins the two. + */ +export const expandAgentPluginsMcpForCursor = ( + text: string, + pluginRoot: string, + pluginData: string, +): string | undefined => { + let document: unknown; + try { + document = JSON.parse(text) as unknown; + } catch { + return undefined; + } + if (!isRecord(document) || !isRecord(document['mcpServers'])) return undefined; + const expandPlaceholders = (value: string): string => + value.replaceAll('${PLUGIN_ROOT}', pluginRoot).replaceAll('${PLUGIN_DATA}', pluginData); + const expandPath = (value: string): string => (value.startsWith('./') ? join(pluginRoot, value.slice(2)) : expandPlaceholders(value)); + let expanded = false; + const servers: Record = {}; + for (const [name, server] of Object.entries(document['mcpServers'])) { + if (!isRecord(server) || server['type'] !== 'stdio' || typeof server['command'] !== 'string') { + servers[name] = server; + continue; + } + expanded = true; + const env: Record = {}; + for (const [key, value] of Object.entries(isRecord(server['env']) ? server['env'] : {})) { + env[key] = typeof value === 'string' ? expandPlaceholders(value) : value; + } + env['PLUGIN_ROOT'] = pluginRoot; + env['PLUGIN_DATA'] = pluginData; + const args = server['args']; + servers[name] = { + ...server, + command: expandPath(server['command']), + ...(Array.isArray(args) ? { args: args.map((argument) => (typeof argument === 'string' ? expandPlaceholders(argument) : argument)) } : {}), + cwd: typeof server['cwd'] === 'string' ? expandPath(server['cwd']) : pluginRoot, + env, + }; + } + if (!expanded) return undefined; + return `${JSON.stringify({ ...document, mcpServers: servers }, null, 2)}\n`; +}; + +const readText = async (path: string): Promise => { + if ((await fileKind(path)) !== 'file') return undefined; + return readFile(path, 'utf8'); +}; + +const empty = (documents?: Readonly<{ readonly 'mcp.json': string }>): CursorAgentPluginsLaunchInspection => + Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); + export const inspectCursorAgentPluginsLaunch = async ( pluginRoot: string, options: InspectCursorAgentPluginsLaunchOptions = {}, @@ -202,31 +259,24 @@ export const inspectCursorAgentPluginsLaunch = async ( // An unreadable or non-regular receipt is the install-comparison lane's finding; here it is simply absent. expansion = undefined; } - const original = expansion?.documents['mcp.json']; - const documents = original === undefined ? undefined : Object.freeze({ 'mcp.json': original }); const mcpPath = resolve(pluginRoot, 'mcp.json'); - // The Agent Plugins byte lane owns a missing, non-regular, or unparsable document. - if ((await fileKind(mcpPath)) !== 'file') { - return Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); - } - let document: unknown; - try { - document = JSON.parse(await readFile(mcpPath, 'utf8')) as unknown; - } catch { - return Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); - } - const servers = stdioServers(document); - if (servers.length === 0) { - return Object.freeze({ diagnostics: Object.freeze([]), ...(documents === undefined ? {} : { documents }) }); - } - const names = Object.freeze(servers.map((server) => server.name)); + const installedText = await readText(mcpPath); if (expansion === undefined) { + // The Agent Plugins byte lane owns a missing, non-regular, or unparsable document. + if (installedText === undefined) return empty(); + let document: unknown; + try { + document = JSON.parse(installedText) as unknown; + } catch { + return empty(); + } + const servers = stdioServers(document); // Without a recorded expansion, only servers that still rely on client-side resolution are a finding; // a copy expanded by other means (absolute paths, cwd, §9.1 variables in place) launches as it is. const unexpanded = servers .map((server) => ({ forms: unexpandedForms(server), name: server.name })) .filter((server) => server.forms.length > 0); - if (unexpanded.length === 0) return Object.freeze({ diagnostics: Object.freeze([]) }); + if (unexpanded.length === 0) return empty(); const detail = unexpanded.map((server) => `${server.name}: ${server.forms.join('; ')}`).join(' | '); const unexpandedNames = unexpanded.map((server) => JSON.stringify(server.name)).join(', '); return Object.freeze({ @@ -237,31 +287,47 @@ export const inspectCursorAgentPluginsLaunch = async ( REINSTALL_RECOVERY, 'warning', )]), - launch: Object.freeze({ servers: names, state: 'unexpanded' }), + launch: Object.freeze({ servers: Object.freeze(servers.map((server) => server.name)), state: 'unexpanded' }), }); } - const issues = await driftIssues(pluginRoot, expansion, servers, caseInsensitive); - const launchBase = Object.freeze({ pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot, servers: names }); - if (issues.length > 0) { - return Object.freeze({ + + // A recorded expansion is proven, never trusted: the installed bytes must be exactly the expansion + // of the recorded document, and only then is that document the one the Agent Plugins byte lane validates. + const original = expansion.documents['mcp.json']; + const expected = original === undefined ? undefined : expandAgentPluginsMcpForCursor(original, expansion.pluginRoot, expansion.pluginData); + const drift = (issues: readonly string[], servers: readonly string[], documents?: Readonly<{ readonly 'mcp.json': string }>): CursorAgentPluginsLaunchInspection => + Object.freeze({ diagnostics: freezeDiagnostics([finding( `Cursor plugin entry ${JSON.stringify(pluginRoot)} recorded an Agent Plugins placeholder expansion that no longer describes the installed copy: ${issues.join('; ')}.`, `${REINSTALL_RECOVERY} A copy moved or edited after install must be reinstalled at its current location.`, 'error', )]), ...(documents === undefined ? {} : { documents }), - launch: Object.freeze({ ...launchBase, state: 'drifted' }), + launch: Object.freeze({ pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot, servers, state: 'drifted' }), }); + if (original === undefined || expected === undefined) { + return drift(['the receipt records an expansion but no pre-expansion mcp.json with a stdio server to expand'], Object.freeze([])); + } + if (installedText === undefined) { + return drift(['mcp.json is missing or not a regular file although the receipt recorded its expansion'], Object.freeze([])); } + const documents = Object.freeze({ 'mcp.json': original }); + const servers = stdioServers(JSON.parse(expected) as unknown); + const names = Object.freeze(servers.map((server) => server.name)); + if (installedText !== expected) { + return drift(['the installed mcp.json is not the expansion of the recorded document (edited or replaced after install)'], names); + } + const issues = await driftIssues(pluginRoot, expansion, servers, caseInsensitive); + if (issues.length > 0) return drift(issues, names, documents); return Object.freeze({ diagnostics: freezeDiagnostics([finding( `Cursor plugin entry ${JSON.stringify(pluginRoot)} is an Agent Plugins package whose stdio server${servers.length === 1 ? '' : 's'} ` + `${names.map((name) => JSON.stringify(name)).join(', ')} were expanded for Cursor at install (provenance: derived; Cursor 3.18.25 expands no Agent Plugins placeholder itself): ` + - `PLUGIN_ROOT=${JSON.stringify(expansion.pluginRoot)}, PLUGIN_DATA=${JSON.stringify(expansion.pluginData)}; every expanded path resolves and the pre-expansion mcp.json is validated against the Agent Plugins 1.0.0 contract.`, + `PLUGIN_ROOT=${JSON.stringify(expansion.pluginRoot)}, PLUGIN_DATA=${JSON.stringify(expansion.pluginData)}; the installed mcp.json is byte-identical to the expansion of the recorded document, every expanded path resolves, and the recorded document is validated against the Agent Plugins 1.0.0 contract.`, 'No action needed; reinstall with the emitted `install.mjs` after moving the copy.', 'info', )]), - ...(documents === undefined ? {} : { documents }), - launch: Object.freeze({ ...launchBase, state: 'expanded' }), + documents, + launch: Object.freeze({ pluginData: expansion.pluginData, pluginRoot: expansion.pluginRoot, servers: names, state: 'expanded' }), }); }; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 99fe7dfce..1ed4b0beb 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -430,7 +430,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { '// A plugin-relative ./ path resolves against the plugin root (spec 7.2.1); anything else is expanded in place.', "const expandPath = (value) => value.startsWith('./') ? join(destination, value.slice(2)) : expandPlaceholders(value);", '// Every stdio server rewritten into the form Cursor launches; undefined when the document has none to expand', - '// (skills-only or remote-only packs stay byte-identical to the bundle).', + '// (skills-only or remote-only packs stay byte-identical to the bundle). Doctor recomputes this exact output', + '// from the receipt (install/cursor-agent-plugins-launch.ts expandAgentPluginsMcpForCursor); keep the two in step.', 'const expandAgentPluginsMcp = (text) => {', ' let document;', ' try { document = JSON.parse(text); } catch { return undefined; }', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index f9e66db58..7a7413538 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -432,6 +432,40 @@ it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the expect(ab7325(moved)[0]?.message).toContain(`the receipt expanded PLUGIN_ROOT to ${JSON.stringify(destination)} but the package is installed at ${JSON.stringify(join(installRoot, 'moved'))}`); // The moved copy's on-disk mcp.json is still validated against the recorded bundle document, not its expanded bytes. expect(ab7320Errors(moved)).toEqual([]); + await rm(join(installRoot, 'moved'), { recursive: true }); + + // 5. An edit to the installed copy that keeps every path valid (a bare command renamed) is still drift: + // the installed bytes must equal the expansion of the recorded document, and the byte lane then + // validates the installed bytes themselves (which are not Agent Plugins-conformant), so the entry is corrupt. + await cp(bundle, join(fixture.root, 'bundle-again'), { recursive: true }); + await new Promise((resolveInstall, reject) => { + const child = spawn(process.execPath, [join(fixture.root, 'bundle-again', 'install.mjs')], { cwd: join(fixture.root, 'bundle-again'), env: { ...process.env, HOME: fixture.home }, stdio: ['ignore', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.stderr.on('data', (chunk: Buffer) => { output += chunk.toString(); }); + child.on('error', reject); + child.on('exit', (code) => code === 0 ? resolveInstall() : reject(new Error(`install.mjs exited ${String(code)}: ${output}`))); + }); + const installedMcp = await readFile(join(destination, 'mcp.json'), 'utf8'); + await writeFile(join(destination, 'mcp.json'), installedMcp.replace('"command": "node"', '"command": "bun"')); + const edited = await doctor(); + expect(ab7325(edited)).toEqual([expect.objectContaining({ + message: expect.stringContaining('the installed mcp.json is not the expansion of the recorded document (edited or replaced after install)'), + severity: 'error', + })]); + expect(ab7320Errors(edited).length).toBeGreaterThan(0); + expect(hostReport(edited, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ launch: expect.objectContaining({ state: 'drifted' }), name: 'expanded', state: 'corrupt' }), + ]); + + // 6. The expanded document removed altogether while the receipt still records it. + await rm(join(destination, 'mcp.json')); + const removed = await doctor(); + expect(ab7325(removed).map((entry) => entry.severity)).toEqual(['error']); + expect(ab7325(removed)[0]?.message).toContain('mcp.json is missing or not a regular file although the receipt recorded its expansion'); + expect(hostReport(removed, 'cursor').inventory.findings).toEqual([ + expect.objectContaining({ launch: { pluginData, pluginRoot: destination, servers: [], state: 'drifted' }, state: 'corrupt' }), + ]); } finally { await fixture.cleanup(); } From 50679e380d6e60181c08cccdc5fa84fa5e0d78df Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 4 Sep 2026 00:44:02 +0000 Subject: [PATCH 4/4] fix(doctor): renumber the Cursor Agent Plugins launch proof to AB7326 (AB7325 is the Claude load-refusal code since #479) --- .changeset/426-cursor-agent-plugins-expansion.md | 2 +- docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md | 2 +- docs/diagnostics.md | 6 +++--- docs/framework-mode.md | 4 ++-- packages/agent-bundle/README.md | 2 +- .../src/adapters/capabilities/portable-1.0.0.json | 6 +++--- .../src/install/cursor-agent-plugins-launch.ts | 2 +- packages/agent-bundle/src/install/doctor.ts | 2 +- packages/agent-bundle/src/install/surface.ts | 2 +- packages/agent-bundle/tests/doctor.test.ts | 4 ++-- packages/agent-bundle/tests/host-install-proof.test.ts | 2 +- packages/agent-bundle/tests/install-surface.test.ts | 2 +- packages/agent-bundle/tests/support/host-install.ts | 8 ++++---- website/docs/en/guide/distribution/installation.mdx | 4 ++-- website/docs/en/reference/cli.mdx | 2 +- website/docs/zh/guide/distribution/installation.mdx | 4 ++-- website/docs/zh/reference/cli.mdx | 2 +- 17 files changed, 28 insertions(+), 28 deletions(-) diff --git a/.changeset/426-cursor-agent-plugins-expansion.md b/.changeset/426-cursor-agent-plugins-expansion.md index a60468a0e..bbc417e6b 100644 --- a/.changeset/426-cursor-agent-plugins-expansion.md +++ b/.changeset/426-cursor-agent-plugins-expansion.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Expand Agent Plugins placeholders for Cursor at install time. The `install.mjs` emitted with a `portable` bundle now rewrites `mcp.json` in the `~/.cursor/plugins/local/` copy — `${PLUGIN_ROOT}` to the absolute plugin root, `${PLUGIN_DATA}` to `~/.cursor/agent-bundle/plugin-data/` (created), an omitted `cwd` to the plugin root, plugin-relative `./` commands to absolute paths, and `PLUGIN_ROOT`/`PLUGIN_DATA` into every stdio server's `env` — because Cursor 3.18.25 performs none of that resolution and every spec-shaped stdio server failed to spawn there. The bundle itself is untouched, the pre-expansion document is recorded in the install receipt (`cursorExpansion`), reruns stay idempotent and older unexpanded copies are replaced on the next run. `agent-bundle doctor --host cursor` validates the Agent Plugins contract (`AB7320`) against the recorded document and adds `AB7325` (`expanded` / `unexpanded` / `drifted`) for the launch proof; `cursor`-target bundles are never rewritten. (#482) +Expand Agent Plugins placeholders for Cursor at install time. The `install.mjs` emitted with a `portable` bundle now rewrites `mcp.json` in the `~/.cursor/plugins/local/` copy — `${PLUGIN_ROOT}` to the absolute plugin root, `${PLUGIN_DATA}` to `~/.cursor/agent-bundle/plugin-data/` (created), an omitted `cwd` to the plugin root, plugin-relative `./` commands to absolute paths, and `PLUGIN_ROOT`/`PLUGIN_DATA` into every stdio server's `env` — because Cursor 3.18.25 performs none of that resolution and every spec-shaped stdio server failed to spawn there. The bundle itself is untouched, the pre-expansion document is recorded in the install receipt (`cursorExpansion`), reruns stay idempotent and older unexpanded copies are replaced on the next run. `agent-bundle doctor --host cursor` validates the Agent Plugins contract (`AB7320`) against the recorded document and adds `AB7326` (`expanded` / `unexpanded` / `drifted`) for the launch proof; `cursor`-target bundles are never rewritten. (#482) diff --git a/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md b/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md index 490900ddc..c512ce7d8 100644 --- a/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md +++ b/docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md @@ -178,7 +178,7 @@ the §7.2.1/§9 resolution itself, for the Cursor copy only: `{ pluginRoot, pluginData, documents: { "mcp.json": } }`. - `agent-bundle doctor --host cursor` validates the Agent Plugins contract (`AB7320`) against the shipped document from the receipt and proves the - expansion (`AB7325`: `expanded`, `unexpanded` for a copy still in spec + expansion (`AB7326`: `expanded`, `unexpanded` for a copy still in spec shape, `drifted` for a moved/duplicated/edited copy). - Provenance is `derived` everywhere it is reported (`portable-1.0.0.json` `plugin.cursorLocalInstall.placeholderExpansion`); diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 30bd09f0b..8db5f5c49 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -36,7 +36,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7326` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), and the Cursor Agent Plugins launch proof (`AB7326`; see below). | +| `AB7300`–`AB7326` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7326`; see below), and the Cursor Agent Plugins launch proof (`AB7326`; see below). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | @@ -879,7 +879,7 @@ The JSON report exposes the same facts: `hosts[].inventory.findings[].errors`, report prints the comparison as `installed copy: load failed (installed , refused by the host: )`. -## Read-only Doctor Cursor Agent Plugins launch proof (`AB7325`) +## Read-only Doctor Cursor Agent Plugins launch proof (`AB7326`) Cursor 3.18.25 loads Agent Plugins 1.0.0 packages from `~/.cursor/plugins/local/` but spawns their stdio servers without @@ -900,4 +900,4 @@ placeholders itself. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB7325` | info / warning / error | Info (`launch.state = expanded`): the receipt's expansion still describes the installed copy — same plugin root, existing data directory, no placeholder left, absolute `cwd` and plugin-root `command`/`args` paths that exist, `PLUGIN_ROOT` / `PLUGIN_DATA` equal to the recorded values. Warning (`unexpanded`): an Agent Plugins install without a recorded expansion whose stdio servers still rely on the spec forms Cursor does not resolve (the message lists the forms per server); Cursor reports `spawn … ENOENT` / `MODULE_NOT_FOUND` for them. Error (`drifted`, entry `corrupt`): the installed `mcp.json` is not byte-identical to the expansion Doctor recomputes from the recorded document (edited, replaced, or removed after install), the recorded expansion names another plugin root (the copy was moved or duplicated), the data directory or an expanded path no longer exists, or the environment no longer carries the recorded values. Only a byte-identical copy has its recorded document validated by `AB7320`; a drifted copy is validated as the bytes on disk. Packages without stdio servers, and copies already carrying absolute paths with the §9.1 variables, produce no finding. | Reinstall with the bundle's emitted `install.mjs` at the copy's current location; the Cursor-target (`.cursor-plugin/plugin.json`) bundle is never rewritten and is not subject to this check. | +| `AB7326` | info / warning / error | Info (`launch.state = expanded`): the receipt's expansion still describes the installed copy — same plugin root, existing data directory, no placeholder left, absolute `cwd` and plugin-root `command`/`args` paths that exist, `PLUGIN_ROOT` / `PLUGIN_DATA` equal to the recorded values. Warning (`unexpanded`): an Agent Plugins install without a recorded expansion whose stdio servers still rely on the spec forms Cursor does not resolve (the message lists the forms per server); Cursor reports `spawn … ENOENT` / `MODULE_NOT_FOUND` for them. Error (`drifted`, entry `corrupt`): the installed `mcp.json` is not byte-identical to the expansion Doctor recomputes from the recorded document (edited, replaced, or removed after install), the recorded expansion names another plugin root (the copy was moved or duplicated), the data directory or an expanded path no longer exists, or the environment no longer carries the recorded values. Only a byte-identical copy has its recorded document validated by `AB7320`; a drifted copy is validated as the bytes on disk. Packages without stdio servers, and copies already carrying absolute paths with the §9.1 variables, produce no finding. | Reinstall with the bundle's emitted `install.mjs` at the copy's current location; the Cursor-target (`.cursor-plugin/plugin.json`) bundle is never rewritten and is not subject to this check. | diff --git a/docs/framework-mode.md b/docs/framework-mode.md index 83cebcd63..885ca31e6 100644 --- a/docs/framework-mode.md +++ b/docs/framework-mode.md @@ -504,7 +504,7 @@ that expansion itself in the `~/.cursor/plugins/local/` copy of as `PLUGIN_DATA`, plugin-root `cwd`, resolved `./` command, `PLUGIN_ROOT` / `PLUGIN_DATA` in each stdio server's `env`), keeps the shipped document in the install receipt (`cursorExpansion`), and `doctor` proves the expansion with -`AB7325` while validating the Agent Plugins contract against the shipped +`AB7326` while validating the Agent Plugins contract against the shipped document. The bundle stays spec-conformant; the provenance is `derived`. The framework CLI performs those same operations: @@ -562,6 +562,6 @@ plugin's manifest hook registration as `registered`, `stale` (a unparsable; `AB7324` reports a staged marketplace as imported or still awaiting the Customize step, and `doctor --from` resolves a marketplace-mode bundle to that staged copy instead of reporting it missing. For Agent Plugins -installs, `AB7325` reports the installer's placeholder expansion as +installs, `AB7326` reports the installer's placeholder expansion as `expanded`, `unexpanded` (spec forms Cursor cannot launch), or `drifted` (moved, duplicated, or edited after the expansion was recorded). diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 6d3288174..9e64bc32a 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -203,7 +203,7 @@ variables; omitted `cwd` → home directory; `./` commands → workspace folder) rewrites `mcp.json` in that copy only with absolute paths (`PLUGIN_DATA` = `~/.cursor/agent-bundle/plugin-data/`, created), keeps the shipped document in the install receipt (`cursorExpansion`), and -`agent-bundle doctor --host cursor` proves the expansion (`AB7325`). The bundle +`agent-bundle doctor --host cursor` proves the expansion (`AB7326`). The bundle itself stays spec-conformant for other Agent Plugins clients. Cursor installation is user-scoped. Claude also accepts `--scope project` and 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 cab2f9919..5246f2fcc 100644 --- a/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/portable-1.0.0.json @@ -77,7 +77,7 @@ "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.", "2026-09-03: Re-verified on the same Cursor 3.18.25 build (realCommit 280eca2911f1…af3; no newer stable release): the cwd, args, and default-cwd gaps reproduce, and single-variable probes additionally show ${PLUGIN_ROOT}/${PLUGIN_DATA} unexpanded in env values (spec 9.2), the reserved PLUGIN_ROOT/PLUGIN_DATA subprocess variables absent (spec 9.1), and plugin-relative ./ commands resolved against the workspace folder (spec 7.2.1); recorded with log excerpts and captures in docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md; not submitted to Cursor by maintainer decision.", - "2026-09-03: framework-side support for the Cursor gap (#426): the emitted install.mjs detects an Agent Plugins pack (root plugin.json with an agent-plugins.org $schema, no .cursor-plugin/plugin.json) and rewrites mcp.json in the ~/.cursor/plugins/local/ copy only — ${PLUGIN_ROOT} → the absolute plugin root, ${PLUGIN_DATA} → ~/.cursor/agent-bundle/plugin-data/ (created by the installer), an omitted cwd → the plugin root, ./ commands → absolute paths under it, and PLUGIN_ROOT/PLUGIN_DATA added to every stdio server's env — because Cursor's spawn honours absolute command, args, cwd and env verbatim (the control run that connected). The bundle stays spec-conformant; the pre-expansion mcp.json is kept in the install receipt (cursorExpansion) and Doctor validates it against the Agent Plugins contract while AB7325 proves the expanded paths (expanded / unexpanded / drifted). Provenance: derived — Cursor still expands nothing itself, so the pathTokens rows above describe the bundle, not what Cursor resolves." + "2026-09-03: framework-side support for the Cursor gap (#426): the emitted install.mjs detects an Agent Plugins pack (root plugin.json with an agent-plugins.org $schema, no .cursor-plugin/plugin.json) and rewrites mcp.json in the ~/.cursor/plugins/local/ copy only — ${PLUGIN_ROOT} → the absolute plugin root, ${PLUGIN_DATA} → ~/.cursor/agent-bundle/plugin-data/ (created by the installer), an omitted cwd → the plugin root, ./ commands → absolute paths under it, and PLUGIN_ROOT/PLUGIN_DATA added to every stdio server's env — because Cursor's spawn honours absolute command, args, cwd and env verbatim (the control run that connected). The bundle stays spec-conformant; the pre-expansion mcp.json is kept in the install receipt (cursorExpansion) and Doctor validates it against the Agent Plugins contract while AB7326 proves the expanded paths (expanded / unexpanded / drifted). Provenance: derived — Cursor still expands nothing itself, so the pathTokens rows above describe the bundle, not what Cursor resolves." ], "legacySse": { "reason": "2026-09-02: Agent Plugins 1.0.0 §7.2.1 defines the deprecated HTTP+SSE variant as OPTIONAL for clients; the compiler emits only the required modern transports and rejects a legacy transport with AB4339 (https://agent-plugins.org/specification).", @@ -156,11 +156,11 @@ "placeholderExpansion": { "evidence": [ "2026-09-02/03: Cursor 3.18.25 (docs/audits/2026-09-03-agent-plugins-cursor-ide-proof.md) fails every spec-shaped stdio launch (cwd \"${PLUGIN_ROOT}\" → spawn node ENOENT; args \"${PLUGIN_ROOT}/…\" → MODULE_NOT_FOUND under $HOME; omitted cwd → $HOME; env values and the §9.1 variables unexpanded/absent; \"./…\" commands resolved against the workspace) while the same server with absolute paths connects (connect_success, stable heartbeat).", - "2026-09-03: the emitted install.mjs performs that expansion for the Cursor copy at install time and records it in the receipt; tests/install-surface.test.ts (bundle untouched, copy expanded, idempotent rerun, same-version re-expansion, older unexpanded copies repaired, skills-only and Cursor Plugin bundles untouched), tests/doctor.test.ts (AB7325 unexpanded → expanded → drifted → moved) and the portable host-install proof (isolated Cursor home, Doctor AB7325 info) pin it." + "2026-09-03: the emitted install.mjs performs that expansion for the Cursor copy at install time and records it in the receipt; tests/install-surface.test.ts (bundle untouched, copy expanded, idempotent rerun, same-version re-expansion, older unexpanded copies repaired, skills-only and Cursor Plugin bundles untouched), tests/doctor.test.ts (AB7326 unexpanded → expanded → drifted → moved) and the portable host-install proof (isolated Cursor home, Doctor AB7326 info) pin it." ], "installer": "install.mjs (emitted with the portable bundle)", "provenance": "derived", - "reason": "Cursor 3.18.25 expands no Agent Plugins placeholder, provides no PLUGIN_ROOT/PLUGIN_DATA, defaults an omitted cwd to the home directory and resolves ./ commands against the workspace (spec §7.2.1/§9); the emitted installer rewrites mcp.json in the ~/.cursor/plugins/local/ copy with absolute paths (PLUGIN_DATA = ~/.cursor/agent-bundle/plugin-data/, created), keeps the shipped document in the receipt, and Doctor proves the expanded paths (AB7325). Never claimed as Cursor behaviour: the bundle itself still relies on the consuming client.", + "reason": "Cursor 3.18.25 expands no Agent Plugins placeholder, provides no PLUGIN_ROOT/PLUGIN_DATA, defaults an omitted cwd to the home directory and resolves ./ commands against the workspace (spec §7.2.1/§9); the emitted installer rewrites mcp.json in the ~/.cursor/plugins/local/ copy with absolute paths (PLUGIN_DATA = ~/.cursor/agent-bundle/plugin-data/, created), keeps the shipped document in the receipt, and Doctor proves the expanded paths (AB7326). Never claimed as Cursor behaviour: the bundle itself still relies on the consuming client.", "state": "supported" } }, diff --git a/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts b/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts index 44377c88c..31dde6e86 100644 --- a/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts +++ b/packages/agent-bundle/src/install/cursor-agent-plugins-launch.ts @@ -50,7 +50,7 @@ export interface InspectCursorAgentPluginsLaunchOptions { readonly caseInsensitivePaths?: boolean; } -const DIAGNOSTIC_CODE = 'AB7325'; +const DIAGNOSTIC_CODE = 'AB7326'; const REINSTALL_RECOVERY = "Reinstall the package with its bundle's emitted `install.mjs`, which expands the Agent Plugins placeholders for the Cursor copy " + '(absolute plugin root and data directory, plugin-root `cwd`, resolved `./` command, `PLUGIN_ROOT`/`PLUGIN_DATA` environment) and records them in the receipt.'; diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 5a5aec805..6c3593bec 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -605,7 +605,7 @@ const installedCursorStaticIssues = async ( target: 'cursor', }); if (!isAgentPluginsManifest(installed)) return symlinks; - // The Cursor copy of an expanded package is conformant only as the bundle shipped it (AB7325 proves the expansion). + // The Cursor copy of an expanded package is conformant only as the bundle shipped it (AB7326 proves the expansion). const documents = launch?.documents; const [portable, containment] = await Promise.all([ validatePortablePluginFiles({ diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 1ed4b0beb..9418afbe6 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -154,7 +154,7 @@ const portableInstructions = (): string[] => [ '(created by the installer), an omitted `cwd` becomes the plugin root, `./` commands resolve against', 'it, and every stdio server gains `PLUGIN_ROOT` / `PLUGIN_DATA` in its environment. The bundle itself', `stays spec-conformant; the pre-expansion document is kept in \`${installReceiptFile}\` (\`cursorExpansion\`),`, - 'and `agent-bundle doctor --host cursor` verifies the expanded paths (`AB7325`). Nothing is changed for', + 'and `agent-bundle doctor --host cursor` verifies the expanded paths (`AB7326`). Nothing is changed for', 'other Agent Plugins clients, which expand the placeholders themselves.', '', '### Reinstall after a same-version rebuild', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 7a7413538..3bd2bcd29 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -312,12 +312,12 @@ it('validates root plugin.json installs that declare an Agent Plugins schema aga } }); -it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the emitted installer\'s expansion is verified, drift is corrupt (AB7325)', async () => { +it('proves Agent Plugins stdio launch on Cursor: unexpanded spec forms warn, the emitted installer\'s expansion is verified, drift is corrupt (AB7326)', async () => { const fixture = await temporaryDoctor(); const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); const pluginSchema = 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json'; const mcpSchema = 'https://agent-plugins.org/schemas/1.0.0/mcp.schema.json'; - const ab7325 = (report: DoctorReport) => report.diagnostics.filter((entry) => entry.code === 'AB7325'); + const ab7325 = (report: DoctorReport) => report.diagnostics.filter((entry) => entry.code === 'AB7326'); const ab7320Errors = (report: DoctorReport) => report.diagnostics.filter((entry) => entry.code === 'AB7320' && entry.severity === 'error'); const doctor = () => runDoctor({ endpointDirectory: fixture.endpointDirectory, home: fixture.home, hosts: ['cursor'] }); try { diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index bd0648318..2aaee15c4 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -491,7 +491,7 @@ it( pluginVariables: { allowedLocations: 'args/env values/cwd only', cursorExpansion: { - doctor: 'AB7325 expanded', + doctor: 'AB7326 expanded', installedCopy: 'PLUGIN_ROOT/PLUGIN_DATA absolute, cwd = plugin root, PLUGIN_ROOT/PLUGIN_DATA env set, no placeholder left', pluginData: '.cursor/agent-bundle/plugin-data/host-install-portable-proof', receipt: 'cursorExpansion records the bundle mcp.json verbatim', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index ecd1be74b..0acf62735 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -133,7 +133,7 @@ it('documents native Agent Plugins clients for the portable profile', () => { // The Cursor-only placeholder expansion is documented where the installer is (#426). expect(install).toContain('### Cursor placeholder expansion'); expect(install).toContain('`~/.cursor/agent-bundle/plugin-data/`'); - expect(install).toContain('`AB7325`'); + expect(install).toContain('`AB7326`'); expect(install).toContain('The bundle itself'); }); diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index 209bd215b..994edbdba 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -332,7 +332,7 @@ export interface PortableHostInstallReport { readonly pluginVariables: { readonly allowedLocations: 'args/env values/cwd only'; readonly cursorExpansion: { - readonly doctor: 'AB7325 expanded'; + readonly doctor: 'AB7326 expanded'; readonly installedCopy: 'PLUGIN_ROOT/PLUGIN_DATA absolute, cwd = plugin root, PLUGIN_ROOT/PLUGIN_DATA env set, no placeholder left'; readonly pluginData: string; readonly receipt: 'cursorExpansion records the bundle mcp.json verbatim'; @@ -1645,10 +1645,10 @@ export const runPortableHostInstallProof = async ( `Portable installed bytes failed the pinned Agent Plugins byte lane: ${JSON.stringify(contractDiagnostics)}`, ); const doctorReport = await runDoctor({ home, hosts: ['cursor'] }); - const launchFindings = doctorReport.diagnostics.filter((entry) => entry.code === 'AB7325'); + const launchFindings = doctorReport.diagnostics.filter((entry) => entry.code === 'AB7326'); assertProof( launchFindings.length === 1 && launchFindings[0]?.severity === 'info' && launchFindings[0].message.includes('were expanded for Cursor at install'), - `Doctor did not prove the Cursor expansion (AB7325): ${JSON.stringify(launchFindings)}`, + `Doctor did not prove the Cursor expansion (AB7326): ${JSON.stringify(launchFindings)}`, ); assertProof( !doctorReport.diagnostics.some((entry) => entry.code === 'AB7320' && entry.severity === 'error'), @@ -1696,7 +1696,7 @@ export const runPortableHostInstallProof = async ( pluginVariables: Object.freeze({ allowedLocations: 'args/env values/cwd only', cursorExpansion: Object.freeze({ - doctor: 'AB7325 expanded', + doctor: 'AB7326 expanded', installedCopy: 'PLUGIN_ROOT/PLUGIN_DATA absolute, cwd = plugin root, PLUGIN_ROOT/PLUGIN_DATA env set, no placeholder left', pluginData: normalizedRelative(home, pluginData), receipt: 'cursorExpansion records the bundle mcp.json verbatim', diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index f63a3db07..d181ef23c 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -54,7 +54,7 @@ so every spec-shaped stdio server fails to spawn. The Cursor copy gets the absol plugin-root `cwd`, resolved `./` commands, and `PLUGIN_ROOT` / `PLUGIN_DATA` in each stdio server's environment. The bundle itself is untouched and stays spec-conformant for other Agent Plugins clients; the pre-expansion document is kept in the install receipt (`cursorExpansion`), -and `agent-bundle doctor --host cursor` proves the expansion with `AB7325`. Nothing here is +and `agent-bundle doctor --host cursor` proves the expansion with `AB7326`. Nothing here is Cursor behaviour: the provenance is `derived`, and a `cursor`-target bundle is never rewritten. Artifact validation rejects a built-in target @@ -125,7 +125,7 @@ installed (or byte-identical existing) copy carries `errors`, instead of reporti | `AB7322` | info / error | Info when an installed Cursor plugin registers plugin-scoped hooks from its manifest and every command's script exists under the plugin root; error when the declared hooks file is missing or malformed, or an executed script is absent. | | `AB7323` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory, so Cursor would deliver that hook twice; manifest registration alone is sufficient. | | `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). | -| `AB7325` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. | +| `AB7326` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. | | `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | ## Next diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index a865f17d5..ec4d01a6b 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -113,7 +113,7 @@ Cursor it also proves plugin-scoped hook registration from the installed manifes when `~/.cursor/hooks.json` would deliver a plugin hook a second time (`AB7323`), and tracks a staged marketplace from `unregistered` to `registered` once Cursor imports it (`AB7324`). For an Agent Plugins install it proves the emitted installer's placeholder expansion — `expanded`, -`unexpanded` (spec forms Cursor 3.18.25 cannot launch), or `drifted` (`AB7325`). +`unexpanded` (spec forms Cursor 3.18.25 cannot launch), or `drifted` (`AB7326`). ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index db1772115..570dade16 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -48,7 +48,7 @@ node ./install.mjs 插件根、作为数据目录的 `~/.cursor/agent-bundle/plugin-data/`(由安装器创建)、指向插件根的 `cwd`、已解析的 `./` 命令,以及每个 stdio 服务器环境中的 `PLUGIN_ROOT` / `PLUGIN_DATA`。捆绑包本身不被改动,对其他 Agent Plugins 客户端仍然符合规范;展开前的文档保存在安装回执中(`cursorExpansion`),`agent-bundle doctor --host cursor` 以 -`AB7325` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而 `cursor` target 的捆绑包绝不会被重写。 +`AB7326` 证明这次展开。这里没有任何内容是 Cursor 的行为:来源为 `derived`,而 `cursor` target 的捆绑包绝不会被重写。 产物校验会拒绝缺少必需安装表面的内置 target,因此捆绑包不可能在缺少它所承诺的安装器的情况下发布。 @@ -107,7 +107,7 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list | `AB7322` | info / error | 已安装的 Cursor 插件从清单注册了插件级 hook 且每条命令的脚本都存在于插件根之下时为 info;声明的 hooks 文件缺失或格式错误、或被执行的脚本不存在时为 error。 | | `AB7323` | warning | `~/.cursor/hooks.json` 注册了指向某个已安装插件目录的命令,Cursor 会把该 hook 投递两次;仅靠清单注册就已足够。 | | `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 | -| `AB7325` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 | +| `AB7326` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 | | `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | ## 下一步 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 9bab42d52..7db6314f8 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -108,7 +108,7 @@ agent-bundle install [--from ] [--scope ] [--mode