From b3ba11be08afd12fbb80ecfc8609bab841d607a4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 03:48:26 +0000 Subject: [PATCH 1/5] fix(install): replace Codex plugins with add-only to keep settings Native `codex plugin remove` deletes the plugin config subtree, so `agent-bundle install codex` now refreshes through add-only. Capture config.toml around add and restore it when inventory reported enabled=false or when add fails after mutation. Co-authored-by: Zack Jackson --- .changeset/codex-install-add-only.md | 5 + docs/diagnostics.md | 3 +- packages/agent-bundle/src/install/doctor.ts | 2 +- packages/agent-bundle/src/install/install.ts | 54 +++++- packages/agent-bundle/src/install/surface.ts | 9 +- packages/agent-bundle/tests/doctor.test.ts | 3 +- .../tests/install-surface.test.ts | 4 +- packages/agent-bundle/tests/install.test.ts | 168 +++++++++++++++++- .../en/guide/distribution/installation.mdx | 6 +- website/docs/en/reference/cli.mdx | 6 +- .../zh/guide/distribution/installation.mdx | 5 +- website/docs/zh/reference/cli.mdx | 3 +- 12 files changed, 243 insertions(+), 25 deletions(-) create mode 100644 .changeset/codex-install-add-only.md diff --git a/.changeset/codex-install-add-only.md b/.changeset/codex-install-add-only.md new file mode 100644 index 000000000..95b71d67a --- /dev/null +++ b/.changeset/codex-install-add-only.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Preserve Codex plugin settings across `agent-bundle install codex` replace: refresh with `codex plugin add` only so nested MCP overrides in `config.toml` survive, and restore a plugin-level `enabled = false` after native add resets it. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e85de5c0c..99c171fa5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1371,7 +1371,8 @@ this plugin's name — a copy installed before receipts existed), or **foreign** `plugin list --json` inventory (Doctor runs it once per host and also lists every installed plugin from it; `AB7303` is emitted only when that listing is unusable); the host owns those copies, so replacement runs `claude plugin uninstall ---keep-data` + `install` or `codex plugin remove` + `add`. +--keep-data` + `install` or Codex add-only (`codex plugin add`, preserving +plugin settings and nested MCP overrides in `config.toml`). | Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | | --- | --- | --- | --- | diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index cf8d79aa2..08ecf29fd 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1749,7 +1749,7 @@ const publicHostReplaceRecipe = (host: Exclude, scopeArgum ? `Rerun \`agent-bundle install claude --from ${scopeArguments}\`; same-version content drift is replaced through ` + '`claude plugin uninstall --keep-data` + `claude plugin install` because Claude\'s `plugin update` is version-gated.' : 'Rerun `agent-bundle install codex --from `; same-version content drift is replaced through ' + - '`codex plugin remove` + `codex plugin add`.'; + '`codex plugin add` so plugin settings and nested MCP overrides in config.toml survive.'; /** * `AB7325`: the host lists the plugin but refused to load it. The message diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index cf4179ec5..c8eb191ea 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises'; +import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, posix, resolve } from 'node:path'; @@ -451,16 +451,24 @@ export const parsePublicHostMarketplaces = ( return Object.freeze(marketplaces); }; -export const readCodexMarketplaceSource = async ( - codexRoot: string, - marketplace: string, -): Promise => { - let config: string; +const readCodexConfig = async (codexRoot: string): Promise => { try { - config = await readFile(join(codexRoot, 'config.toml'), 'utf8'); + return await readFile(join(codexRoot, 'config.toml'), 'utf8'); } catch { return undefined; } +}; + +const writeCodexConfig = async (codexRoot: string, contents: string): Promise => { + await writeFile(join(codexRoot, 'config.toml'), contents); +}; + +export const readCodexMarketplaceSource = async ( + codexRoot: string, + marketplace: string, +): Promise => { + const config = await readCodexConfig(codexRoot); + if (config === undefined) return undefined; const headers = new Set([ `[marketplaces.${marketplace}]`, `[marketplaces.${JSON.stringify(marketplace)}]`, @@ -668,7 +676,12 @@ const installPublicCli = async ( } // Decided before any host verb runs, so the marketplace ownership check sees the pre-install state. const recorded = await receiptIdentity(); - if (replaced) { + // Codex `plugin remove` deletes the `[plugins.""]` subtree in config.toml, including nested + // MCP overrides. Native `plugin add` refreshes the cache in place and keeps those tables, so + // replacement is add-only. Native add does reset plugin-level `enabled = false` to `true`; + // the snapshot taken after marketplace add is written back when the inventory said disabled. + const replaceRemovesPlugin = replaced && host !== 'codex'; + if (replaceRemovesPlugin) { await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); } await runHostCommand(runner, identity, host, [ @@ -677,6 +690,13 @@ const installPublicCli = async ( 'add', identity.bundleRoot, ]); + const priorCodexConfig = host === 'codex' && replaced + ? await readCodexConfig(publicHostRoot(host, environment, home)) + : undefined; + const restorePriorCodexConfig = async (): Promise => { + if (priorCodexConfig === undefined) return; + await writeCodexConfig(publicHostRoot(host, environment, home), priorCodexConfig); + }; // Between `marketplace add` and the receipt write, everything this run registered is claimed only in memory. // If the plugin install or the receipt write fails there, reverse what did complete rather than leave // registrations nothing records: a plugin without a receipt would pass the byte-identical fast path on retry @@ -692,6 +712,9 @@ const installPublicCli = async ( ? ['plugin', 'install', id, '--scope', scope] : ['plugin', 'add', id]); pluginInstalled = true; + // Native Codex add resets plugin-level enabled to true; restore the captured settings so a + // disabled plugin stays disabled and nested MCP overrides stay exactly as they were. + if (entry?.enabled === false) await restorePriorCodexConfig(); const state = await recordInstalledState({ environment, home, @@ -712,8 +735,21 @@ const installPublicCli = async ( })); } catch (error) { if (stateRollback !== undefined) await stateRollback(); + try { + await restorePriorCodexConfig(); + } catch (restoreError) { + throw failure( + 'AB7004', + `${errorMessage(error)} Restoring prior Codex plugin settings also failed: ${errorMessage(restoreError)}.`, + host, + ); + } const rollbacks: (readonly string[])[] = [ - ...(pluginInstalled ? [publicHostUninstallArguments(host, id, scope)] : []), + // Codex replace is add-only: the plugin was already installed, so a failed receipt must not + // `plugin remove` (that would delete the settings subtree this path exists to keep). + ...(pluginInstalled && !(host === 'codex' && replaced) + ? [publicHostUninstallArguments(host, id, scope)] + : []), ...(createdMarketplace ? [publicHostMarketplaceRemoveArguments(marketplace)] : []), ]; for (const args of rollbacks) { diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 5e3302f96..b74f9d028 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -130,15 +130,18 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ '', '### Reinstall after a same-version rebuild', '', - '`codex plugin add` re-copies the marketplace snapshot but never deletes files a rebuild removed.', - 'Remove and add again for a clean same-version copy:', + '`codex plugin add` re-copies the marketplace snapshot and keeps plugin settings in `config.toml`.', + '`codex plugin remove` deletes that settings subtree, including nested MCP overrides, so a same-version', + 'refresh is add-only:', '', '```sh', - `codex plugin remove ${pluginId(model)}`, 'codex plugin marketplace add ./', `codex plugin add ${pluginId(model)}`, '```', '', + 'Native add resets a plugin-level `enabled = false` to `true`. The optional `agent-bundle install`', + 'restores that flag from the prior `config.toml` after add.', + '', ...optionalCliReinstall('codex'), '', '### Uninstall', diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index d4420ba25..3d796bb9e 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -2183,7 +2183,8 @@ it('compares the Codex cache copy against the artifact once plugin list --json n }); const staleDiagnostic = stale.diagnostics.find((entry) => entry.code === 'AB7308'); expect(staleDiagnostic).toMatchObject({ severity: 'warning', target: 'codex' }); - expect(staleDiagnostic?.recovery).toContain('codex plugin remove'); + expect(staleDiagnostic?.recovery).toContain('codex plugin add'); + expect(staleDiagnostic?.recovery).not.toContain('codex plugin remove'); } finally { await fixture.cleanup(); } diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 3a345956b..0901e6a56 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -762,7 +762,9 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve const codex = writesFor('codex').get('INSTALL.md') ?? ''; expect(codex).toContain('Reinstall after a same-version rebuild'); - expect(codex).toContain('codex plugin remove install-fixture@install-fixture-marketplace'); + expect(codex).toContain('codex plugin marketplace add ./'); + expect(codex).toContain('codex plugin add install-fixture@install-fixture-marketplace'); + expect(codex.split('### Uninstall')[0]).not.toContain('codex plugin remove'); expect(codex).toContain('--replace'); for (const target of ['cursor', 'portable']) { diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 9672f4883..a3ad915b5 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -543,7 +543,67 @@ it('fails a Claude install (AB7006) when plugin list --json reports load errors } }); -it('honours --replace for Codex through remove + add and fails closed without a usable inventory', async () => { +const codexPluginId = 'install-fixture@install-fixture-marketplace'; +const codexPluginTable = `[plugins.${JSON.stringify(codexPluginId)}]`; +const codexPluginNestedMcp = `${codexPluginTable}.mcp_servers.grok-bot`; + +const writeCodexPluginSettings = async ( + codexHome: string, + pluginEnabled: boolean, +): Promise => { + const config = [ + 'model = "keep-me"', + '', + codexPluginTable, + `enabled = ${pluginEnabled}`, + '', + codexPluginNestedMcp, + 'enabled = false', + '', + '[marketplaces.install-fixture-marketplace]', + 'source_type = "local"', + '', + ].join('\n'); + await mkdir(codexHome, { recursive: true }); + await writeFile(join(codexHome, 'config.toml'), config); + return config; +}; + +/** + * Native Codex `plugin add` resets plugin-level `enabled` to true and leaves + * nested MCP tables in place. `plugin remove` deletes the plugin settings subtree. + */ +const applyNativeCodexPluginMutation = async ( + codexHome: string, + verb: 'add' | 'remove', +): Promise => { + const path = join(codexHome, 'config.toml'); + let config: string; + try { + config = await readFile(path, 'utf8'); + } catch { + return; + } + if (verb === 'remove') { + const kept: string[] = []; + let skipping = false; + for (const line of config.split(/\n/u)) { + const trimmed = line.trim(); + if (trimmed.startsWith('[')) { + skipping = trimmed === codexPluginTable || trimmed.startsWith(`${codexPluginTable}.`); + } + if (!skipping) kept.push(line); + } + await writeFile(path, kept.join('\n')); + return; + } + await writeFile( + path, + config.replace(`${codexPluginTable}\nenabled = false`, `${codexPluginTable}\nenabled = true`), + ); +}; + +it('honours --replace for Codex through add-only and fails closed without a usable inventory', async () => { const fixture = await createHostBundle('codex'); const home = join(fixture.cleanupRoot, 'home'); const codexHome = join(fixture.cleanupRoot, 'codex-home'); @@ -577,11 +637,11 @@ it('honours --replace for Codex through remove + add and fails closed without a previousContentHash: (await treeInventory(installed)).hash, state: 'replaced', }); + // Native remove deletes plugin settings; replacement refreshes through add-only. // No receipt yet, so the marketplace ownership read precedes every host verb. expect(calls.map((call) => call.args.join(' '))).toEqual([ 'plugin list --json', 'plugin marketplace list --json', - 'plugin remove install-fixture@install-fixture-marketplace', `plugin marketplace add ${fixture.bundleRoot}`, 'plugin add install-fixture@install-fixture-marketplace', ]); @@ -604,6 +664,110 @@ it('honours --replace for Codex through remove + add and fails closed without a } }); +it('preserves Codex nested MCP overrides and restores plugin-level disabled state across replace', async () => { + const fixture = await createHostBundle('codex'); + const home = join(fixture.cleanupRoot, 'home'); + const codexHome = join(fixture.cleanupRoot, 'codex-home'); + const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); + await cp(fixture.bundleRoot, installed, { recursive: true }); + const prior = await writeCodexPluginSettings(codexHome, false); + const calls: CommandCall[] = []; + const runner: InstallCommandRunner = { + run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + calls.push(call); + if (isInventoryCall(call)) { + return { code: 0, stderr: '', stdout: JSON.stringify({ + available: [], + installed: [{ + enabled: false, + installed: true, + marketplaceName: 'install-fixture-marketplace', + name: 'install-fixture', + pluginId: codexPluginId, + version: '1.0.0', + }], + }) }; + } + if (args[0] === 'plugin' && args[1] === 'add') { + await applyNativeCodexPluginMutation(codexHome, 'add'); + } + if (args[0] === 'plugin' && args[1] === 'remove') { + await applyNativeCodexPluginMutation(codexHome, 'remove'); + } + return { code: 0, stderr: '', stdout: isMarketplaceListCall(call) ? noMarketplaces(call) : '' }; + }, + }; + try { + const replaced = await installBundle({ + commandRunner: runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex', + replace: true, + scope: 'user', + }); + expect(replaced).toMatchObject({ host: 'codex', state: 'replaced' }); + expect(calls.map((call) => call.args.join(' '))).not.toContain(`plugin remove ${codexPluginId}`); + expect(await readFile(join(codexHome, 'config.toml'), 'utf8')).toBe(prior); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('restores prior Codex plugin settings when add fails during replace', async () => { + const fixture = await createHostBundle('codex'); + const home = join(fixture.cleanupRoot, 'home'); + const codexHome = join(fixture.cleanupRoot, 'codex-home'); + const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); + await cp(fixture.bundleRoot, installed, { recursive: true }); + const prior = await writeCodexPluginSettings(codexHome, false); + const calls: CommandCall[] = []; + const runner: InstallCommandRunner = { + run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + calls.push(call); + if (isInventoryCall(call)) { + return { code: 0, stderr: '', stdout: JSON.stringify({ + available: [], + installed: [{ + enabled: false, + installed: true, + marketplaceName: 'install-fixture-marketplace', + name: 'install-fixture', + pluginId: codexPluginId, + version: '1.0.0', + }], + }) }; + } + if (args[0] === 'plugin' && args[1] === 'add') { + await applyNativeCodexPluginMutation(codexHome, 'add'); + return { code: 1, stderr: 'add exploded', stdout: '' }; + } + return { code: 0, stderr: '', stdout: isMarketplaceListCall(call) ? noMarketplaces(call) : '' }; + }, + }; + try { + const failed = await installBundle({ + commandRunner: runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(failed).toBeInstanceOf(DiagnosticError); + expect((failed as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); + expect((failed as DiagnosticError).diagnostics[0]?.message).toContain('add exploded'); + expect(calls.map((call) => call.args.join(' '))).not.toContain(`plugin remove ${codexPluginId}`); + expect(await readFile(join(codexHome, 'config.toml'), 'utf8')).toBe(prior); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it.each(['claude', 'codex', 'cursor'] as const)( 'refuses --from that names a directory above the %s plugin root instead of probing into it (#555)', async (host) => { diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index f583b295b..9cf652b07 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -169,8 +169,10 @@ location); `uninstall --purge-data --confirm-purge` removes only roots whose rec installation owns them. Pre-existing, shared, marker-less, and otherwise unproven override roots are retained. Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is -version-gated; Codex runs `codex plugin remove` before `add`. The emitted `INSTALL.md` documents -the same recipe per host. +version-gated; Codex replacement is add-only (`codex plugin add`) so plugin settings and nested +MCP overrides in `config.toml` survive — native add resets a plugin-level `enabled = false` to +`true`, and the installer restores that flag from the prior config. The emitted `INSTALL.md` +documents the same recipe per host. The framework CLI derives the artifact-side hash and copy set from `agent-bundle.manifest.json` `files[]`, verifies every listed digest, and does not walk the artifact diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 2d492e70f..ae7797859 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -195,8 +195,10 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies carry an install receipt (`.agent-bundle-install.json`), replacement touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs -`claude plugin uninstall --keep-data` before reinstalling and Codex runs `codex plugin remove` -before `add`. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: +`claude plugin uninstall --keep-data` before reinstalling. Codex replacement is add-only +(`codex plugin add`) so plugin settings and nested MCP overrides survive; a plugin-level +`enabled = false` is restored after native add resets it. Every install writes a lifecycle +receipt (format `agent-bundle-install-receipt/2`: version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for Cursor and Amp local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor marketplace mode — that `uninstall` and `doctor` consume. diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 69b29dede..b9cbeef3d 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -134,8 +134,9 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl `~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall` 配合 `--purge-data --confirm-purge` 只会删除回执证明归该安装独占的根;预先存在、共享、无标记或其他 无法证明归属的覆盖根都会保留。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, -因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 -同样的步骤。 +因为 `plugin update` 受版本门控;Codex 的替换只执行 `codex plugin add`,以便保留 `config.toml` 中的插件设置与嵌套 +MCP 覆盖——原生 add 会把插件级 `enabled = false` 重置为 `true`,安装器会从先前的配置恢复该标志。输出的 +`INSTALL.md` 按宿主记录了同样的步骤。 框架 CLI 从 `agent-bundle.manifest.json` 的 `files[]` 派生产物侧哈希与复制集合,校验每个列出的摘要, 而不遍历产物目录。清单自身与约定的操作员 `.env` / `.env.local` 覆盖层也会被复制并哈希;未列出的 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index b6327b821..9aa87217a 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -184,7 +184,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ 输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 -先运行 `claude plugin uninstall --keep-data` 再重新安装,Codex 则先 `codex plugin remove` 再 `add`。 +先运行 `claude plugin uninstall --keep-data` 再重新安装。Codex 的替换只执行 `codex plugin add`,以便保留插件设置与嵌套 +MCP 覆盖;原生 add 把插件级 `enabled = false` 重置为 `true` 后,安装器会恢复该标志。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 宿主注册、时间戳)——Cursor 与 Amp 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 From 269f87597ee167ae4014e1f5ff33ef3968ef2daa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 03:49:38 +0000 Subject: [PATCH 2/5] test(install): allow INSTALL.md to warn that Codex remove deletes settings The reinstall recipe is add-only; the prose still names plugin remove so operators know why that verb is not in the executable block. Co-authored-by: Zack Jackson --- .changeset/codex-install-add-only.md | 2 +- packages/agent-bundle/tests/install-surface.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/codex-install-add-only.md b/.changeset/codex-install-add-only.md index 95b71d67a..ef6986dbf 100644 --- a/.changeset/codex-install-add-only.md +++ b/.changeset/codex-install-add-only.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Preserve Codex plugin settings across `agent-bundle install codex` replace: refresh with `codex plugin add` only so nested MCP overrides in `config.toml` survive, and restore a plugin-level `enabled = false` after native add resets it. +Preserve Codex plugin settings across `agent-bundle install codex` replace: refresh with `codex plugin add` only so nested MCP overrides in `config.toml` survive, and restore a plugin-level `enabled = false` after native add resets it. (#824) diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 0901e6a56..727b9a8e2 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -764,7 +764,9 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve expect(codex).toContain('Reinstall after a same-version rebuild'); expect(codex).toContain('codex plugin marketplace add ./'); expect(codex).toContain('codex plugin add install-fixture@install-fixture-marketplace'); - expect(codex.split('### Uninstall')[0]).not.toContain('codex plugin remove'); + const reinstall = (codex.split('### Reinstall after a same-version rebuild')[1] ?? '').split('### Uninstall')[0] ?? ''; + const reinstallBlocks = [...reinstall.matchAll(/```sh\n([\s\S]*?)```/gu)].map((match) => match[1]!); + expect(reinstallBlocks.join('\n')).not.toContain('codex plugin remove'); expect(codex).toContain('--replace'); for (const target of ['cursor', 'portable']) { From dde840d362c7c9eecaf59e5def14699e250fc902 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 03:59:32 +0000 Subject: [PATCH 3/5] fix(install): refuse disabled Codex replace without rewriting config Keep native add-only for enabled Codex replacements so nested MCP overrides survive. Pinned Codex has no settings-preserving update API, so disabled or unknown-enablement replacements fail closed before any host mutation instead of capturing or rewriting config.toml. Co-authored-by: Zack Jackson --- .changeset/codex-install-add-only.md | 2 +- docs/diagnostics.md | 7 +- packages/agent-bundle/src/install/doctor.ts | 4 +- packages/agent-bundle/src/install/install.ts | 63 +++--- packages/agent-bundle/src/install/surface.ts | 6 +- .../tests/install-surface.test.ts | 2 + packages/agent-bundle/tests/install.test.ts | 198 ++++++++++++++---- .../en/guide/distribution/installation.mdx | 6 +- website/docs/en/reference/cli.mdx | 5 +- .../zh/guide/distribution/installation.mdx | 3 +- website/docs/zh/reference/cli.mdx | 2 +- 11 files changed, 211 insertions(+), 87 deletions(-) diff --git a/.changeset/codex-install-add-only.md b/.changeset/codex-install-add-only.md index ef6986dbf..e373db012 100644 --- a/.changeset/codex-install-add-only.md +++ b/.changeset/codex-install-add-only.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Preserve Codex plugin settings across `agent-bundle install codex` replace: refresh with `codex plugin add` only so nested MCP overrides in `config.toml` survive, and restore a plugin-level `enabled = false` after native add resets it. (#824) +Replace Codex plugins with `codex plugin add` only so nested MCP overrides in `config.toml` survive. Refuse disabled or unknown-enablement Codex replacements (`AB7004`) because pinned Codex has no settings-preserving update API. (#824) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 99c171fa5..9b9a81bae 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1372,7 +1372,10 @@ this plugin's name — a copy installed before receipts existed), or **foreign** installed plugin from it; `AB7303` is emitted only when that listing is unusable); the host owns those copies, so replacement runs `claude plugin uninstall --keep-data` + `install` or Codex add-only (`codex plugin add`, preserving -plugin settings and nested MCP overrides in `config.toml`). +plugin settings and nested MCP overrides in `config.toml`). A Codex replace +whose plugin list row is `enabled: false` or omits `enabled` is refused +(`AB7004`) before any host verb: pinned Codex has no settings-preserving +update API, and native add would set plugin-level enabled to true. | Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | | --- | --- | --- | --- | @@ -1833,7 +1836,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 5` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has neither its required `documents.plugin` nor Amp `documents.entry`, or whose `documents.entry` / `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor or Amp `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (pinned Codex has no settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 08ecf29fd..cd6651b78 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1749,7 +1749,9 @@ const publicHostReplaceRecipe = (host: Exclude, scopeArgum ? `Rerun \`agent-bundle install claude --from ${scopeArguments}\`; same-version content drift is replaced through ` + '`claude plugin uninstall --keep-data` + `claude plugin install` because Claude\'s `plugin update` is version-gated.' : 'Rerun `agent-bundle install codex --from `; same-version content drift is replaced through ' + - '`codex plugin add` so plugin settings and nested MCP overrides in config.toml survive.'; + '`codex plugin add` so plugin settings and nested MCP overrides in config.toml survive. ' + + 'Replacement is refused (`AB7004`) when plugin list reports `enabled: false` or omits `enabled`, ' + + 'because pinned Codex has no settings-preserving update API.'; /** * `AB7325`: the host lists the plugin but refused to load it. The message diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index c8eb191ea..b9ded63d8 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { lstat, mkdir, readFile, rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, posix, resolve } from 'node:path'; @@ -451,24 +451,16 @@ export const parsePublicHostMarketplaces = ( return Object.freeze(marketplaces); }; -const readCodexConfig = async (codexRoot: string): Promise => { - try { - return await readFile(join(codexRoot, 'config.toml'), 'utf8'); - } catch { - return undefined; - } -}; - -const writeCodexConfig = async (codexRoot: string, contents: string): Promise => { - await writeFile(join(codexRoot, 'config.toml'), contents); -}; - export const readCodexMarketplaceSource = async ( codexRoot: string, marketplace: string, ): Promise => { - const config = await readCodexConfig(codexRoot); - if (config === undefined) return undefined; + let config: string; + try { + config = await readFile(join(codexRoot, 'config.toml'), 'utf8'); + } catch { + return undefined; + } const headers = new Set([ `[marketplaces.${marketplace}]`, `[marketplaces.${JSON.stringify(marketplace)}]`, @@ -674,12 +666,26 @@ const installPublicCli = async ( previousContentHash = installed?.hash ?? previousReceipt?.contentHash; } } - // Decided before any host verb runs, so the marketplace ownership check sees the pre-install state. - const recorded = await receiptIdentity(); // Codex `plugin remove` deletes the `[plugins.""]` subtree in config.toml, including nested // MCP overrides. Native `plugin add` refreshes the cache in place and keeps those tables, so - // replacement is add-only. Native add does reset plugin-level `enabled = false` to `true`; - // the snapshot taken after marketplace add is written back when the inventory said disabled. + // replacement is add-only. Native add also resets plugin-level `enabled = false` to `true`. + // Pinned Codex has no qualified settings-preserving update API (no expected-version write), + // so a replace whose inventory row is disabled or omits `enabled` is refused before any host + // verb mutates config. Enable the plugin in Codex, then replace; otherwise leave it unchanged. + if (replaced && host === 'codex' && entry?.enabled !== true) { + const reason = entry?.enabled === false + ? 'plugin list reports enabled: false' + : 'plugin list omits enabled'; + throw failure( + 'AB7004', + `Cannot replace the Codex install of ${id}: ${reason}. ` + + 'Pinned Codex has no settings-preserving update API, and native `plugin add` resets ' + + 'plugin-level enabled to true. Enable the plugin in Codex before replacing, or leave this install unchanged.', + host, + ); + } + // Decided before any host verb runs, so the marketplace ownership check sees the pre-install state. + const recorded = await receiptIdentity(); const replaceRemovesPlugin = replaced && host !== 'codex'; if (replaceRemovesPlugin) { await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); @@ -690,13 +696,6 @@ const installPublicCli = async ( 'add', identity.bundleRoot, ]); - const priorCodexConfig = host === 'codex' && replaced - ? await readCodexConfig(publicHostRoot(host, environment, home)) - : undefined; - const restorePriorCodexConfig = async (): Promise => { - if (priorCodexConfig === undefined) return; - await writeCodexConfig(publicHostRoot(host, environment, home), priorCodexConfig); - }; // Between `marketplace add` and the receipt write, everything this run registered is claimed only in memory. // If the plugin install or the receipt write fails there, reverse what did complete rather than leave // registrations nothing records: a plugin without a receipt would pass the byte-identical fast path on retry @@ -712,9 +711,6 @@ const installPublicCli = async ( ? ['plugin', 'install', id, '--scope', scope] : ['plugin', 'add', id]); pluginInstalled = true; - // Native Codex add resets plugin-level enabled to true; restore the captured settings so a - // disabled plugin stays disabled and nested MCP overrides stay exactly as they were. - if (entry?.enabled === false) await restorePriorCodexConfig(); const state = await recordInstalledState({ environment, home, @@ -735,15 +731,6 @@ const installPublicCli = async ( })); } catch (error) { if (stateRollback !== undefined) await stateRollback(); - try { - await restorePriorCodexConfig(); - } catch (restoreError) { - throw failure( - 'AB7004', - `${errorMessage(error)} Restoring prior Codex plugin settings also failed: ${errorMessage(restoreError)}.`, - host, - ); - } const rollbacks: (readonly string[])[] = [ // Codex replace is add-only: the plugin was already installed, so a failed receipt must not // `plugin remove` (that would delete the settings subtree this path exists to keep). diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index b74f9d028..1cea1d731 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -139,8 +139,10 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ `codex plugin add ${pluginId(model)}`, '```', '', - 'Native add resets a plugin-level `enabled = false` to `true`. The optional `agent-bundle install`', - 'restores that flag from the prior `config.toml` after add.', + 'Native add resets a plugin-level `enabled = false` to `true`. Pinned Codex has no', + 'settings-preserving update API, so `agent-bundle install` refuses replacement when', + '`plugin list --json` reports `enabled: false` or omits `enabled` (`AB7004`) and leaves', + 'that install unchanged. Enable the plugin in Codex first, then rerun.', '', ...optionalCliReinstall('codex'), '', diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 727b9a8e2..190acf12d 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -767,6 +767,8 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve const reinstall = (codex.split('### Reinstall after a same-version rebuild')[1] ?? '').split('### Uninstall')[0] ?? ''; const reinstallBlocks = [...reinstall.matchAll(/```sh\n([\s\S]*?)```/gu)].map((match) => match[1]!); expect(reinstallBlocks.join('\n')).not.toContain('codex plugin remove'); + expect(codex).toContain('no settings-preserving update API'); + expect(codex).toContain('AB7004'); expect(codex).toContain('--replace'); for (const target of ['cursor', 'portable']) { diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index a3ad915b5..669dcffd1 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -16,7 +16,10 @@ import { runDoctor } from '../src/install/doctor.ts'; import { formatInstallResult } from '../src/install/format.ts'; import { stableJson } from '../src/core/digest.ts'; import { installedBundleInventory, readBundleIdentity } from '../src/install/identity.ts'; -import { installBundle, type InstallCommandRunner } from '../src/install/install.ts'; +import { + installBundle, + type InstallCommandRunner, +} from '../src/install/install.ts'; import { copyInventoryFiles, installReceiptFile, @@ -545,7 +548,7 @@ it('fails a Claude install (AB7006) when plugin list --json reports load errors const codexPluginId = 'install-fixture@install-fixture-marketplace'; const codexPluginTable = `[plugins.${JSON.stringify(codexPluginId)}]`; -const codexPluginNestedMcp = `${codexPluginTable}.mcp_servers.grok-bot`; +const codexPluginNestedMcp = `[plugins.${JSON.stringify(codexPluginId)}.mcp_servers.grok-bot]`; const writeCodexPluginSettings = async ( codexHome: string, @@ -570,8 +573,8 @@ const writeCodexPluginSettings = async ( }; /** - * Native Codex `plugin add` resets plugin-level `enabled` to true and leaves - * nested MCP tables in place. `plugin remove` deletes the plugin settings subtree. + * Native Codex `plugin add` leaves nested MCP tables in place and may rewrite + * unrelated keys. `plugin remove` deletes the plugin settings subtree. */ const applyNativeCodexPluginMutation = async ( codexHome: string, @@ -590,7 +593,7 @@ const applyNativeCodexPluginMutation = async ( for (const line of config.split(/\n/u)) { const trimmed = line.trim(); if (trimmed.startsWith('[')) { - skipping = trimmed === codexPluginTable || trimmed.startsWith(`${codexPluginTable}.`); + skipping = trimmed === codexPluginTable || trimmed.startsWith(`${codexPluginTable.slice(0, -1)}.`); } if (!skipping) kept.push(line); } @@ -599,10 +602,35 @@ const applyNativeCodexPluginMutation = async ( } await writeFile( path, - config.replace(`${codexPluginTable}\nenabled = false`, `${codexPluginTable}\nenabled = true`), + config + .replace(`${codexPluginTable}\nenabled = false`, `${codexPluginTable}\nenabled = true`) + .replace('model = "keep-me"', 'model = "changed-by-add"'), ); }; +const disabledCodexInventory = JSON.stringify({ + available: [], + installed: [{ + enabled: false, + installed: true, + marketplaceName: 'install-fixture-marketplace', + name: 'install-fixture', + pluginId: codexPluginId, + version: '1.0.0', + }], +}); + +const unknownEnablementCodexInventory = JSON.stringify({ + available: [], + installed: [{ + installed: true, + marketplaceName: 'install-fixture-marketplace', + name: 'install-fixture', + pluginId: codexPluginId, + version: '1.0.0', + }], +}); + it('honours --replace for Codex through add-only and fails closed without a usable inventory', async () => { const fixture = await createHostBundle('codex'); const home = join(fixture.cleanupRoot, 'home'); @@ -664,31 +692,19 @@ it('honours --replace for Codex through add-only and fails closed without a usab } }); -it('preserves Codex nested MCP overrides and restores plugin-level disabled state across replace', async () => { +it('preserves Codex nested MCP overrides and concurrent config edits across enabled replace', async () => { const fixture = await createHostBundle('codex'); const home = join(fixture.cleanupRoot, 'home'); const codexHome = join(fixture.cleanupRoot, 'codex-home'); const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); await cp(fixture.bundleRoot, installed, { recursive: true }); - const prior = await writeCodexPluginSettings(codexHome, false); + await writeCodexPluginSettings(codexHome, true); const calls: CommandCall[] = []; const runner: InstallCommandRunner = { run: async (command, args, runOptions) => { const call = { args: [...args], command, cwd: runOptions.cwd }; calls.push(call); - if (isInventoryCall(call)) { - return { code: 0, stderr: '', stdout: JSON.stringify({ - available: [], - installed: [{ - enabled: false, - installed: true, - marketplaceName: 'install-fixture-marketplace', - name: 'install-fixture', - pluginId: codexPluginId, - version: '1.0.0', - }], - }) }; - } + if (isInventoryCall(call)) return { code: 0, stderr: '', stdout: codexInventory('1.0.0') }; if (args[0] === 'plugin' && args[1] === 'add') { await applyNativeCodexPluginMutation(codexHome, 'add'); } @@ -710,37 +726,31 @@ it('preserves Codex nested MCP overrides and restores plugin-level disabled stat }); expect(replaced).toMatchObject({ host: 'codex', state: 'replaced' }); expect(calls.map((call) => call.args.join(' '))).not.toContain(`plugin remove ${codexPluginId}`); - expect(await readFile(join(codexHome, 'config.toml'), 'utf8')).toBe(prior); + const config = await readFile(join(codexHome, 'config.toml'), 'utf8'); + // Add-only keeps nested MCP overrides. The installer never writes config.toml, so a + // concurrent/native rewrite of an unrelated key is not reverted. + expect(config).toContain(`${codexPluginTable}\nenabled = true`); + expect(config).toContain(`${codexPluginNestedMcp}\nenabled = false`); + expect(config).toContain('model = "changed-by-add"'); + expect(config).not.toContain('model = "keep-me"'); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } }); -it('restores prior Codex plugin settings when add fails during replace', async () => { +it('keeps Codex plugin settings when add fails during enabled replace', async () => { const fixture = await createHostBundle('codex'); const home = join(fixture.cleanupRoot, 'home'); const codexHome = join(fixture.cleanupRoot, 'codex-home'); const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); await cp(fixture.bundleRoot, installed, { recursive: true }); - const prior = await writeCodexPluginSettings(codexHome, false); + await writeCodexPluginSettings(codexHome, true); const calls: CommandCall[] = []; const runner: InstallCommandRunner = { run: async (command, args, runOptions) => { const call = { args: [...args], command, cwd: runOptions.cwd }; calls.push(call); - if (isInventoryCall(call)) { - return { code: 0, stderr: '', stdout: JSON.stringify({ - available: [], - installed: [{ - enabled: false, - installed: true, - marketplaceName: 'install-fixture-marketplace', - name: 'install-fixture', - pluginId: codexPluginId, - version: '1.0.0', - }], - }) }; - } + if (isInventoryCall(call)) return { code: 0, stderr: '', stdout: codexInventory('1.0.0') }; if (args[0] === 'plugin' && args[1] === 'add') { await applyNativeCodexPluginMutation(codexHome, 'add'); return { code: 1, stderr: 'add exploded', stdout: '' }; @@ -762,7 +772,121 @@ it('restores prior Codex plugin settings when add fails during replace', async ( expect((failed as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); expect((failed as DiagnosticError).diagnostics[0]?.message).toContain('add exploded'); expect(calls.map((call) => call.args.join(' '))).not.toContain(`plugin remove ${codexPluginId}`); + const config = await readFile(join(codexHome, 'config.toml'), 'utf8'); + expect(config).toContain(`${codexPluginTable}\nenabled = true`); + expect(config).toContain(`${codexPluginNestedMcp}\nenabled = false`); + expect(config).toContain('model = "changed-by-add"'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('does not plugin-remove after a failed Codex replace receipt write', async () => { + const fixture = await createHostBundle('codex'); + const home = join(fixture.cleanupRoot, 'home'); + const codexHome = join(fixture.cleanupRoot, 'codex-home'); + const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); + await cp(fixture.bundleRoot, installed, { recursive: true }); + const prior = await writeCodexPluginSettings(codexHome, true); + const calls: CommandCall[] = []; + const writeReceipt = rs.spyOn(installReceipt, 'writeStoredInstallReceipt') + .mockRejectedValueOnce(new Error('receipt write failed')); + const runner: InstallCommandRunner = { + run: async (command, args, runOptions) => { + const call = { args: [...args], command, cwd: runOptions.cwd }; + calls.push(call); + if (isInventoryCall(call)) return { code: 0, stderr: '', stdout: codexInventory('1.0.0') }; + if (args[0] === 'plugin' && args[1] === 'add') { + await applyNativeCodexPluginMutation(codexHome, 'add'); + } + if (args[0] === 'plugin' && args[1] === 'remove') { + await applyNativeCodexPluginMutation(codexHome, 'remove'); + } + return { code: 0, stderr: '', stdout: isMarketplaceListCall(call) ? noMarketplaces(call) : '' }; + }, + }; + try { + const failed = await installBundle({ + commandRunner: runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(failed).toBeInstanceOf(Error); + expect(failed).not.toBeInstanceOf(DiagnosticError); + expect((failed as Error).message).toContain('receipt write failed'); + expect(calls.map((call) => call.args.join(' '))).not.toContain(`plugin remove ${codexPluginId}`); + const config = await readFile(join(codexHome, 'config.toml'), 'utf8'); + expect(config).toContain(`${codexPluginNestedMcp}\nenabled = false`); + expect(config).toContain('model = "changed-by-add"'); + expect(config).not.toBe(prior); + } finally { + writeReceipt.mockRestore(); + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('fails Codex replace closed for disabled or unknown enablement before any mutation', async () => { + const fixture = await createHostBundle('codex'); + const home = join(fixture.cleanupRoot, 'home'); + const codexHome = join(fixture.cleanupRoot, 'codex-home'); + const installed = join(codexHome, 'plugins', 'cache', 'install-fixture-marketplace', 'install-fixture', '1.0.0'); + await cp(fixture.bundleRoot, installed, { recursive: true }); + const prior = await writeCodexPluginSettings(codexHome, false); + try { + const disabled = recordingRunner((call) => isInventoryCall(call) ? disabledCodexInventory : ''); + const disabledError = await installBundle({ + commandRunner: disabled.runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(disabledError).toBeInstanceOf(DiagnosticError); + expect((disabledError as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); + expect((disabledError as DiagnosticError).diagnostics[0]?.message).toContain('enabled: false'); + expect((disabledError as DiagnosticError).diagnostics[0]?.message) + .toContain('no settings-preserving update API'); + expect(disabled.calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); expect(await readFile(join(codexHome, 'config.toml'), 'utf8')).toBe(prior); + + const unknown = recordingRunner((call) => isInventoryCall(call) ? unknownEnablementCodexInventory : ''); + const unknownError = await installBundle({ + commandRunner: unknown.runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(unknownError).toBeInstanceOf(DiagnosticError); + expect((unknownError as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); + expect((unknownError as DiagnosticError).diagnostics[0]?.message).toContain('omits enabled'); + expect(unknown.calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + expect(await readFile(join(codexHome, 'config.toml'), 'utf8')).toBe(prior); + + await rm(join(codexHome, 'config.toml')); + await mkdir(join(codexHome, 'config.toml')); + const unreadable = recordingRunner((call) => isInventoryCall(call) ? disabledCodexInventory : ''); + const unreadableError = await installBundle({ + commandRunner: unreadable.runner, + environment: { CODEX_HOME: codexHome }, + from: fixture.from, + home, + host: 'codex', + replace: true, + scope: 'user', + }).catch((failure: unknown) => failure); + expect(unreadableError).toBeInstanceOf(DiagnosticError); + expect((unreadableError as DiagnosticError).diagnostics[0]?.message).toContain('enabled: false'); + expect(unreadable.calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); + expect((await stat(join(codexHome, 'config.toml'))).isDirectory()).toBe(true); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); } diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 9cf652b07..beb24e213 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -170,8 +170,10 @@ installation owns them. Pre-existing, shared, marker-less, and otherwise unprove are retained. Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex replacement is add-only (`codex plugin add`) so plugin settings and nested -MCP overrides in `config.toml` survive — native add resets a plugin-level `enabled = false` to -`true`, and the installer restores that flag from the prior config. The emitted `INSTALL.md` +MCP overrides in `config.toml` survive. Native add would reset a plugin-level `enabled = false` +to `true`; pinned Codex has no settings-preserving update API, so the installer refuses +disabled or unknown-enablement replacements (`AB7004`) and leaves those installs unchanged. +The emitted `INSTALL.md` documents the same recipe per host. The framework CLI derives the artifact-side hash and copy set from diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index ae7797859..04aa54372 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -196,8 +196,9 @@ The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies receipt (`.agent-bundle-install.json`), replacement touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling. Codex replacement is add-only -(`codex plugin add`) so plugin settings and nested MCP overrides survive; a plugin-level -`enabled = false` is restored after native add resets it. Every install writes a lifecycle +(`codex plugin add`) so plugin settings and nested MCP overrides survive. Disabled or +unknown-enablement Codex replacements are refused (`AB7004`) because pinned Codex has no +settings-preserving update API. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for Cursor and Amp local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index b9cbeef3d..7f52de091 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -135,7 +135,8 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl 配合 `--purge-data --confirm-purge` 只会删除回执证明归该安装独占的根;预先存在、共享、无标记或其他 无法证明归属的覆盖根都会保留。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 的替换只执行 `codex plugin add`,以便保留 `config.toml` 中的插件设置与嵌套 -MCP 覆盖——原生 add 会把插件级 `enabled = false` 重置为 `true`,安装器会从先前的配置恢复该标志。输出的 +MCP 覆盖。原生 add 会把插件级 `enabled = false` 重置为 `true`;钉住的 Codex 没有可保留设置的更新 API,因此安装器会拒绝 +已禁用或启用状态未知的替换(`AB7004`),并保持该安装不变。输出的 `INSTALL.md` 按宿主记录了同样的步骤。 框架 CLI 从 `agent-bundle.manifest.json` 的 `files[]` 派生产物侧哈希与复制集合,校验每个列出的摘要, diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 9aa87217a..83c546dcd 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -185,7 +185,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ 输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 先运行 `claude plugin uninstall --keep-data` 再重新安装。Codex 的替换只执行 `codex plugin add`,以便保留插件设置与嵌套 -MCP 覆盖;原生 add 把插件级 `enabled = false` 重置为 `true` 后,安装器会恢复该标志。 +MCP 覆盖。已禁用或启用状态未知的 Codex 替换会被拒绝(`AB7004`),因为钉住的 Codex 没有可保留设置的更新 API。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 宿主注册、时间戳)——Cursor 与 Amp 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 From 5886a5fe170627c0f516d7ba57ad6f66f71aa3e9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 04:02:37 +0000 Subject: [PATCH 4/5] docs(install): name native plugin CLI limitation, not missing app-server State that the native plugin CLI has no qualified settings-preserving update API. Do not deny app-server config/value/write. Record that the plugin-list enabled snapshot is not atomic against a concurrent edit of the same plugin flag between list and add. Co-authored-by: Zack Jackson --- .changeset/codex-install-add-only.md | 2 +- docs/diagnostics.md | 6 +++--- packages/agent-bundle/src/install/doctor.ts | 2 +- packages/agent-bundle/src/install/install.ts | 10 ++++++---- packages/agent-bundle/src/install/surface.ts | 8 ++++---- website/docs/en/guide/distribution/installation.mdx | 2 +- website/docs/en/reference/cli.mdx | 4 ++-- website/docs/zh/guide/distribution/installation.mdx | 2 +- website/docs/zh/reference/cli.mdx | 2 +- 9 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.changeset/codex-install-add-only.md b/.changeset/codex-install-add-only.md index e373db012..ebb0f9925 100644 --- a/.changeset/codex-install-add-only.md +++ b/.changeset/codex-install-add-only.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Replace Codex plugins with `codex plugin add` only so nested MCP overrides in `config.toml` survive. Refuse disabled or unknown-enablement Codex replacements (`AB7004`) because pinned Codex has no settings-preserving update API. (#824) +Replace Codex plugins with `codex plugin add` only so nested MCP overrides in `config.toml` survive. Refuse disabled or unknown-enablement Codex replacements (`AB7004`) because the native plugin CLI has no qualified settings-preserving update API. (#824) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 9b9a81bae..d4b05c964 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1374,8 +1374,8 @@ the host owns those copies, so replacement runs `claude plugin uninstall --keep-data` + `install` or Codex add-only (`codex plugin add`, preserving plugin settings and nested MCP overrides in `config.toml`). A Codex replace whose plugin list row is `enabled: false` or omits `enabled` is refused -(`AB7004`) before any host verb: pinned Codex has no settings-preserving -update API, and native add would set plugin-level enabled to true. +(`AB7004`) before any host verb: the native plugin CLI has no qualified +settings-preserving update API, and native add would set plugin-level enabled to true. | Installed copy | `install` | `install --replace` (alias `--force`) | Doctor | | --- | --- | --- | --- | @@ -1836,7 +1836,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json` — no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 5` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has neither its required `documents.plugin` nor Amp `documents.entry`, or whose `documents.entry` / `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor or Amp `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.` — the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: ` — loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.` — the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.` — the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or — as the message says — stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (pinned Codex has no settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (the native plugin CLI has no qualified settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index cd6651b78..28f88ea47 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1751,7 +1751,7 @@ const publicHostReplaceRecipe = (host: Exclude, scopeArgum : 'Rerun `agent-bundle install codex --from `; same-version content drift is replaced through ' + '`codex plugin add` so plugin settings and nested MCP overrides in config.toml survive. ' + 'Replacement is refused (`AB7004`) when plugin list reports `enabled: false` or omits `enabled`, ' + - 'because pinned Codex has no settings-preserving update API.'; + 'because the native plugin CLI has no qualified settings-preserving update API.'; /** * `AB7325`: the host lists the plugin but refused to load it. The message diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index b9ded63d8..a8142d6db 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -669,9 +669,11 @@ const installPublicCli = async ( // Codex `plugin remove` deletes the `[plugins.""]` subtree in config.toml, including nested // MCP overrides. Native `plugin add` refreshes the cache in place and keeps those tables, so // replacement is add-only. Native add also resets plugin-level `enabled = false` to `true`. - // Pinned Codex has no qualified settings-preserving update API (no expected-version write), - // so a replace whose inventory row is disabled or omits `enabled` is refused before any host - // verb mutates config. Enable the plugin in Codex, then replace; otherwise leave it unchanged. + // The native plugin CLI has no qualified settings-preserving update API (no expected-version + // write on plugin add/list/remove). A replace whose inventory row is disabled or omits + // `enabled` is refused before any host verb mutates config. Enable the plugin in Codex, then + // replace; otherwise leave it unchanged. The list `--json` `enabled` snapshot is not atomic + // against a concurrent edit of this same plugin's enabled flag between list and add. if (replaced && host === 'codex' && entry?.enabled !== true) { const reason = entry?.enabled === false ? 'plugin list reports enabled: false' @@ -679,7 +681,7 @@ const installPublicCli = async ( throw failure( 'AB7004', `Cannot replace the Codex install of ${id}: ${reason}. ` + - 'Pinned Codex has no settings-preserving update API, and native `plugin add` resets ' + + 'The native plugin CLI has no qualified settings-preserving update API, and native `plugin add` resets ' + 'plugin-level enabled to true. Enable the plugin in Codex before replacing, or leave this install unchanged.', host, ); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 1cea1d731..e384c6dc9 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -139,10 +139,10 @@ const codexInstructions = (model: NormalizedPlugin): string[] => [ `codex plugin add ${pluginId(model)}`, '```', '', - 'Native add resets a plugin-level `enabled = false` to `true`. Pinned Codex has no', - 'settings-preserving update API, so `agent-bundle install` refuses replacement when', - '`plugin list --json` reports `enabled: false` or omits `enabled` (`AB7004`) and leaves', - 'that install unchanged. Enable the plugin in Codex first, then rerun.', + 'Native add resets a plugin-level `enabled = false` to `true`.', + 'The native plugin CLI has no qualified settings-preserving update API, so the optional', + '`agent-bundle install` refuses replacement when `plugin list --json` reports `enabled: false`', + 'or omits `enabled` (`AB7004`) and leaves that install unchanged. Enable the plugin in Codex first, then rerun.', '', ...optionalCliReinstall('codex'), '', diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index beb24e213..e82ff1366 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -171,7 +171,7 @@ are retained. Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling because `plugin update` is version-gated; Codex replacement is add-only (`codex plugin add`) so plugin settings and nested MCP overrides in `config.toml` survive. Native add would reset a plugin-level `enabled = false` -to `true`; pinned Codex has no settings-preserving update API, so the installer refuses +to `true`; the native plugin CLI has no qualified settings-preserving update API, so the installer refuses disabled or unknown-enablement replacements (`AB7004`) and leaves those installs unchanged. The emitted `INSTALL.md` documents the same recipe per host. diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 04aa54372..396bbb5c8 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -197,8 +197,8 @@ receipt (`.agent-bundle-install.json`), replacement touches owned files only, and `--replace` adopts a pre-receipt copy; Claude replacement runs `claude plugin uninstall --keep-data` before reinstalling. Codex replacement is add-only (`codex plugin add`) so plugin settings and nested MCP overrides survive. Disabled or -unknown-enablement Codex replacements are refused (`AB7004`) because pinned Codex has no -settings-preserving update API. Every install writes a lifecycle +unknown-enablement Codex replacements are refused (`AB7004`) because the native plugin CLI +has no qualified settings-preserving update API. Every install writes a lifecycle receipt (format `agent-bundle-install-receipt/2`: version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for Cursor and Amp local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 7f52de091..fa9b61d70 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -135,7 +135,7 @@ Amp 不属于开发期安装宿主;请使用其有归属回执的 `agent-bundl 配合 `--purge-data --confirm-purge` 只会删除回执证明归该安装独占的根;预先存在、共享、无标记或其他 无法证明归属的覆盖根都会保留。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 的替换只执行 `codex plugin add`,以便保留 `config.toml` 中的插件设置与嵌套 -MCP 覆盖。原生 add 会把插件级 `enabled = false` 重置为 `true`;钉住的 Codex 没有可保留设置的更新 API,因此安装器会拒绝 +MCP 覆盖。原生 add 会把插件级 `enabled = false` 重置为 `true`;原生 plugin CLI 没有可保留设置的合格更新 API,因此安装器会拒绝 已禁用或启用状态未知的替换(`AB7004`),并保持该安装不变。输出的 `INSTALL.md` 按宿主记录了同样的步骤。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 83c546dcd..4d614bc55 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -185,7 +185,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ 输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 (`.agent-bundle-install.json`),替换只触碰归属文件,`--replace` 会接管回执出现之前的副本;Claude 的替换 先运行 `claude plugin uninstall --keep-data` 再重新安装。Codex 的替换只执行 `codex plugin add`,以便保留插件设置与嵌套 -MCP 覆盖。已禁用或启用状态未知的 Codex 替换会被拒绝(`AB7004`),因为钉住的 Codex 没有可保留设置的更新 API。 +MCP 覆盖。已禁用或启用状态未知的 Codex 替换会被拒绝(`AB7004`),因为原生 plugin CLI 没有可保留设置的合格更新 API。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 宿主注册、时间戳)——Cursor 与 Amp 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 From 2ebfcd87ba803a4a6b2f80288ebee809d4ae41e8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 04:05:06 +0000 Subject: [PATCH 5/5] test(install): expect qualified settings-preserving API wording Align install and INSTALL.md assertions with the production AB7004 / Codex reinstall phrase. Co-authored-by: Zack Jackson --- packages/agent-bundle/tests/install-surface.test.ts | 2 +- packages/agent-bundle/tests/install.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 190acf12d..ee4533bd7 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -767,7 +767,7 @@ it('documents the same-version reinstall recipe per host, including Claude\'s ve const reinstall = (codex.split('### Reinstall after a same-version rebuild')[1] ?? '').split('### Uninstall')[0] ?? ''; const reinstallBlocks = [...reinstall.matchAll(/```sh\n([\s\S]*?)```/gu)].map((match) => match[1]!); expect(reinstallBlocks.join('\n')).not.toContain('codex plugin remove'); - expect(codex).toContain('no settings-preserving update API'); + expect(codex).toContain('no qualified settings-preserving update API'); expect(codex).toContain('AB7004'); expect(codex).toContain('--replace'); diff --git a/packages/agent-bundle/tests/install.test.ts b/packages/agent-bundle/tests/install.test.ts index 669dcffd1..e48ef7e34 100644 --- a/packages/agent-bundle/tests/install.test.ts +++ b/packages/agent-bundle/tests/install.test.ts @@ -851,7 +851,7 @@ it('fails Codex replace closed for disabled or unknown enablement before any mut expect((disabledError as DiagnosticError).diagnostics[0]).toMatchObject({ code: 'AB7004', target: 'codex' }); expect((disabledError as DiagnosticError).diagnostics[0]?.message).toContain('enabled: false'); expect((disabledError as DiagnosticError).diagnostics[0]?.message) - .toContain('no settings-preserving update API'); + .toContain('no qualified settings-preserving update API'); expect(disabled.calls.map((call) => call.args.join(' '))).toEqual(['plugin list --json']); expect(await readFile(join(codexHome, 'config.toml'), 'utf8')).toBe(prior);