diff --git a/.changeset/codex-install-add-only.md b/.changeset/codex-install-add-only.md new file mode 100644 index 000000000..ebb0f9925 --- /dev/null +++ b/.changeset/codex-install-add-only.md @@ -0,0 +1,5 @@ +--- +"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 the native plugin CLI has no qualified settings-preserving update API. (#824) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e85de5c0c..d4b05c964 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1371,7 +1371,11 @@ 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`). A Codex replace +whose plugin list row is `enabled: false` or omits `enabled` is refused +(`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 | | --- | --- | --- | --- | @@ -1832,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` (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 cf8d79aa2..28f88ea47 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 remove` + `codex plugin add`.'; + '`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 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 cf4179ec5..a8142d6db 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -666,9 +666,30 @@ const installPublicCli = async ( previousContentHash = installed?.hash ?? previousReceipt?.contentHash; } } + // 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`. + // 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' + : 'plugin list omits enabled'; + throw failure( + 'AB7004', + `Cannot replace the Codex install of ${id}: ${reason}. ` + + '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, + ); + } // Decided before any host verb runs, so the marketplace ownership check sees the pre-install state. const recorded = await receiptIdentity(); - if (replaced) { + const replaceRemovesPlugin = replaced && host !== 'codex'; + if (replaceRemovesPlugin) { await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal'); } await runHostCommand(runner, identity, host, [ @@ -713,7 +734,11 @@ const installPublicCli = async ( } catch (error) { if (stateRollback !== undefined) await stateRollback(); 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..e384c6dc9 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -130,15 +130,20 @@ 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 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'), '', '### 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..ee4533bd7 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -762,7 +762,13 @@ 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'); + 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 qualified 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 9672f4883..e48ef7e34 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, @@ -543,7 +546,92 @@ 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 = `[plugins.${JSON.stringify(codexPluginId)}.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` leaves nested MCP tables in place and may rewrite + * unrelated keys. `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.slice(0, -1)}.`); + } + if (!skipping) kept.push(line); + } + await writeFile(path, kept.join('\n')); + return; + } + await writeFile( + path, + 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'); const codexHome = join(fixture.cleanupRoot, 'codex-home'); @@ -577,11 +665,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 +692,206 @@ it('honours --replace for Codex through remove + add and fails closed without a } }); +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 }); + 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: 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 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}`); + 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('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 }); + 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: codexInventory('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}`); + 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 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); + + 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 }); + } +}); + 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..e82ff1366 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -169,8 +169,12 @@ 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 would reset a plugin-level `enabled = false` +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. 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..396bbb5c8 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -195,8 +195,11 @@ 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. Disabled or +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 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..fa9b61d70 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -134,8 +134,10 @@ 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`;原生 plugin CLI 没有可保留设置的合格更新 API,因此安装器会拒绝 +已禁用或启用状态未知的替换(`AB7004`),并保持该安装不变。输出的 +`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..4d614bc55 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 覆盖。已禁用或启用状态未知的 Codex 替换会被拒绝(`AB7004`),因为原生 plugin CLI 没有可保留设置的合格更新 API。 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 宿主注册、时间戳)——Cursor 与 Amp 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。