Skip to content

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

Merged
ScriptedAlchemy merged 3 commits into
mainfrom
fix/claude-manifest-hooks-and-doctor-errors
Sep 4, 2026
Merged

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
ScriptedAlchemy merged 3 commits into
mainfrom
fix/claude-manifest-hooks-and-doctor-errors

Conversation

@ScriptedAlchemy

@ScriptedAlchemy ScriptedAlchemy commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Fixes #464; #462/#463 were fixed by #470 (5775351).

Issue cross-references (recorded here instead of on the threads)

Symptom (ScriptedAlchemy/cargo-hauler#48)

cargo-hauler@0.4.1 built with agent-bundle preview 886b192 installed cleanly through claude plugin install, agent-bundle doctor --host claude said installed copy: current, yet the session had no mcp__cargo-hauler__* tools and none of the plugin's hooks. claude plugin list showed ✘ failed to load; claude plugin list --json carried the reason on the row:

Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file
<install>/hooks/hooks.json. The standard hooks/hooks.json is loaded automatically, so manifest.hooks
should only reference additional hook files.

Root cause (#464)

Both readers of claude plugin list --json dropped the errors array:

  • packages/agent-bundle/src/install/install.ts:326 (main) — parsePublicHostInventory pushed only installPath/scope/version, so installBundle reported a refused copy already-installed or installed.
  • packages/agent-bundle/src/install/doctor.ts:885-908 (main) — inventory findings were always state: 'installed'; the --from comparison then hashed the (byte-identical) cache copy and reported current; the --plugin-dir registration proof only checked that a <name>@inline row existed (doctor.ts:1393-1398).

claude plugin install exits 0 and claude plugin validate --strict --json reports success: true for the refused plugin, so the list row's errors array is the only surface where the refusal appears.

Fix

  • install.ts: claudePluginRowErrors reads errors into PublicHostInstalledEntry.errors. installBundle throws AB7006 when the byte-identical existing copy carries errors (reinstalling the same bytes cannot help) and re-reads the listing after claude plugin install to 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 are state: 'failed' with errors; the --from comparison is a new load-failed status (AB7325, error, host text verbatim) instead of current/stale; the --plugin-dir registration proof is failed with errors instead of registered. cli.ts prints the new comparison.
  • schemas/claude/plugin.schema.json:150 pinned hooks as const "./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 (AB6012 at /hooks), so a hand-written or older manifest is caught at build/validate time. PROVENANCE.json and capabilities/claude-2.1.250.json record the evidence; Claude adapterRevision 1.24.0 → 1.25.0 (schema re-pin), plugin adapter unchanged.
  • Codex: codex.ts emits no manifest hooks pointer 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):

  • "Plugin manifest schema" › "Component path fields", line 548: hooksstring|array|object — "Hook config paths or inline config", example "./my-extra-hooks.json", i.e. additional configs.
  • "Plugin directory structure" › "File locations reference", line 918: Hooks — hooks/hooks.json — the auto-loaded default (also line 93: "Location: hooks/hooks.json in plugin root, or inline in plugin.json").
  • "plugin list", line 1137ff: --json output; "plugin validate", line 1212ff: --strict, --json (≥ v2.1.259) with top-level success, strict, target, manifest, contents.
  • uploads/hooks-2.md: hooks/hooks.json document shape.

Observed on this machine (2026-09-03), claude --plugin-dir <plugin> plugin list --json for a manifest carrying "hooks": "./hooks/hooks.json":

Claude Code result
2.1.250 (pinned revision) errors: ["Hook load failed: Duplicate hooks file detected …"]
2.1.251 same
2.1.257 same
2.1.259 same; claude plugin validate <plugin> --strict --jsonsuccess: true

So 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 (stays true when refused), installPath, installedAt, lastUpdated, optional mcpServers, and errors (nonempty string array, present only on a refused plugin).

Tests

  • tests/install.test.ts: AB7006 after a fresh install whose follow-up listing carries errors, on a byte-identical refused copy (no after installation), and unchanged already-installed for a healthy row; the existing Claude call sequences gain the post-install plugin list --json.
  • tests/doctor.test.ts: load-failed comparison + failed inventory entry + AB7325 (no AB7308/AB7309); --plugin-dir proof row with errorsbundle.state: 'failed' + AB7325 (no AB7311/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; in integrationTestFiles and in test:host-install, the CI job with the pinned Claude CLI on PATH): emits the cargo-hauler shape through claudeAdapter, asserts the manifest has no hooks key and passes the pinned schema; when claude is on PATH, runs claude plugin validate <root> --strict --json on ≥ 2.1.259 (asserts success: true, no manifest/content errors or warnings) or the textual --strict form on older binaries such as the pinned 2.1.250 (exit 0 + "Validation passed"), then claude --plugin-dir <root> plugin list --json (asserts the cargo-hauler@inline row has no errors), all under an isolated CLAUDE_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:unit 205 files / 3041 tests / 0 failed; integration pool hooks, plugin-bundle, host-adapters, artifact-validator, claude-plugin-validate-acceptance 425/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+zh reference/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 --artifact already runs claude plugin validate --strict when the CLI is present (guide/distribution/validation.mdx), but that verb accepted the refused manifest; the load verdict only exists in plugin list --json, which doctor --from now reads on both the --plugin-dir proof and the installed copy. Whether validate should additionally run --plugin-dir plugin list --json is 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 in 011dce547, which has not been re-reviewed (no review-request comments are posted on this PR by maintainer instruction):

  1. Acceptance test never ran with a real binary in CI (rstest.integration-tests.ts): test:host-install — the host-install-proofs job, which installs the pinned CLIs and exposes them on PATH — now runs claude-plugin-validate-acceptance.test.ts, so the load verdict gates CI. It stays in integrationTestFiles too, so the Verify pool keeps the process-free half and skips the binary half with the missing-evidence title.
  2. Normalized aliases of the auto-loaded file slipped past the literal exclusion (plugin.schema.json): additionalHooksPath now 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 in claude-plugin-validation.test.ts; PROVENANCE and the metadata pins are re-hashed.
  3. plugin validate --json fails on the pinned 2.1.250 (claude-plugin-validate-acceptance.test.ts): the test reads claude --version and uses --json only from 2.1.259; older binaries run the textual --strict form and are held to exit 0 plus the "Validation passed" line (2.1.250 answers error: unknown option '--json' otherwise — confirmed locally). Ran the file with 2.1.250 on PATH (2/2), 2.1.259 (2/2), and no claude (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 except Release gates (Node 22.19), whose only failure is release-audit.test.ts › audits an externally installed production tarball and generates its CycloneDX SBOM (120 s timeout in scripts/audit-packed-release.mjs waiting on npm's advisories endpoint; red on main too and being removed separately). Merged under the maintainer's relaxation for that single job. Host install proofs ran claude-plugin-validate-acceptance.test.ts with the pinned Claude CLI: 2/2 passed.

…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-bot

changeset-bot Bot commented Sep 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 011dce5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
agent-bundle Patch

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

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 3, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-03T23:41:32.745877Z 422ece7 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pkg-pr-new

pkg-pr-new Bot commented Sep 3, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle@479
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/create-agent-bundle@479
npm i https://pkg.pr.new/ScriptedAlchemy/agent-bundle/@agent-bundle/runtime@479

commit: 011dce5

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Comment on lines +8 to +9
"not": { "const": "./hooks/hooks.json" },
"pattern": "^\\./",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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.
@ScriptedAlchemy
ScriptedAlchemy merged commit 9f1e707 into main Sep 4, 2026
12 of 13 checks passed
@ScriptedAlchemy
ScriptedAlchemy deleted the fix/claude-manifest-hooks-and-doctor-errors branch September 4, 2026 00:33
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
… (AB7325 is the Claude load-refusal code since #479)
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
… (AB7325 is the Claude load-refusal code since #479)
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
… (AB7325 is the Claude load-refusal code since #479)
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…and doctor activation states (#101)

Squash of the #452 branch (rounds 1-5 of review folded in) ahead of the
rebase onto main, which now carries #479's AB7006/AB7325.
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…round #479 (AB7006→AB7009, AB7325→AB7328); write the Claude receipt before the load verdict
ScriptedAlchemy added a commit that referenced this pull request Sep 4, 2026
…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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

doctor/install --host claude: claude plugin list --json errors[] is never read, so a plugin Claude refused to load is reported as installed/current

1 participant