Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/codex-install-add-only.md
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 6 additions & 2 deletions docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| --- | --- | --- | --- |
Expand Down Expand Up @@ -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 `<from>/<host>` 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: <reason>` — 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: `<host> is not installed or is not available on PATH.`, `Cursor is not installed in "<root>".` / `Cursor home "<root>" 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: `<host> plugin <operation> failed: <detail>` (a host CLI verb exited nonzero); `<host> 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 "<name>" 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: `<host> plugin <operation> failed: <detail>` (a host CLI verb exited nonzero); `<host> 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 "<name>" 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`)

Expand Down
4 changes: 3 additions & 1 deletion packages/agent-bundle/src/install/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1749,7 +1749,9 @@ const publicHostReplaceRecipe = (host: Exclude<DoctorHost, 'cursor'>, scopeArgum
? `Rerun \`agent-bundle install claude --from <bundle-dir>${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 <bundle-dir>`; 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
Expand Down
29 changes: 27 additions & 2 deletions packages/agent-bundle/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,9 +666,30 @@ const installPublicCli = async (
previousContentHash = installed?.hash ?? previousReceipt?.contentHash;
}
}
// Codex `plugin remove` deletes the `[plugins."<id>"]` 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';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve clean replacements on pinned Codex 0.147

When a supported Codex 0.147.0 user replaces a bundle that removed or renamed a file, this unconditional exclusion skips the only step that cleans the cache. The pre-change native instructions explicitly recorded that plugin add re-copies the snapshot but never deletes files removed by a rebuild, while the repository still pins and supports 0.147.0; the commit's stated real-host proof covers only 0.154. Installation can therefore report replaced and write the new receipt while obsolete skills or hooks remain, and the manifest-based Doctor comparison can ignore files no longer declared. Gate add-only replacement on a proven host version, raise the supported version and pin, or provide another settings-preserving clean replacement.

Useful? React with 👍 / 👎.

if (replaceRemovesPlugin) {
await runHostCommand(runner, identity, host, publicHostUninstallArguments(host, id, scope), 'removal');
}
await runHostCommand(runner, identity, host, [
Expand Down Expand Up @@ -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) {
Expand Down
11 changes: 8 additions & 3 deletions packages/agent-bundle/src/install/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
8 changes: 7 additions & 1 deletion packages/agent-bundle/tests/install-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand Down
Loading
Loading