fix(install): surface claude plugin list --json errors in doctor/install (AB7325, AB7006); reject the auto-loaded hooks path in the Claude manifest schema - #479
Conversation
…install; reject the auto-loaded hooks path in the manifest schema Claude Code lists a plugin it refused to load with an `errors` array on its `plugin list --json` row (the row stays `enabled: true`; healthy rows omit the key). `doctor` and `install` read only id/installPath/scope/version from that row, so a refused plugin — the cargo-hauler#48 case, where the manifest `hooks` pointer at the auto-loaded `hooks/hooks.json` made Claude Code drop every hook, MCP server, and skill — was reported `installed` / `current` (#464). - install.ts: `claudePluginRowErrors` reads the array into `PublicHostInstalledEntry.errors`; `installBundle` throws `AB7006` when the byte-identical existing copy carries errors (reinstalling cannot help) and re-reads the listing after `claude plugin install` (which exits 0 for a plugin Claude Code then refuses) to fail the same way. - doctor.ts: inventory entries with errors are `failed`; the `--from` comparison is `load-failed` (`AB7325`, error) instead of `current`/`stale`; the `--plugin-dir` registration proof is `failed` with the host's text. - cli.ts: text report prints the `load-failed` comparison. - schemas/claude/plugin.schema.json: `hooks` admits the documented additional-hook-file forms (`./` path, array, inline object) and rejects the literal `./hooks/hooks.json`; PROVENANCE and the Claude capability table record the evidence (refused on 2.1.250, 2.1.251, 2.1.257, 2.1.259; accepted by `plugin validate --strict`). Claude adapterRevision 1.25.0. - tests: doctor/install fixtures use the verbatim 2.1.259 row shape; claude-plugin-validate-acceptance.test.ts emits the cargo-hauler shape and, when `claude` is on PATH, runs `plugin validate --strict --json` and `--plugin-dir plugin list --json` under an isolated CLAUDE_CONFIG_DIR. - docs: diagnostics.md (`AB7006`, `AB7325`, comparison matrix), en/zh cli, installation, and validation pages. The adapter half (#462/#463) landed in #470 (5775351).
🦋 Changeset detectedLatest commit: 011dce5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 422ece7ed1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'packages/agent-bundle/tests/artifact-validator.test.ts', | ||
| 'packages/agent-bundle/tests/browser-stdio-bridge-spike.test.ts', | ||
| 'packages/agent-bundle/tests/build.test.ts', | ||
| 'packages/agent-bundle/tests/claude-plugin-validate-acceptance.test.ts', |
There was a problem hiding this comment.
Run the Claude acceptance test in the binary-gated CI job
Registering this file only in integrationTestFiles means the Verify workflow runs it without installing Claude, so claudeIt skips the real-host assertion. I checked .github/workflows/ci.yml: the host-install-proofs job installs the pinned CLIs but runs only test:host-install, test:host-install:packed, and test:packed:native, none of which include this file. Consequently the claimed real-host load-verdict acceptance never gates CI; include it in the binary-gated job or one of that job's suites.
Useful? React with 👍 / 👎.
| "not": { "const": "./hooks/hooks.json" }, | ||
| "pattern": "^\\./", |
There was a problem hiding this comment.
Reject normalized aliases of the auto-loaded hook file
The exact-value exclusion still accepts paths such as ./hooks/./hooks.json or ./hooks/../hooks/hooks.json, even though they resolve to the same auto-loaded hooks/hooks.json. For a hand-written or older manifest using either alias, pinned-schema validation therefore succeeds and Claude subsequently refuses the plugin with the same duplicate-hooks error this change is intended to prevent. Reject the normalized path rather than only this literal spelling.
Useful? React with 👍 / 👎.
| claudeIt(`passes claude plugin validate --strict --json and loads without errors for the emitted claude artifact${claudeAvailable ? '' : missingClaude}`, () => { | ||
| const pluginRoot = join(root, 'plugin'); | ||
|
|
||
| const report = runClaude(['plugin', 'validate', pluginRoot, '--strict', '--json']) as { |
There was a problem hiding this comment.
Avoid --json with the pinned Claude validator
When the integration pool runs on an environment containing the repository's pinned Claude 2.1.250 binary, the availability check enables this test, but plugin validate --json is only available from 2.1.259 according to the contract evidence for this change. runClaude then requires exit code 0 and JSON output, so the acceptance test fails before reaching the load check on the exact CLI version pinned in schemas/claude/PROVENANCE.json. Use the supported textual validation form for older versions, version-gate this invocation, or deliberately update the host pin.
Useful? React with 👍 / 👎.
…date --json, reject alias paths in the schema - package.json: `test:host-install` (the CI job with the pinned Claude CLI on PATH) now runs claude-plugin-validate-acceptance.test.ts, so the real-host load verdict gates CI instead of skipping in the Verify pool. - The acceptance test reads `claude --version` and uses `plugin validate --strict --json` only from 2.1.259; the pinned 2.1.250 rejects the flag, so older binaries run the textual `--strict` form and are held to its exit code and "Validation passed" line. Verified against 2.1.250, 2.1.259, and no binary (skips with the missing-evidence title). - plugin.schema.json `additionalHooksPath`: the pattern admits only normalized `./`-prefixed paths (no `.`, `..`, or empty segments), so aliases of the auto-loaded file such as `./hooks/./hooks.json` or `./hooks/../hooks/hooks.json` fail the segment rule rather than slipping past the literal exclusion; PROVENANCE/metadata pins re-hashed, tests cover the aliases.
… (AB7325 is the Claude load-refusal code since #479)
… (AB7325 is the Claude load-refusal code since #479)
… (AB7325 is the Claude load-refusal code since #479)
…round #479 (AB7006→AB7009, AB7325→AB7328); write the Claude receipt before the load verdict
…at install time; doctor AB7326 (#426) (#482) * feat(install): expand Agent Plugins placeholders for the Cursor copy at install time; doctor AB7325 proves it (#426) * chore(changeset): reference #482 * fix(doctor): AB7325 recomputes the recorded expansion and requires the installed mcp.json to match it byte for byte (review) * fix(doctor): renumber the Cursor Agent Plugins launch proof to AB7326 (AB7325 is the Claude load-refusal code since #479)
Fixes #464; #462/#463 were fixed by #470 (5775351).
Issue cross-references (recorded here instead of on the threads)
hookspointer at the auto-loadedhooks/hooks.jsonin.claude-plugin/plugin.json. fix(claude): drop the manifest hooks pointer Claude Code reports as a duplicate; pin every Claude hook_event_name #470 (squash-merged as5775351fbc1c82e2861a9d69cad78adb086d052d) fixed it inadapters/claude.ts/adapters/plugin.ts(removedattachClaudeHookManifest), pinned the manifest and every Claude hook event intests/claude-hook-event-name.test.ts, and updated the en/zhhooks.mdx/installation.mdx. Both issues are closed as completed by fix(claude): drop the manifest hooks pointer Claude Code reports as a duplicate; pin every Claude hook_event_name #470.errorstext (table below), and the Claude Code CHANGELOG has no entry for the rule. The verified statement is "every revision from the pinned 2.1.250 through 2.1.259".claude plugin list --jsonerrors[] is never read, so a plugin Claude refused to load is reported as installed/current #464), the schema rejection of the auto-loaded path, and the real-host acceptance test.Symptom (ScriptedAlchemy/cargo-hauler#48)
cargo-hauler@0.4.1built with agent-bundle preview886b192installed cleanly throughclaude plugin install,agent-bundle doctor --host claudesaidinstalled copy: current, yet the session had nomcp__cargo-hauler__*tools and none of the plugin's hooks.claude plugin listshowed✘ failed to load;claude plugin list --jsoncarried the reason on the row:Root cause (#464)
Both readers of
claude plugin list --jsondropped theerrorsarray:packages/agent-bundle/src/install/install.ts:326(main) —parsePublicHostInventorypushed onlyinstallPath/scope/version, soinstallBundlereported a refused copyalready-installedorinstalled.packages/agent-bundle/src/install/doctor.ts:885-908(main) — inventory findings were alwaysstate: 'installed'; the--fromcomparison then hashed the (byte-identical) cache copy and reportedcurrent; the--plugin-dirregistration proof only checked that a<name>@inlinerow existed (doctor.ts:1393-1398).claude plugin installexits 0 andclaude plugin validate --strict --jsonreportssuccess: truefor the refused plugin, so the list row'serrorsarray is the only surface where the refusal appears.Fix
install.ts:claudePluginRowErrorsreadserrorsintoPublicHostInstalledEntry.errors.installBundlethrowsAB7006when the byte-identical existing copy carries errors (reinstalling the same bytes cannot help) and re-reads the listing afterclaude plugin installto fail the same way; an unusable post-install listing leaves the result unverified rather than failing an install the host accepted.doctor.ts: inventory entries with errors arestate: 'failed'witherrors; the--fromcomparison is a newload-failedstatus (AB7325, error, host text verbatim) instead ofcurrent/stale; the--plugin-dirregistration proof isfailedwitherrorsinstead ofregistered.cli.tsprints the new comparison.schemas/claude/plugin.schema.json:150pinnedhooksasconst "./hooks/hooks.json"— the exact value Claude Code refuses. It now admits the documented additional-hook-file forms (./-prefixed path, path array, inline object) and rejects the literal./hooks/hooks.json(AB6012at/hooks), so a hand-written or older manifest is caught at build/validate time.PROVENANCE.jsonandcapabilities/claude-2.1.250.jsonrecord the evidence; ClaudeadapterRevision1.24.0 → 1.25.0 (schema re-pin),pluginadapter unchanged.codex.tsemits no manifesthookspointer and the Codex capability table records no duplicate-hooks rule, so nothing changed there.Contract evidence
uploads/plugins-reference-1.md(Claude Code plugins reference, fetched 2026-09-03):hooks—string|array|object— "Hook config paths or inline config", example"./my-extra-hooks.json", i.e. additional configs.hooks/hooks.json— the auto-loaded default (also line 93: "Location:hooks/hooks.jsonin plugin root, or inline in plugin.json").--jsonoutput; "plugin validate", line 1212ff:--strict,--json(≥ v2.1.259) with top-levelsuccess,strict,target,manifest,contents.uploads/hooks-2.md:hooks/hooks.jsondocument shape.Observed on this machine (2026-09-03),
claude --plugin-dir <plugin> plugin list --jsonfor a manifest carrying"hooks": "./hooks/hooks.json":errors: ["Hook load failed: Duplicate hooks file detected …"]claude plugin validate <plugin> --strict --json→success: trueSo the verified threshold is "every revision from the pinned 2.1.250 through 2.1.259"; neither #462's "≥ 2.1.255" nor #463's "≥ 2.1.22" is a regression boundary we could observe, and the Claude Code CHANGELOG has no entry for the rule. Real 2.1.259 row shape used for the fixtures:
id,version,scope,enabled(staystruewhen refused),installPath,installedAt,lastUpdated, optionalmcpServers, anderrors(nonempty string array, present only on a refused plugin).Tests
tests/install.test.ts:AB7006after a fresh install whose follow-up listing carrieserrors, on a byte-identical refused copy (noafter installation), and unchangedalready-installedfor a healthy row; the existing Claude call sequences gain the post-installplugin list --json.tests/doctor.test.ts:load-failedcomparison +failedinventory entry +AB7325(noAB7308/AB7309);--plugin-dirproof row witherrors→bundle.state: 'failed'+AB7325(noAB7311/AB7312).tests/claude-plugin-validation.test.ts: schema rejects./hooks/hooks.json(string and array), accepts no field,./my-extra-hooks.json, arrays, inline object; rejects a non-./path and the non-normalized aliases./hooks/./hooks.json,./hooks/../hooks/hooks.json,./hooks//hooks.json,./hooks/hooks.json/(the pattern admits only./+ non-empty, non-dot segments).tests/claude-plugin-validate-acceptance.test.ts(new; inintegrationTestFilesand intest:host-install, the CI job with the pinned Claude CLI on PATH): emits the cargo-hauler shape throughclaudeAdapter, asserts the manifest has nohookskey and passes the pinned schema; whenclaudeis on PATH, runsclaude plugin validate <root> --strict --jsonon ≥ 2.1.259 (assertssuccess: true, no manifest/content errors or warnings) or the textual--strictform on older binaries such as the pinned 2.1.250 (exit 0 + "Validation passed"), thenclaude --plugin-dir <root> plugin list --json(asserts thecargo-hauler@inlinerow has noerrors), all under an isolatedCLAUDE_CONFIG_DIR; skips with a[missing evidence: claude binary unavailable on PATH]title otherwise. Verified locally with 2.1.250, 2.1.259, and no binary.tests/adapter-metadata.test.ts: new schema sha256 / adapterRevision pins.Evidence:
pnpm test:unit205 files / 3041 tests / 0 failed; integration poolhooks,plugin-bundle,host-adapters,artifact-validator,claude-plugin-validate-acceptance425/425 (acceptance ran against the real 2.1.259 binary);pnpm typecheck,pnpm lint,pnpm docs:site:build(language parity) green.Docs
docs/diagnostics.md(index rows, comparison matrix row, new "Host load refusal for Claude installs (AB7006,AB7325)" section); en+zhreference/cli.mdx,guide/distribution/installation.mdx,guide/distribution/validation.mdx. Generated hosts/diagnostics pages follow from the JSON/markdown sources.Notes / follow-ups
agent-bundle validate --artifactalready runsclaude plugin validate --strictwhen the CLI is present (guide/distribution/validation.mdx), but that verb accepted the refused manifest; the load verdict only exists inplugin list --json, whichdoctor --fromnow reads on both the--plugin-dirproof and the installed copy. Whethervalidateshould additionally run--plugin-dir plugin list --jsonis left to the separate audit of the validate path.Review status
Codex reviewed
422ece7(PR opened) and left three P2 findings; all three are addressed in011dce547, which has not been re-reviewed (no review-request comments are posted on this PR by maintainer instruction):rstest.integration-tests.ts):test:host-install— thehost-install-proofsjob, which installs the pinned CLIs and exposes them on PATH — now runsclaude-plugin-validate-acceptance.test.ts, so the load verdict gates CI. It stays inintegrationTestFilestoo, so the Verify pool keeps the process-free half and skips the binary half with the missing-evidence title.plugin.schema.json):additionalHooksPathnow uses^\./(?:(?!\.{1,2}/)[^/]+/)*(?!\.{1,2}$)[^/]+$—./followed by non-empty segments, none.or..— so every accepted value is already normalized and the literal exclusion is exact../hooks/./hooks.json,./hooks/../hooks/hooks.json,./hooks//hooks.json,./hooks/hooks.json/are covered inclaude-plugin-validation.test.ts; PROVENANCE and the metadata pins are re-hashed.plugin validate --jsonfails on the pinned 2.1.250 (claude-plugin-validate-acceptance.test.ts): the test readsclaude --versionand uses--jsononly from 2.1.259; older binaries run the textual--strictform and are held to exit 0 plus the "Validation passed" line (2.1.250 answerserror: unknown option '--json'otherwise — confirmed locally). Ran the file with 2.1.250 on PATH (2/2), 2.1.259 (2/2), and noclaude(1 pass, 1 skipped with the missing-evidence title).Last-reviewed head:
422ece7ed. Unreviewed heads:9eeaf5db5(changeset(#479)),011dce547(the three fixes above). Merged on green CI per the reviewer-quota fallback in AGENTS.md.CI at
011dce547: every check green exceptRelease gates (Node 22.19), whose only failure isrelease-audit.test.ts › audits an externally installed production tarball and generates its CycloneDX SBOM(120 s timeout inscripts/audit-packed-release.mjswaiting on npm's advisories endpoint; red onmaintoo and being removed separately). Merged under the maintainer's relaxation for that single job.Host install proofsranclaude-plugin-validate-acceptance.test.tswith the pinned Claude CLI: 2/2 passed.