diff --git a/.changeset/476-claude-host-validator-build-doctor.md b/.changeset/476-claude-host-validator-build-doctor.md new file mode 100644 index 000000000..2f6c18f18 --- /dev/null +++ b/.changeset/476-claude-host-validator-build-doctor.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Run the Claude Code host validator where it was missing and turn it into a load verdict. `agent-bundle build` now runs the same Claude Code checks as `validate --artifact` over every built `claude` and `plugin` target (`--host-validation` on by default, `--no-host-validation` to skip, `--strict` to promote host warnings to errors), and both commands follow the two `claude plugin validate --strict` runs with a `claude --plugin-dir plugin list --json` load check: a row with `errors` is reported as `AB7325` (a warning when the only errors are uninstalled declared dependencies), no row as `AB7311`, an unreadable listing as `AB6022`; the report carries `load.status` (`loaded`, `refused`, `unregistered`, `failed`). Without `claude` on `PATH`, `build` spawns once and reports a single informational `AB6019`. `agent-bundle doctor --host claude --from ` runs the validator over the bundle and every installed copy (findings keep `AB6019`–`AB6022`, prefixed with the copy they came from) and reads each row's `enabled` flag: an installed-but-disabled copy is reported as `disabled` with the new `AB7327` warning naming the `claude plugin enable` command. The native Claude proofs now validate in plugin mode through the shared runner and record the documented symlink warning. (#504) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 8db5f5c49..981d2f095 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -36,7 +36,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7013` | npm prepack inventory, artifact freshness, package bin targets, and release-version agreement. | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds. | -| `AB7300`–`AB7326` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7326`; see below), and the Cursor Agent Plugins launch proof (`AB7326`; see below). | +| `AB7300`–`AB7327` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), and a disabled Claude install (`AB7327`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route. | @@ -46,11 +46,16 @@ even when no error diagnostic was reported. | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | -## Claude Code host validation (`AB6019`–`AB6022`) +## Claude Code host validation (`AB6019`–`AB6022`, `AB7311`, `AB7325`) -`agent-bundle validate --artifact ` runs the installed Claude Code -validator for the `claude` and `plugin` targets when `--host-validation` is on. -Claude Code decides what to check from the manifest it is pointed at: a run +`agent-bundle validate --artifact ` and `agent-bundle build` run the +installed Claude Code validator for the `claude` and `plugin` targets when +`--host-validation` is on (the default for both commands; `--no-host-validation` +skips it, and programmatic `build()` calls skip it unless `hostValidation: true` +is passed). `agent-bundle doctor --host claude --from ` runs the same +validator over the `--from` bundle and over every installed copy Claude lists +for the plugin, prefixing each finding with `Bundle at …` or `Installed copy at +… (scope …)`. Claude Code decides what to check from the manifest it is pointed at: a run against the bundle directory picks `.claude-plugin/marketplace.json` when it is present and never opens the plugin's skill, agent, command, or hook files (Claude Code docs, "Create and distribute a plugin marketplace" → @@ -65,12 +70,31 @@ plugin run already reported. On Claude Code 2.1.259 or later both runs add releases fall back to the text report, attributed by its `Validating : ` headers. +`claude plugin validate --strict` is not a load verdict: Claude Code 2.1.250 +through 2.1.260 accept manifests and component files (for example an invalid +`monitors/monitors.json`, or a `hooks` field naming the auto-loaded +`hooks/hooks.json`) that a session then refuses to load. So `build` and +`validate --artifact` follow the two validation runs with a load check, +`claude --plugin-dir plugin list --json`, and read the plugin's +`@inline` row: no `errors` is `load.status: 'loaded'`; `errors` is +`refused` and `AB7325`; no row at all is `unregistered` and `AB7311`; a listing +that cannot be read is `failed` and `AB6022`. The check is read-only (the +listing writes nothing under `~/.claude`), is skipped with the two validation +runs when `claude` is absent (`AB6019`), and is skipped when the bundle has no +readable `.claude-plugin/plugin.json` name (the validation runs already report +that manifest). Doctor does not repeat it: its registration proof and the +inventory rows' `errors` already carry the same verdicts. Without `claude` on +`PATH`, `build` spawns once, reports one `AB6019`, and marks the remaining +`claude`/`plugin` targets `unavailable` without spawning again. + | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | | `AB6019` | info | The `claude` CLI is not installed or not on `PATH`, so host validation was skipped. Local pinned-schema validation (`AB6011`/`AB6012`) still runs. | Install Claude Code and ensure `claude` is on `PATH`, then rerun artifact validation. | | `AB6020` | warning (error in strict mode) / info | One Claude Code validation warning, or (info) one note, from the plugin or marketplace run. The message names the validated file and Claude Code's field path, for example `(hooks hooks/hooks.json): hooks: hooks.postToolUse: unknown hook event`. Claude Code tolerates these at load time; `agent-bundle validate --strict` promotes warnings to errors, mirroring `claude plugin validate --strict`. | Run `claude plugin validate /.claude-plugin/plugin.json --strict`, repair the reported Claude artifact, and rebuild. | | `AB6021` | error | One Claude Code validation error from the plugin or marketplace run, such as invalid JSON in `hooks/hooks.json`, frontmatter that fails to parse, or a duplicate plugin name in `marketplace.json`. Claude Code loads the plugin without the failing component or refuses the marketplace. | Same as `AB6020`. | -| `AB6022` | error | The bounded `claude --version` probe or a validation run could not start, exited nonzero without a report, timed out, exceeded 1 MiB of output, or (2.1.259+) returned no JSON report; the message carries the CLI's stderr when there is one. | Verify the Claude CLI starts and responds, then rerun `claude plugin validate /.claude-plugin/plugin.json --strict`. | +| `AB6022` | error | The bounded `claude --version` probe, a validation run, or the load check could not start, exited nonzero without a report, timed out, exceeded 1 MiB of output, (2.1.259+) returned no JSON report, or (load check) returned something other than a JSON array; the message carries the CLI's stderr when there is one. | Verify the Claude CLI starts and responds, then rerun `claude plugin validate /.claude-plugin/plugin.json --strict`. | +| `AB7311` | error | The load check's `claude --plugin-dir plugin list --json` listed no `@inline` row for the bundle (`load.status: 'unregistered'`): Claude Code did not register the directory as a plugin. Doctor emits the same code from its registration proof. | Inspect `claude --plugin-dir plugin list --json` and register the intended bundle. | +| `AB7325` | error; warning when every `errors` entry is `Dependency "@" is not installed …` (error under `--strict`) | The load check's row for the bundle carries `errors` (`load.status: 'refused'` with the strings verbatim): `claude plugin validate --strict` accepted the artifact, but a session would refuse to load it. A missing declared dependency is a property of the validating machine rather than of the artifact, so it is a warning and the build completes. Doctor emits the same code for installed copies and its registration proof (see "Host load refusal for Claude installs"). | Fix the artifact so `claude --plugin-dir plugin list --json` reports no `errors` for it, then rebuild; for a missing dependency, install it (`claude plugin install @`) or validate where it is installed. | ## Cursor built-artifact validation (`AB6026`–`AB6029`) @@ -872,7 +896,29 @@ pinned Claude `plugin` schema now rejects (`AB6012` at `/hooks`). | Code | Severity | Trigger | Recovery | | --- | --- | --- | --- | | `AB7006` | error | `install claude` found `errors` on the plugin's row: after `claude plugin install` ran (the install itself exited 0, so the result would otherwise have been `installed`/`replaced`), or on a byte-identical existing copy that would otherwise have been reported `already-installed` (reinstalling the same bytes cannot help). The message carries the host's `errors` verbatim, the install path, and the scope. An unusable post-install listing leaves the result unverified rather than failing an install the host accepted. | Fix the artifact until `claude plugin list --json` shows no `errors` for it (the message names the cause), rebuild, and rerun `agent-bundle install claude --from --replace`. | -| `AB7325` | error | Doctor found `errors` on the plugin's row in `claude plugin list --json` (inventory entry `state: 'failed'` with `errors`; `doctor --from` comparison `status: 'load-failed'` with `errors` instead of `current`/`stale`, since the installed bytes never reach a session) or on the `--plugin-dir` registration proof row (`bundle.state: 'failed'` with `errors`, replacing the `registered` verdict). The message carries the host's `errors` verbatim. | Same as `AB7006`: fix the artifact, rebuild, and reinstall with `--replace`. | +| `AB7325` | error | Doctor found `errors` on the plugin's row in `claude plugin list --json` (inventory entry `state: 'failed'` with `errors`; `doctor --from` comparison `status: 'load-failed'` with `errors` instead of `current`/`stale`, since the installed bytes never reach a session) or on the `--plugin-dir` registration proof row (`bundle.state: 'failed'` with `errors`, replacing the `registered` verdict). The message carries the host's `errors` verbatim. `build` and `validate --artifact` emit the same code from their load check (see "Claude Code host validation"). | Same as `AB7006`: fix the artifact, rebuild, and reinstall with `--replace`. | + +## Disabled Claude install (`AB7327`) + +`claude plugin disable ` (or the `/plugin` menu) keeps a plugin +installed but switched off: its row in `claude plugin list --json` reports +`enabled: false`, and none of its hooks, MCP servers, or skills reach a session +until `claude plugin enable` runs (Claude Code docs, "Plugins reference" → +"plugin enable" / "plugin disable"). Reinstalling, even with `--replace`, does +not enable it. `agent-bundle doctor --host claude` reads the flag: the +inventory entry carries `enabled: false` with `state: 'disabled'` (instead of +`installed`), and a `--from` comparison of that copy carries `enabled: false` +next to its content verdict — a disabled copy can still be `current` or +`stale`, and both facts are reported. Rows without a boolean `enabled` carry no +flag and are `installed`. A row with `errors` is `failed` (`AB7325`) whatever +its `enabled` value. A plugin that ships `defaultEnabled: false` in +`plugin.json` installs disabled by design ("Plugins reference" → "Default +enablement"); Doctor still reports `AB7327` for it, because the recovery is the +same `claude plugin enable`. + +| Code | Severity | Trigger | Recovery | +| --- | --- | --- | --- | +| `AB7327` | warning | `doctor --host claude --from ` compared an installed copy whose row reports `enabled: false`. The message names the plugin, version, install path, and scope. | Run `claude plugin enable @ [--scope ]` (or use `/plugin` in a session), then rerun Doctor; reinstalling does not enable a disabled plugin. | The JSON report exposes the same facts: `hosts[].inventory.findings[].errors`, `hosts[].bundle.errors`, and `hosts[].bundle.comparison.errors`. The text diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index a4e7630da..8314b5808 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -107,6 +107,7 @@ import type { ProjectContext } from './core/project-context.ts'; import type { NormalizedPlugin } from './core/types.ts'; import { validateClaudePlugin, + type ClaudePluginCommandRunner, type ClaudePluginValidationReport, } from './host-contracts/claude-plugin-validation.ts'; import { @@ -430,6 +431,18 @@ export interface InvalidInspectResult { export type InspectResult = ReadyInspectResult | InvalidInspectResult; export interface BuildOptions extends ProjectOptions { + /** + * After the artifact is written, run the installed Claude developer + * validator (`claude plugin validate --strict` against the emitted + * `plugin.json` and `marketplace.json`) for every built `claude` and + * `plugin` target, exactly as `validate --artifact` does. The CLI `build` + * command requests this by default; programmatic artifact operations + * (temporary artifacts, dev, evals) never do. Without `claude` on `PATH` + * the run costs one failed spawn and reports a single `AB6019` info. + */ + readonly hostValidation?: boolean; + /** Injectable only to make the Claude host validator deterministic in tests; production always spawns `claude`. */ + readonly hostValidationRunner?: ClaudePluginCommandRunner; readonly output?: string; /** * Also produce the framework-owned npm package build (`dist/` bin + lib @@ -438,11 +451,16 @@ export interface BuildOptions extends ProjectOptions { * operations (temporary artifacts, dev, evals) never do. */ readonly packageOutputs?: boolean; + /** Promote host-tool warnings to errors (`hostValidation` only). */ + readonly strict?: boolean; } export interface BuildProjectResult { readonly build: BuildResult; + /** Project diagnostics followed by the host-validation findings (`AB6019`–`AB6022`) when `hostValidation` ran. */ readonly diagnostics: readonly Diagnostic[]; + /** One report per built `claude`/`plugin` target; present only when `hostValidation` was requested. */ + readonly hostValidation?: readonly ClaudePluginValidationReport[]; readonly model: NormalizedPlugin; readonly packageBuild?: PackageBuildResult; readonly projectContext: ProjectContext; @@ -1070,15 +1088,58 @@ export const build = async (options: BuildOptions): Promise }); if (packageBuild !== undefined) assertPackageOutputSources(packageBuild, projectContext); } + const hostValidation = options.hostValidation === true + ? await buildHostValidation(result.manifest.targets.map((target) => target.name), output, options) + : undefined; return Object.freeze({ build: result, - diagnostics: prepared.diagnostics, + diagnostics: hostValidation === undefined + ? prepared.diagnostics + : freezeDiagnostics([...prepared.diagnostics, ...hostValidation.diagnostics]), + ...(hostValidation === undefined ? {} : { hostValidation: hostValidation.reports }), model, ...(packageBuild === undefined ? {} : { packageBuild }), projectContext, }); }; +const claudeValidatedTargets: ReadonlySet = new Set(['claude', 'plugin']); + +/** + * `build --host-validation`: the Claude developer validator (`plugin validate` + * over both manifests, then the `--plugin-dir … plugin list --json` load check) + * over every built `claude`/`plugin` target (#476). Targets run one after + * another: once the CLI proves absent (`AB6019`), the remaining targets are + * marked `unavailable` without another spawn, so a build without `claude` on + * `PATH` costs one failed spawn and reports the skip once. + */ +const buildHostValidation = async ( + targets: readonly string[], + output: string, + options: Pick, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly reports: readonly ClaudePluginValidationReport[] }> => { + const reports: ClaudePluginValidationReport[] = []; + let unavailable = false; + for (const target of targets.filter((name) => claudeValidatedTargets.has(name))) { + if (unavailable) { + reports.push(Object.freeze({ diagnostics: freezeDiagnostics([]), host: 'claude', status: 'unavailable', target })); + continue; + } + const report = await validateClaudePlugin({ + pluginDirectory: join(output, target), + ...(options.hostValidationRunner === undefined ? {} : { run: options.hostValidationRunner }), + ...(options.strict === undefined ? {} : { strict: options.strict }), + target, + }); + reports.push(report); + unavailable = report.status === 'unavailable'; + } + return Object.freeze({ + diagnostics: freezeDiagnostics(reports.flatMap((report) => report.diagnostics)), + reports: Object.freeze(reports), + }); +}; + const execFile = promisify(executeFile); export const prepack = async (options: BuildOptions): Promise => { diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 947d70811..c2e95950f 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -60,6 +60,8 @@ interface CliSignalSource { } export interface CliDependencies { + /** Injectable only to verify the build host-validation CLI policy without an installed host. */ + readonly build?: typeof build; readonly installBundle?: typeof installBundle; readonly prepack?: typeof prepack; readonly runDoctor?: typeof runDoctor; @@ -274,7 +276,19 @@ const writeMachine = (output: Output, result: unknown): void => { }; const writeHumanBuild = (output: Output, result: Awaited>): void => { + // Errors abort the command before this writer runs, so any diagnostics + // reaching it are informational nudges or host-validation warnings. + for (const diagnostic of result.diagnostics) { + output.write(`${diagnostic.code} (${diagnostic.severity}): ${diagnostic.message}\n`); + } output.write(`Built ${result.model.metadata.name} to ${result.build.outputRoot}\n`); + for (const report of result.hostValidation ?? []) { + output.write( + `Host validation (${report.target}): ${report.status}` + + `${report.version === undefined ? '' : ` (Claude Code ${report.version})`}` + + `${report.load === undefined ? '' : `, load check ${report.load.status}`}\n`, + ); + } if (result.packageBuild !== undefined) { output.write(`Package build (${result.packageBuild.files.length} file(s)) at ${result.packageBuild.outputRoot}\n`); } @@ -293,11 +307,12 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => { }; const describeInstallComparison = (comparison: DoctorInstallComparison): string => { - const installed = comparison.installedContentHash === undefined + const installed = (comparison.installedContentHash === undefined ? '' : `; installed ${comparison.installedVersion ?? 'unknown version'} ` + `content ${shortContentHash(comparison.installedContentHash)}, ` + - `artifact content ${shortContentHash(comparison.artifactContentHash)}`; + `artifact content ${shortContentHash(comparison.artifactContentHash)}`) + + (comparison.enabled === false ? '; disabled by the host' : ''); switch (comparison.status) { case 'current': return `current${installed}`; @@ -344,6 +359,12 @@ const writeHumanDoctor = (output: Output, result: DoctorReport): void => { if (host.bundle.comparison !== undefined) { output.write(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); } + for (const validation of host.bundle.hostValidation ?? []) { + output.write( + ` host validation (${validation.copy} ${validation.pluginDirectory}` + + `${validation.scope === undefined ? '' : `, scope ${validation.scope}`}): ${validation.status}\n`, + ); + } } const reports = [ ...host.inventory.findings.map((finding) => finding.durableState), @@ -605,10 +626,23 @@ export const runCli = async ( const buildCommand = configureSourceOptions( program.command('build').description('Build a validated Agent Bundle artifact'), - ).option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default artifact, since dist is the npm package build output)'); + ) + .option('--output ', 'Artifact output path relative to --root (overrides config output.distPath; default artifact, since dist is the npm package build output)') + .option('--host-validation', 'Run the installed Claude developer validator over built claude and plugin targets', true) + .option('--no-host-validation', 'Skip the installed Claude developer validator') + .option('--strict', 'Promote host-tool warnings to errors'); buildCommand.action(async (options: BuildCommandOptions) => { const { build } = await import('./api.ts'); - const result = await build({ ...projectOptions(options), output: options.output, packageOutputs: true }); + const result = await (dependencies.build ?? build)({ + ...projectOptions(options), + hostValidation: options.hostValidation, + output: options.output, + packageOutputs: true, + strict: options.strict, + }); + if (result.diagnostics.some((diagnostic) => diagnostic.severity === 'error')) { + throw new DiagnosticError(result.diagnostics); + } if (options.json === true) writeMachine(stdout, result); else writeHumanBuild(stdout, result); }); diff --git a/packages/agent-bundle/src/contracts/discovery.ts b/packages/agent-bundle/src/contracts/discovery.ts index 6696c92de..ce1766e8a 100644 --- a/packages/agent-bundle/src/contracts/discovery.ts +++ b/packages/agent-bundle/src/contracts/discovery.ts @@ -4,6 +4,7 @@ export type DiscoveryInventoryStatus = 'known' | 'skipped' | 'unknown'; export type DiscoveryFindingState = | 'conflicted' | 'corrupt' + | 'disabled' | 'drifted' | 'failed' | 'installed' diff --git a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts index d28b17a29..400eb388c 100644 --- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts @@ -19,9 +19,30 @@ type ClaudePluginTermination = 'output-limit' | 'timed-out'; export type ClaudePluginValidationStatus = 'failed' | 'passed' | 'unavailable' | 'warnings'; +/** + * Verdict of `claude --plugin-dir plugin list --json`: `loaded` + * (the plugin's `@inline` row has no `errors`), `refused` (the row + * carries `errors[]`, reported as `AB7325`), `unregistered` (no row for the + * plugin, `AB7311`), or `failed` (the listing itself could not be read, `AB6022`). + */ +export type ClaudePluginLoadStatus = 'failed' | 'loaded' | 'refused' | 'unregistered'; + +export interface ClaudePluginLoadCheck { + /** The host's own load errors, verbatim from the row's `errors` array; present only when `refused`. */ + readonly errors?: readonly string[]; + readonly status: ClaudePluginLoadStatus; +} + export interface ClaudePluginValidationReport { readonly diagnostics: readonly Diagnostic[]; readonly host: 'claude'; + /** + * The load check that follows the validation runs. Absent when the CLI was + * unavailable, a validation run itself failed (`AB6022`), the caller opted + * out, or the bundle has no readable `.claude-plugin/plugin.json` name to + * look for in the listing. + */ + readonly load?: ClaudePluginLoadCheck; readonly status: ClaudePluginValidationStatus; readonly target: string; readonly version?: string; @@ -35,12 +56,26 @@ export type ClaudePluginCommandRunner = ( export interface ValidateClaudePluginOptions { readonly executable?: string; + /** + * Also run `claude --plugin-dir plugin list --json` after the + * validation runs and read the plugin's row (default `true`). `plugin + * validate --strict` accepts manifests Claude Code then refuses to load + * (observed 2.1.250–2.1.259); the listing's `errors[]` is the only load + * verdict. Doctor passes `false` because it runs its own registration proof. + */ + readonly loadCheck?: boolean; readonly pluginDirectory: string; /** Injectable proof seam. Production always uses the bounded process runner. */ readonly run?: ClaudePluginCommandRunner; /** Promote host warnings to Agent Bundle errors. Claude itself always runs with `--strict`. */ readonly strict?: boolean; readonly target: string; + /** + * The `claude --version` number the caller already probed from the same + * executable; skips this run's own probe (Doctor validates several + * directories per host and probes once). + */ + readonly version?: string; } export interface ValidateClaudePluginFilesOptions { @@ -58,25 +93,56 @@ const runClaudeCommand: ClaudePluginCommandRunner = (request) => runBoundedChild const versionFrom = (output: string): string | undefined => /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(output)?.[1]; +type ClaudeHostDiagnosticCode = 'AB6019' | 'AB6020' | 'AB6021' | 'AB6022' | 'AB7311' | 'AB7325'; + +const recoveryFor = (code: ClaudeHostDiagnosticCode): string => { + switch (code) { + case 'AB6019': + return 'Install Claude Code and ensure `claude` is on PATH, then rerun artifact validation.'; + case 'AB6020': + case 'AB6021': + return 'Run `claude plugin validate /.claude-plugin/plugin.json --strict`, ' + + 'repair the reported Claude artifact, and rebuild.'; + case 'AB6022': + return 'Verify the Claude CLI starts and responds, then rerun ' + + '`claude plugin validate /.claude-plugin/plugin.json --strict`.'; + case 'AB7311': + return 'Inspect `claude --plugin-dir plugin list --json` and register the intended bundle.'; + case 'AB7325': + return 'Fix the artifact so `claude --plugin-dir plugin list --json` reports no `errors` for it, ' + + 'then rebuild.'; + default: { + const exhaustive: never = code; + throw new TypeError(`Unknown Claude host diagnostic ${String(exhaustive)}.`); + } + } +}; + const diagnostic = ( - code: 'AB6019' | 'AB6020' | 'AB6021' | 'AB6022', + code: ClaudeHostDiagnosticCode, message: string, severity: DiagnosticSeverity, target: string, ): Diagnostic => Object.freeze({ code, message, - recovery: code === 'AB6019' - ? 'Install Claude Code and ensure `claude` is on PATH, then rerun artifact validation.' - : code === 'AB6022' - ? 'Verify the Claude CLI starts and responds, then rerun ' + - '`claude plugin validate /.claude-plugin/plugin.json --strict`.' - : 'Run `claude plugin validate /.claude-plugin/plugin.json --strict`, ' + - 'repair the reported Claude artifact, and rebuild.', + recovery: recoveryFor(code), severity, target, }); +/** + * Reads a Claude `plugin list --json` row's `errors` array. Healthy rows omit + * the key (Claude Code 2.1.259); a refused row carries nonempty strings. + * Anything else is treated as "no readable errors" rather than a failure, so + * an unexpected shape cannot mask the row as uninstalled. + */ +export const claudePluginRowErrors = (row: Readonly>): readonly string[] => { + const errors = row['errors']; + if (!Array.isArray(errors)) return []; + return Object.freeze(errors.filter((error): error is string => typeof error === 'string' && error.trim().length > 0)); +}; + const matchingDocumentPaths = async ( root: string, contractPath: string, @@ -401,44 +467,71 @@ const validationTargets = async (pluginDirectory: string): Promise => { - const pluginDirectory = resolve(options.pluginDirectory); - const executable = options.executable ?? 'claude'; - const run = options.run ?? runClaudeCommand; - const cwd = dirname(pluginDirectory); - let version: string | undefined; +interface ClaudeCommandContext { + readonly cwd: string; + readonly executable: string; + readonly run: ClaudePluginCommandRunner; +} + +type ClaudeVersionProbe = + | { readonly report: ClaudePluginValidationReport } + | { readonly version: string | undefined }; + +/** `claude --version`: the report to return when the CLI is missing or unresponsive, else its version number. */ +const probeClaudeVersion = async (context: ClaudeCommandContext, target: string): Promise => { try { - const probe = await run(Object.freeze({ args: Object.freeze(['--version']), cwd, executable })); + const probe = await context.run(Object.freeze({ + args: Object.freeze(['--version']), + cwd: context.cwd, + executable: context.executable, + })); if (probe.exitCode !== 0 || probe.termination !== undefined) { - return failedReport( - probe.termination === 'timed-out' - ? 'Claude CLI version probe timed out.' - : probe.termination === 'output-limit' - ? 'Claude CLI version probe exceeded its output limit.' - : `Claude CLI version probe exited with code ${probe.exitCode ?? 'unknown'}.`, - options.target, - undefined, - ); + return { + report: failedReport( + probe.termination === 'timed-out' + ? 'Claude CLI version probe timed out.' + : probe.termination === 'output-limit' + ? 'Claude CLI version probe exceeded its output limit.' + : `Claude CLI version probe exited with code ${probe.exitCode ?? 'unknown'}.`, + target, + undefined, + ), + }; } - version = versionFrom(`${probe.stdout}\n${probe.stderr}`); + return { version: versionFrom(`${probe.stdout}\n${probe.stderr}`) }; } catch (error) { if (!isErrno(error, 'ENOENT')) { - return failedReport('Claude CLI version probe could not be started.', options.target, undefined); + return { report: failedReport('Claude CLI version probe could not be started.', target, undefined) }; } - return Object.freeze({ - diagnostics: freezeDiagnostics([diagnostic( - 'AB6019', - 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.', - 'info', - options.target, - )]), - host: 'claude', - status: 'unavailable', - target: options.target, - }); + return { + report: Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB6019', + 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.', + 'info', + target, + )]), + host: 'claude', + status: 'unavailable', + target, + }), + }; } +}; + +export const validateClaudePlugin = async ( + options: ValidateClaudePluginOptions, +): Promise => { + const pluginDirectory = resolve(options.pluginDirectory); + const executable = options.executable ?? 'claude'; + const run = options.run ?? runClaudeCommand; + const cwd = dirname(pluginDirectory); + const context: ClaudeCommandContext = Object.freeze({ cwd, executable, run }); + const probed = options.version === undefined + ? await probeClaudeVersion(context, options.target) + : { version: options.version }; + if ('report' in probed) return probed.report; + const version = probed.version; const jsonReport = claudeSupportsJsonValidationReport(version); const findingsByRun: Partial> = {}; @@ -498,17 +591,127 @@ export const validateClaudePlugin = async ( const pluginFindings = findingsByRun.plugin ?? []; const marketplaceFindings = withoutDuplicateManifestFindings(pluginFindings, findingsByRun.marketplace ?? []); - const diagnostics = freezeDiagnostics( - [...pluginFindings, ...marketplaceFindings] + const load = options.loadCheck === false + ? undefined + : await claudeLoadCheck(pluginDirectory, context, options.target, options.strict === true); + const diagnostics = freezeDiagnostics([ + ...[...pluginFindings, ...marketplaceFindings] .map((finding) => findingDiagnostic(finding, options.strict === true, options.target)), - ); + ...(load?.diagnostics ?? []), + ]); const failed = diagnostics.some((entry) => entry.severity === 'error'); const blocking = diagnostics.some((entry) => entry.severity !== 'info'); return Object.freeze({ diagnostics, host: 'claude', + ...(load === undefined ? {} : { load: load.check }), status: failed ? 'failed' : blocking ? 'warnings' : 'passed', target: options.target, ...(version === undefined ? {} : { version }), }); }; + +interface ClaudeLoadCheckOutcome { + readonly check: ClaudePluginLoadCheck; + readonly diagnostics: readonly Diagnostic[]; +} + +/** The `name` of `/.claude-plugin/plugin.json`, or `undefined` when the manifest cannot name a row. */ +const claudePluginManifestName = async (pluginDirectory: string): Promise => { + try { + const manifest: unknown = JSON.parse(await readFile(join(pluginDirectory, '.claude-plugin', 'plugin.json'), 'utf8')); + const name = isRecord(manifest) ? manifest['name'] : undefined; + return typeof name === 'string' && name.trim() !== '' ? name : undefined; + } catch { + return undefined; + } +}; + +const claudeLoadFailure = (message: string, target: string): ClaudeLoadCheckOutcome => Object.freeze({ + check: Object.freeze({ status: 'failed' }), + diagnostics: freezeDiagnostics([diagnostic('AB6022', message, 'error', target)]), +}); + +/** + * The load verdict `plugin validate` cannot give: `claude --plugin-dir + * plugin list --json` registers the directory as `@inline` and reports + * in that row's `errors[]` why a session would refuse it (#479: `--strict` + * accepts manifests Claude then refuses to load). Read-only — the listing + * writes nothing under `~/.claude`. Returns `undefined` when the bundle has no + * readable manifest name: the validation runs already reported that manifest. + */ +const claudeLoadCheck = async ( + pluginDirectory: string, + context: ClaudeCommandContext, + target: string, + strict: boolean, +): Promise => { + const name = await claudePluginManifestName(pluginDirectory); + if (name === undefined) return undefined; + const label = 'Claude plugin load check (`claude --plugin-dir plugin list --json`)'; + let result: ClaudePluginCommandResult; + try { + result = await context.run(Object.freeze({ + args: Object.freeze(['--plugin-dir', pluginDirectory, 'plugin', 'list', '--json']), + cwd: context.cwd, + executable: context.executable, + })); + } catch { + return claudeLoadFailure(`${label} could not be started.`, target); + } + if (result.termination !== undefined) { + return claudeLoadFailure( + result.termination === 'timed-out' ? `${label} timed out.` : `${label} exceeded its output limit.`, + target, + ); + } + if (result.exitCode !== 0) { + const reason = result.stderr.trim(); + return claudeLoadFailure( + `${label} exited with code ${result.exitCode ?? 'unknown'}${reason === '' ? '' : `: ${reason}`}.`, + target, + ); + } + let rows: unknown; + try { + rows = JSON.parse(result.stdout); + } catch { + return claudeLoadFailure(`${label} did not return JSON.`, target); + } + if (!Array.isArray(rows)) return claudeLoadFailure(`${label} did not return a JSON array.`, target); + const id = `${name}@inline`; + const row = rows.find((entry): entry is Record => isRecord(entry) && entry['id'] === id); + if (row === undefined) { + return Object.freeze({ + check: Object.freeze({ status: 'unregistered' }), + diagnostics: freezeDiagnostics([diagnostic( + 'AB7311', + `Claude Code did not register ${id} from ${JSON.stringify(pluginDirectory)}: ` + + '`claude --plugin-dir plugin list --json` listed no row for it.', + 'error', + target, + )]), + }); + } + const errors = claudePluginRowErrors(row); + if (errors.length === 0) return Object.freeze({ check: Object.freeze({ status: 'loaded' }), diagnostics: freezeDiagnostics([]) }); + // A declared `dependencies` entry the validating machine lacks is a property of this machine, + // not of the artifact: Claude refuses the load here, and would load it where the dependency is + // installed. Report it, but let the build finish unless `--strict` asked otherwise. + const environmental = errors.every((error) => claudeMissingDependencyError.test(error)); + return Object.freeze({ + check: Object.freeze({ errors, status: 'refused' }), + diagnostics: freezeDiagnostics([diagnostic( + 'AB7325', + `Claude Code refused to load ${id} from ${JSON.stringify(pluginDirectory)} although ` + + `\`plugin validate --strict\` accepted it` + + `${environmental ? ' (a declared dependency is not installed on this machine)' : ''}; ` + + `the host reported: ${errors.join(' | ')}`, + environmental && !strict ? 'warning' : 'error', + target, + )]), + }); +}; + +/** Claude Code 2.1.260: `Dependency "audit-logger@acme-shared" is not installed — run \`claude plugin install …\``. */ +const claudeMissingDependencyError = /^Dependency "[^"]+" is not installed\b/u; diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 6c3593bec..67bb9e223 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -11,7 +11,11 @@ import { import { mapConcurrent } from '../core/async.ts'; import { isErrno } from '../core/errors.ts'; import { exists } from '../core/paths.ts'; -import { validateClaudePluginFiles } from '../host-contracts/claude-plugin-validation.ts'; +import { + validateClaudePlugin, + validateClaudePluginFiles, + type ClaudePluginValidationReport, +} from '../host-contracts/claude-plugin-validation.ts'; import { validateCodexPluginFiles } from '../host-contracts/codex-plugin-validation.ts'; import { validateCursorPluginFiles, @@ -59,6 +63,7 @@ export type DoctorInventoryStatus = 'known' | 'skipped' | 'unknown'; export type DoctorFindingState = | 'conflicted' | 'corrupt' + | 'disabled' | 'drifted' | 'failed' | 'installed' @@ -99,6 +104,12 @@ export interface DoctorFinding { /** Git commit of a staged Cursor marketplace repository. */ readonly commit?: string; readonly durableState?: DoctorDurableStateReport; + /** + * Claude only: the row's `enabled` flag from `claude plugin list --json`. + * `false` sets `state: 'disabled'` — the copy is installed but switched off, + * so none of it reaches a session until `claude plugin enable` runs. + */ + readonly enabled?: boolean; readonly entry?: string; /** * The host's own load errors, verbatim (`claude plugin list --json` `errors`); @@ -173,6 +184,8 @@ export type DoctorInstallComparisonStatus = */ export interface DoctorInstallComparison { readonly artifactContentHash: string; + /** Claude only: `false` when the host lists the compared copy as disabled (`AB7327`). */ + readonly enabled?: boolean; /** Host load errors for a `load-failed` copy, verbatim from `claude plugin list --json`. */ readonly errors?: readonly string[]; readonly installedContentHash?: string; @@ -183,10 +196,25 @@ export interface DoctorInstallComparison { readonly status: DoctorInstallComparisonStatus; } +/** + * One `claude plugin validate` pass (the same runner `validate --artifact` + * uses: `plugin.json` then `marketplace.json`, `--strict`, `--json` on + * 2.1.259+) over the built bundle or one installed copy of it. + */ +export interface DoctorHostValidation extends ClaudePluginValidationReport { + /** `bundle`: the `--from` bundle root; `installed`: a copy the host lists for this plugin. */ + readonly copy: 'bundle' | 'installed'; + readonly pluginDirectory: string; + /** Claude install scope of an `installed` copy. */ + readonly scope?: string; +} + export interface DoctorHostReport { readonly bundle?: DoctorFinding & { readonly bundleRoot?: string; readonly comparison?: DoctorInstallComparison; + /** Claude only: host validator reports for the bundle and every installed copy, when `claude` is available. */ + readonly hostValidation?: readonly DoctorHostValidation[]; readonly marketplace?: string; }; readonly diagnostics: readonly Diagnostic[]; @@ -920,12 +948,16 @@ const publicHostInventory = ( // A row with `errors` is installed but refused by Claude Code (no hooks, MCP servers, or skills // reach the session); the inventory says so instead of listing it as a healthy install. const errors = claudePluginRowErrors(row); + // `enabled: false` is a copy the user switched off (`claude plugin disable`): installed, but no + // hooks, MCP servers, or skills reach a session until it is enabled again (#476). + const enabled = typeof row['enabled'] === 'boolean' ? row['enabled'] : undefined; findings.push({ + ...(enabled === undefined ? {} : { enabled }), entry: `${row['id']} (${row['scope']})`, ...(errors.length === 0 ? {} : { errors }), name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')), path: row['installPath'], - state: errors.length === 0 ? 'installed' : 'failed', + state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed', version: row['version'], }); } @@ -1029,6 +1061,28 @@ const hostLoadFailureDiagnostic = ( host, ); +/** + * `AB7327`: Claude lists this copy with `enabled: false`. The bytes may be + * current, but a disabled plugin contributes no hooks, MCP servers, or skills + * to a session, and no install or rebuild changes that — only + * `claude plugin enable` does (plugins-reference §plugin enable). + */ +const disabledInstallDiagnostic = ( + identity: PluginIdentity, + version: string, + installPath: string, + scope: string | undefined, +): Diagnostic => diagnostic( + 'AB7327', + `claude lists ${identity.name}@${version} at ${installPath}${scope === undefined ? '' : ` (scope ${scope})`} ` + + 'as disabled (`enabled: false`): the copy is installed but none of it loads in a session.', + `Run \`claude plugin enable ${identity.name}${identity.marketplace === undefined ? '' : `@${identity.marketplace}`}` + + `${scope === undefined ? '' : ` --scope ${scope}`}\` (or \`/plugin\` in a session), then rerun Doctor; ` + + 'reinstalling does not enable a disabled plugin.', + 'warning', + 'claude', +); + /** * Compares the copy a public host CLI caches for this plugin against the * built artifact. Unusable inventories degrade to `unknown` (Doctor never @@ -1065,11 +1119,18 @@ const publicHostInstallComparison = async ( for (const entry of inventory.entries) { const scoped = entry.scope === undefined ? '' : ` (scope ${entry.scope})`; const replaceHint = entry.scope === undefined ? '' : ` --scope ${entry.scope}`; + const enabledField = entry.enabled === undefined ? {} : { enabled: entry.enabled }; + if (entry.enabled === false) { + // Installed and possibly current, yet switched off: the content comparison still runs (a stale + // disabled copy is both), but the report must not read as a healthy install (#476). + diagnostics.push(disabledInstallDiagnostic(identity, entry.version, entry.installPath, entry.scope)); + } if (entry.errors !== undefined && entry.errors.length > 0) { // The host lists the copy but refused to load it: content comparison is moot because none of the // plugin reaches a session. Report the host's own message rather than `current`/`stale` (#464). comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, + ...enabledField, errors: entry.errors, installedPath: entry.installPath, installedVersion: entry.version, @@ -1085,6 +1146,7 @@ const publicHostInstallComparison = async ( } catch (error) { comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, + ...enabledField, installedPath: entry.installPath, installedVersion: entry.version, ownership: 'host', @@ -1108,6 +1170,7 @@ const publicHostInstallComparison = async ( : 'stale'; comparisons.push(Object.freeze({ artifactContentHash: artifact.hash, + ...enabledField, installedContentHash: installed.hash, installedPath: entry.installPath, installedVersion: entry.version, @@ -1449,7 +1512,47 @@ interface PublicHostContext { readonly run: DoctorCommandRunner; } -/** Claude: inline registration proof plus the installed cache copy compared against the artifact. */ +/** + * The Claude developer validator over one directory, through the same runner + * `validate --artifact` uses (`plugin.json` run, then `marketplace.json`; + * `--strict`; `--json` on 2.1.259+). Findings keep their `AB6019`–`AB6022` + * codes; the message names which copy they were found in, since Doctor + * validates the `--from` bundle and every installed copy the host lists (#476). + */ +const claudeHostValidation = async ( + copy: DoctorHostValidation['copy'], + pluginDirectory: string, + scope: string | undefined, + probe: DoctorHostProbe, + run: DoctorCommandRunner, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly validation: DoctorHostValidation }> => { + // Doctor already holds the load verdicts (`claudeRegistration` for the bundle, the inventory + // rows' `errors[]` → AB7325 for installed copies) and the probed version, so the runner + // neither lists nor probes again. + const report = await validateClaudePlugin({ + loadCheck: false, + pluginDirectory, + run, + target: 'claude', + ...(probe.version === undefined ? {} : { version: probe.version }), + }); + const location = copy === 'bundle' + ? `Bundle at ${JSON.stringify(pluginDirectory)}` + : `Installed copy at ${JSON.stringify(pluginDirectory)}${scope === undefined ? '' : ` (scope ${scope})`}`; + return { + diagnostics: freezeDiagnostics(report.diagnostics.map((entry) => Object.freeze({ + ...entry, + message: `${location}: ${entry.message}`, + }))), + validation: Object.freeze({ ...report, copy, pluginDirectory, ...(scope === undefined ? {} : { scope }) }), + }; +}; + +/** + * Claude: inline registration proof, the installed cache copy compared against + * the artifact, and `claude plugin validate` over the bundle and each installed + * copy. Doctor stays read-only: the validator only reads the named files. + */ const claudeBundle = async ( identity: PluginIdentity, probe: DoctorHostProbe, @@ -1460,9 +1563,23 @@ const claudeBundle = async ( const artifact = await treeInventory(identity.bundleRoot); const inventory = readPublicHostInventory('claude', identity, context.listing, context.environment, context.home); const compared = await publicHostInstallComparison('claude', identity, artifact, inventory); + const validated = [await claudeHostValidation('bundle', identity.bundleRoot, undefined, probe, context.run)]; + if (inventory.status === 'available') { + for (const entry of inventory.entries) { + validated.push(await claudeHostValidation('installed', entry.installPath, entry.scope, probe, context.run)); + } + } return { - diagnostics: freezeDiagnostics([...registration.diagnostics, ...compared.diagnostics]), - finding: Object.freeze({ ...registration.finding, comparison: compared.comparison }), + diagnostics: freezeDiagnostics([ + ...registration.diagnostics, + ...compared.diagnostics, + ...validated.flatMap((entry) => entry.diagnostics), + ]), + finding: Object.freeze({ + ...registration.finding, + comparison: compared.comparison, + hostValidation: Object.freeze(validated.map((entry) => entry.validation)), + }), }; }; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index b78c602ee..4568db7e1 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -10,6 +10,7 @@ import { errorMessage, isErrno } from '../core/errors.ts'; import { exists } from '../core/paths.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; +import { claudePluginRowErrors } from '../host-contracts/claude-plugin-validation.ts'; import { stageCursorMarketplace } from './cursor-marketplace.ts'; import { compareInstalledTree, @@ -259,6 +260,13 @@ export const publicHostCacheRoot = ( : join(environment['CODEX_HOME'] ?? join(home, '.codex'), 'plugins', 'cache'); export interface PublicHostInstalledEntry { + /** + * Claude only: the row's `enabled` flag. `false` means the copy is installed + * but switched off (`claude plugin disable`), so none of it reaches a + * session until `claude plugin enable` runs; absent when the row carries no + * boolean. + */ + readonly enabled?: boolean; /** * Claude only: the host's own load errors for this copy, verbatim from the * row's `errors` array (present and nonempty only when Claude Code refused @@ -273,17 +281,7 @@ export interface PublicHostInstalledEntry { readonly version: string; } -/** - * Reads a Claude `plugin list --json` row's `errors` array. Healthy rows omit - * the key (Claude Code 2.1.259); a refused row carries nonempty strings. - * Anything else is treated as "no readable errors" rather than a failure, so - * an unexpected shape cannot mask the row as uninstalled. - */ -export const claudePluginRowErrors = (row: Readonly>): readonly string[] => { - const errors = row['errors']; - if (!Array.isArray(errors)) return []; - return Object.freeze(errors.filter((error): error is string => typeof error === 'string' && error.trim().length > 0)); -}; +export { claudePluginRowErrors }; /** * The host's own answer to "is this plugin installed, and where": usable, or @@ -345,6 +343,7 @@ export const parsePublicHostInventory = ( if (options.scope !== undefined && row['scope'] !== options.scope) continue; const errors = claudePluginRowErrors(row); entries.push({ + ...(typeof row['enabled'] === 'boolean' ? { enabled: row['enabled'] } : {}), ...(errors.length === 0 ? {} : { errors }), installPath: row['installPath'], scope: row['scope'], diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 74e073d5a..a6ed55584 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -18,6 +18,7 @@ import { inspectArtifactFilesystem } from '../src/build/emit.ts'; import type { CapabilityState } from '../src/core/capabilities.ts'; import type { Diagnostic } from '../src/core/diagnostics.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; +import type { ClaudePluginCommandRunner } from '../src/host-contracts/claude-plugin-validation.ts'; import { ProjectService } from '../src/dev/project-service.ts'; import { createTargetMcpRuntime, @@ -410,6 +411,134 @@ it('resolves artifact output with CLI, config, and default precedence', async () } }, 30_000); +const recordedClaudeReport = async (name: string, pluginDirectory: string): Promise => + (await readFile(new URL(`./fixtures/claude-plugin-validate/${name}`, import.meta.url), 'utf8')) + .replaceAll('/bundle/claude', pluginDirectory); + +it('build runs the Claude developer validator and load check over built claude targets only when asked (#476)', async () => { + const root = await createProject(); + try { + const artifact = join(root, 'artifact'); + const claudeBundle = join(artifact, 'claude'); + const calls: string[][] = []; + const runner: ClaudePluginCommandRunner = async (request) => { + calls.push([...request.args]); + if (request.args[0] === '--version') return { exitCode: 0, signal: null, stderr: '', stdout: '2.1.259 (Claude Code)\n' }; + if (request.args[0] === '--plugin-dir') { + return { + exitCode: 0, + signal: null, + stderr: '', + stdout: JSON.stringify([{ enabled: true, id: 'api-fixture@inline', installPath: request.args[1], scope: 'inline', version: '1.0.0' }]), + }; + } + const target = request.args[2] ?? ''; + const findings = target.endsWith(join('.claude-plugin', 'plugin.json')); + return { + exitCode: findings ? 1 : 0, + signal: null, + stderr: '', + stdout: await recordedClaudeReport(findings ? '2.1.259-plugin-strict-findings.json' : '2.1.259-plugin-strict-passed.json', claudeBundle), + }; + }; + + // Programmatic builds never spawn the host. + const silent = await build({ hostValidationRunner: runner, output: artifact, root }); + expect(silent.hostValidation).toBeUndefined(); + expect(calls).toEqual([]); + + const validated = await build({ hostValidation: true, hostValidationRunner: runner, output: artifact, root }); + expect(calls).toEqual([ + ['--version'], + ['plugin', 'validate', join(claudeBundle, '.claude-plugin', 'plugin.json'), '--strict', '--json'], + ['plugin', 'validate', join(claudeBundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'], + ['--plugin-dir', claudeBundle, 'plugin', 'list', '--json'], + ]); + // Codex is built too, but only claude/plugin targets have a Claude validator. + expect(validated.build.manifest.targets.map((target) => target.name).sort()).toEqual(['claude', 'codex']); + expect(validated.hostValidation).toEqual([ + expect.objectContaining({ host: 'claude', load: { status: 'loaded' }, status: 'warnings', target: 'claude', version: '2.1.259' }), + ]); + const findings = validated.diagnostics.filter((entry) => entry.code === 'AB6020'); + expect(findings.length).toBeGreaterThan(0); + expect(findings.every((entry) => entry.severity === 'warning' && entry.target === 'claude')).toBe(true); + expect(validated.diagnostics.some((entry) => entry.severity === 'error')).toBe(false); + + // `--strict` promotes the same host warnings to build errors. + const strict = await build({ hostValidation: true, hostValidationRunner: runner, output: artifact, root, strict: true }); + expect(strict.hostValidation?.[0]?.status).toBe('failed'); + expect(strict.diagnostics.filter((entry) => entry.code === 'AB6020').every((entry) => entry.severity === 'error')).toBe(true); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}, 30_000); + +it('build surfaces a Claude load refusal as AB7325 even when plugin validate --strict passed (#476)', async () => { + const root = await createProject(); + try { + const artifact = join(root, 'artifact'); + const claudeBundle = join(artifact, 'claude'); + const errors = ['Hook load failed: Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file.']; + const runner: ClaudePluginCommandRunner = async (request) => { + if (request.args[0] === '--version') return { exitCode: 0, signal: null, stderr: '', stdout: '2.1.259 (Claude Code)\n' }; + if (request.args[0] === '--plugin-dir') { + return { + exitCode: 0, + signal: null, + stderr: '', + stdout: JSON.stringify([{ enabled: true, errors, id: 'api-fixture@inline', installPath: request.args[1], scope: 'inline' }]), + }; + } + return { exitCode: 0, signal: null, stderr: '', stdout: await recordedClaudeReport('2.1.259-plugin-strict-passed.json', claudeBundle) }; + }; + const result = await build({ hostValidation: true, hostValidationRunner: runner, output: artifact, root, targets: ['claude'] }); + + expect(result.hostValidation).toEqual([ + expect.objectContaining({ load: { errors, status: 'refused' }, status: 'failed', target: 'claude' }), + ]); + expect(result.diagnostics.filter((entry) => entry.code === 'AB7325')).toEqual([expect.objectContaining({ + message: expect.stringContaining(`refused to load api-fixture@inline from ${JSON.stringify(claudeBundle)}`), + severity: 'error', + target: 'claude', + })]); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}, 30_000); + +it('build reports one informational AB6019 skip for all Claude-validated targets when claude is absent (#476)', async () => { + const root = await createProject(); + try { + await writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " targets: ['claude', 'plugin', 'codex'],", + '};', + '', + ].join('\n')); + const missing = Object.assign(new Error('spawn claude ENOENT'), { code: 'ENOENT' }); + let spawns = 0; + const runner: ClaudePluginCommandRunner = async () => { + spawns += 1; + throw missing; + }; + const result = await build({ hostValidation: true, hostValidationRunner: runner, output: join(root, 'artifact'), root }); + + // One failed spawn for the whole build, one info diagnostic — build stays fast without the host. + expect(spawns).toBe(1); + expect(result.hostValidation?.map((report) => [report.target, report.status, report.diagnostics.length])).toEqual([ + ['claude', 'unavailable', 1], + ['plugin', 'unavailable', 0], + ]); + expect(result.diagnostics.filter((entry) => entry.code === 'AB6019')).toEqual([ + expect.objectContaining({ severity: 'info', target: 'claude' }), + ]); + expect(result.diagnostics.some((entry) => entry.severity === 'error')).toBe(false); + } finally { + await rm(join(root, '..'), { force: true, recursive: true }); + } +}, 30_000); + it('deduplicates identical adapter diagnostics without collapsing distinct stable identities', async () => { const root = await createProject(); const diagnostic = (code: string, overrides: Partial = {}): Diagnostic => ({ diff --git a/packages/agent-bundle/tests/claude-plugin-validation.test.ts b/packages/agent-bundle/tests/claude-plugin-validation.test.ts index c9be7be68..312b8b099 100644 --- a/packages/agent-bundle/tests/claude-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/claude-plugin-validation.test.ts @@ -30,6 +30,16 @@ const emittedClaudeBundle = async (withMarketplace = true): Promise => { return root; }; +/** A healthy `claude --plugin-dir plugin list --json` row for the emitted fixture bundle. */ +const loadedFixtureRow = (bundle: string, extra: Readonly> = {}): string => JSON.stringify([{ + enabled: true, + id: 'fixture@inline', + installPath: bundle, + scope: 'inline', + version: '0.0.0', + ...extra, +}]); + const runByTarget = ( responses: Readonly>>, version = '2.1.259', @@ -42,6 +52,13 @@ const runByTarget = ( if (request.args[0] === '--version') { return { exitCode: 0, signal: null, stderr: '', stdout: `${version} (Claude Code)\n` }; } + if (request.args[0] === '--plugin-dir') { + // Load check (#476): answer with the recorded `load` response when the test provides one, else a loaded row. + const load = responses['load']; + return load === undefined + ? { exitCode: 0, signal: null, stderr: '', stdout: loadedFixtureRow(request.args[1] ?? '') } + : { exitCode: load.exitCode, signal: null, stderr: load.stderr ?? '', stdout: load.stdout }; + } const target = request.args[2] ?? ''; const response = responses[target.endsWith('marketplace.json') ? 'marketplace' : 'plugin']; if (response === undefined) throw new Error(`unexpected validation target ${target}`); @@ -452,8 +469,16 @@ it('validates the plugin manifest and the marketplace manifest separately with - ['--version'], ['plugin', 'validate', join(bundle, '.claude-plugin', 'plugin.json'), '--strict', '--json'], ['plugin', 'validate', join(bundle, '.claude-plugin', 'marketplace.json'), '--strict', '--json'], + ['--plugin-dir', bundle, 'plugin', 'list', '--json'], ]); - expect(report).toEqual({ diagnostics: [], host: 'claude', status: 'passed', target: 'claude', version: '2.1.259' }); + expect(report).toEqual({ + diagnostics: [], + host: 'claude', + load: { status: 'loaded' }, + status: 'passed', + target: 'claude', + version: '2.1.259', + }); }); it('skips the marketplace run when the bundle emits no marketplace.json', async () => { @@ -462,7 +487,136 @@ it('skips the marketplace run when the bundle emits no marketplace.json', async plugin: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }, }); await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' }); - expect(fixture.calls.map((call) => call[2])).toEqual([undefined, join(bundle, '.claude-plugin', 'plugin.json')]); + expect(fixture.calls.map((call) => call[2])).toEqual([undefined, join(bundle, '.claude-plugin', 'plugin.json'), 'plugin']); +}); + +it('skips the load check on request and when the bundle has no manifest name to look up', async () => { + const bundle = await emittedClaudeBundle(false); + const passed = { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }; + const optedOut = runByTarget({ plugin: passed }); + const report = await validateClaudePlugin({ loadCheck: false, pluginDirectory: bundle, run: optedOut.run, target: 'claude' }); + expect(report.load).toBeUndefined(); + expect(optedOut.calls.some((call) => call[0] === '--plugin-dir')).toBe(false); + + await writeFile(join(bundle, '.claude-plugin', 'plugin.json'), '{"version":"1.0.0"}\n'); + const nameless = runByTarget({ plugin: passed }); + expect((await validateClaudePlugin({ pluginDirectory: bundle, run: nameless.run, target: 'claude' })).load).toBeUndefined(); + expect(nameless.calls.some((call) => call[0] === '--plugin-dir')).toBe(false); +}); + +it('reports AB7325 when Claude accepts the manifest under --strict but refuses to load it (#479 follow-up)', async () => { + const bundle = await emittedClaudeBundle(); + const errors = [ + 'Hook load failed: Duplicate hooks file detected: hooks/hooks.json is loaded automatically; remove it from the manifest.', + ]; + const fixture = runByTarget({ + load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors }) }, + marketplace: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }, + plugin: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }, + }); + const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' }); + + expect(report.status).toBe('failed'); + expect(report.load).toEqual({ errors, status: 'refused' }); + expect(report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB7325', + message: `Claude Code refused to load fixture@inline from ${JSON.stringify(bundle)} although ` + + `\`plugin validate --strict\` accepted it; the host reported: ${errors[0]}`, + recovery: expect.stringContaining('claude --plugin-dir plugin list --json'), + severity: 'error', + target: 'claude', + })]); +}); + +it('reports a load refusal caused only by an uninstalled declared dependency as an AB7325 warning', async () => { + const bundle = await emittedClaudeBundle(false); + const errors = [ + 'Dependency "audit-logger@acme-shared" is not installed — run `claude plugin install audit-logger@acme-shared`, ' + + 'or check that its marketplace is added', + ]; + const passed = { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }; + const report = await validateClaudePlugin({ + pluginDirectory: bundle, + run: runByTarget({ load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors }) }, plugin: passed }).run, + target: 'claude', + }); + expect(report.status).toBe('warnings'); + expect(report.load).toEqual({ errors, status: 'refused' }); + expect(report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB7325', + message: expect.stringContaining('(a declared dependency is not installed on this machine)'), + severity: 'warning', + })]); + + const strict = await validateClaudePlugin({ + pluginDirectory: bundle, + run: runByTarget({ load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors }) }, plugin: passed }).run, + strict: true, + target: 'claude', + }); + expect(strict.status).toBe('failed'); + expect(strict.diagnostics[0]?.severity).toBe('error'); + + // Mixed with any other refusal the verdict stays an error. + const mixed = await validateClaudePlugin({ + pluginDirectory: bundle, + run: runByTarget({ + load: { exitCode: 0, stdout: loadedFixtureRow(bundle, { errors: [...errors, 'Hook load failed: Duplicate hooks file detected'] }) }, + plugin: passed, + }).run, + target: 'claude', + }); + expect(mixed.status).toBe('failed'); + expect(mixed.diagnostics[0]?.message).not.toContain('declared dependency'); +}); + +it('reports AB7311 when the --plugin-dir listing has no row for the bundle', async () => { + const bundle = await emittedClaudeBundle(false); + const fixture = runByTarget({ + load: { exitCode: 0, stdout: JSON.stringify([{ id: 'other@marketplace', installPath: '/elsewhere', scope: 'user' }]) }, + plugin: { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }, + }); + const report = await validateClaudePlugin({ pluginDirectory: bundle, run: fixture.run, target: 'claude' }); + + expect(report.status).toBe('failed'); + expect(report.load).toEqual({ status: 'unregistered' }); + expect(report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB7311', + message: expect.stringContaining('did not register fixture@inline'), + severity: 'error', + })]); +}); + +it('reports AB6022 when the load check itself cannot be read', async () => { + const bundle = await emittedClaudeBundle(false); + const passed = { exitCode: 0, stdout: await recordedReport('2.1.259-plugin-strict-passed.json', bundle) }; + const nonzero = await validateClaudePlugin({ + pluginDirectory: bundle, + run: runByTarget({ load: { exitCode: 1, stderr: 'boom', stdout: '' }, plugin: passed }).run, + target: 'claude', + }); + expect(nonzero).toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB6022', message: expect.stringContaining('exited with code 1: boom') })], + load: { status: 'failed' }, + status: 'failed', + }); + + const notJson = await validateClaudePlugin({ + pluginDirectory: bundle, + run: runByTarget({ load: { exitCode: 0, stdout: 'Installed plugins:\n' }, plugin: passed }).run, + target: 'claude', + }); + expect(notJson).toMatchObject({ + diagnostics: [expect.objectContaining({ code: 'AB6022', message: expect.stringContaining('did not return JSON') })], + load: { status: 'failed' }, + }); + + const notArray = await validateClaudePlugin({ + pluginDirectory: bundle, + run: runByTarget({ load: { exitCode: 0, stdout: '{"plugins":[]}' }, plugin: passed }).run, + target: 'claude', + }); + expect(notArray.diagnostics).toEqual([expect.objectContaining({ message: expect.stringContaining('did not return a JSON array') })]); }); it('attributes --json findings to their file and de-duplicates marketplace re-reports of manifest findings', async () => { diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index 86ce8b5a3..a491c5b61 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -403,6 +403,46 @@ it('keeps inspect JSON stable and validates only the supplied artifact', async ( } }, 30_000 * timeScale); +it('build requests the Claude host validator by default, opts out under --no-host-validation, and fails under --strict (#476)', async () => { + const calls: unknown[] = []; + const build = async (options: unknown) => { + calls.push(options); + const strict = (options as { strict?: boolean }).strict === true; + const skipped = (options as { hostValidation?: boolean }).hostValidation === false; + const diagnostics = skipped + ? [] + : [{ code: 'AB6020', message: 'Claude plugin validation warning.', severity: strict ? 'error' as const : 'warning' as const }]; + return { + build: { outputRoot: '/artifact' }, + diagnostics, + ...(skipped ? {} : { + hostValidation: [{ diagnostics, host: 'claude', load: { status: 'loaded' }, status: strict ? 'failed' : 'warnings', target: 'claude', version: '2.1.259' }], + }), + model: { metadata: { name: 'fixture' } }, + }; + }; + + const human = await runSourceCliWithOutput(['build', '--root', '/project'], { build: build as never }); + expect(human).toEqual({ + code: 0, + stderr: '', + stdout: 'AB6020 (warning): Claude plugin validation warning.\nBuilt fixture to /artifact\nHost validation (claude): warnings (Claude Code 2.1.259), load check loaded\n', + }); + + const skipped = await runSourceCliWithOutput(['build', '--root', '/project', '--no-host-validation'], { build: build as never }); + expect(skipped).toEqual({ code: 0, stderr: '', stdout: 'Built fixture to /artifact\n' }); + + const strict = await runSourceCliWithOutput(['build', '--root', '/project', '--strict', '--json'], { build: build as never }); + expect(strict.code).toBe(1); + expect(strict.stderr).toContain('AB6020'); + + expect(calls).toEqual([ + expect.objectContaining({ hostValidation: true, packageOutputs: true, root: '/project', strict: undefined }), + expect.objectContaining({ hostValidation: false, packageOutputs: true, root: '/project' }), + expect.objectContaining({ hostValidation: true, strict: true }), + ]); +}); + it('enables bounded host validation for built artifacts and promotes warnings only under --strict', async () => { const calls: unknown[] = []; const validate = async (options: unknown) => { diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 3bd2bcd29..189e4540c 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -881,7 +881,9 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' hosts: ['claude'], }); - // Read-only proofs only: version probe, installed inventory, inline registration proof; never `plugin validate`. + // Read-only proofs only: one version probe, installed inventory, inline registration proof, then the + // developer validator over the bundle's two manifests (#476) — it reads files and writes nothing. + // The pinned-bytes document check (AB7319) needs no CLI proof of its own. expect(calls).toEqual([ expect.objectContaining({ args: ['--version'], executable: 'claude' }), expect.objectContaining({ args: ['plugin', 'list', '--json'], cwd: bundle, executable: 'claude' }), @@ -889,6 +891,14 @@ it('validates --from Claude documents from pinned bytes without a new CLI proof' args: ['--plugin-dir', bundle, 'plugin', 'list', '--json'], executable: 'claude', }), + expect.objectContaining({ + args: ['plugin', 'validate', join(bundle, '.claude-plugin', 'plugin.json'), '--strict'], + executable: 'claude', + }), + expect.objectContaining({ + args: ['plugin', 'validate', join(bundle, '.claude-plugin', 'marketplace.json'), '--strict'], + executable: 'claude', + }), ]); expect(hostReport(report, 'claude').bundle?.state).toBe('corrupt'); expect(report.diagnostics).toEqual(expect.arrayContaining([ @@ -1009,8 +1019,22 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v expect(report.diagnostics.some((entry) => entry.code === 'AB7303')).toBe(false); expect(hostReport(report, 'claude').inventory).toEqual({ findings: [ - { entry: 'alpha@alpha-marketplace (user)', name: 'alpha', path: '/cache/alpha/1.0.0', state: 'installed', version: '1.0.0' }, - { entry: 'alpha@alpha-marketplace (project)', name: 'alpha', path: '/cache/alpha-project/1.0.0', state: 'installed', version: '1.0.0' }, + { + enabled: true, + entry: 'alpha@alpha-marketplace (user)', + name: 'alpha', + path: '/cache/alpha/1.0.0', + state: 'installed', + version: '1.0.0', + }, + { + enabled: true, + entry: 'alpha@alpha-marketplace (project)', + name: 'alpha', + path: '/cache/alpha-project/1.0.0', + state: 'installed', + version: '1.0.0', + }, ], status: 'known', }); @@ -1508,6 +1532,7 @@ it('reports a Claude copy the host refused to load as load-failed (AB7325) inste expect(host.bundle?.comparison).not.toHaveProperty('installedContentHash'); expect(host.inventory).toEqual({ findings: [{ + enabled: true, entry: 'doctor-fixture@doctor-fixture-marketplace (user)', errors: refusedRow.errors, name: 'doctor-fixture', @@ -1529,6 +1554,128 @@ it('reports a Claude copy the host refused to load as load-failed (AB7325) inste } }); +it('reports an installed-but-disabled Claude copy as disabled (AB7327) with the enable command (#476)', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'claude'); + const installed = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + await cp(bundle, installed, { recursive: true }); + const artifactHash = (await treeInventory(bundle)).hash; + const disabledRow = { + enabled: false, + id: 'doctor-fixture@doctor-fixture-marketplace', + installPath: installed, + scope: 'project', + version: '1.2.3', + }; + const runner: DoctorCommandRunner = async (request) => { + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.259' }); + if (isInventoryRequest(request)) return commandResult({ stdout: JSON.stringify([disabledRow]) }); + if (request.args[0] === '--plugin-dir') return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + return commandResult({ stdout: JSON.stringify({ contents: [], manifest: { errors: [], notes: [], warnings: [] }, success: true }) }); + }; + const host = hostReport(await runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['claude'], + }), 'claude'); + + // Byte-identical and current, yet switched off: the comparison still runs, the inventory says so. + expect(host.bundle).toMatchObject({ + comparison: { + artifactContentHash: artifactHash, + enabled: false, + installedContentHash: artifactHash, + installedPath: installed, + status: 'current', + }, + state: 'registered', + }); + expect(host.inventory).toEqual({ + findings: [{ + enabled: false, + entry: 'doctor-fixture@doctor-fixture-marketplace (project)', + name: 'doctor-fixture', + path: installed, + state: 'disabled', + version: '1.2.3', + }], + status: 'known', + }); + const disabled = host.diagnostics.filter((entry) => entry.code === 'AB7327'); + expect(disabled).toEqual([expect.objectContaining({ severity: 'warning', target: 'claude' })]); + expect(disabled[0]?.message).toContain('as disabled (`enabled: false`)'); + expect(disabled[0]?.message).toContain('(scope project)'); + expect(disabled[0]?.recovery).toContain('claude plugin enable doctor-fixture@doctor-fixture-marketplace --scope project'); + expect(disabled[0]?.recovery).toContain('reinstalling does not enable'); + expect(host.diagnostics.some((entry) => entry.code === 'AB7308' || entry.code === 'AB7325')).toBe(false); + } finally { + await fixture.cleanup(); + } +}); + +it('runs the Claude developer validator over the bundle and every installed copy (#476)', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'claude'); + const installed = join(fixture.root, 'claude-config', 'plugins', 'cache', 'doctor-fixture-marketplace', 'doctor-fixture', '1.2.3'); + await cp(bundle, installed, { recursive: true }); + const recorded = (name: string, directory: string): Promise => + readFile(new URL(`./fixtures/claude-plugin-validate/${name}`, import.meta.url), 'utf8') + .then((text) => text.replaceAll('/bundle/claude', directory)); + const calls: string[][] = []; + const runner: DoctorCommandRunner = async (request) => { + calls.push([...request.args]); + if (request.args[0] === '--version') return commandResult({ stdout: 'claude 2.1.259' }); + if (isInventoryRequest(request)) { + return commandResult({ stdout: JSON.stringify([ + { enabled: true, id: 'doctor-fixture@doctor-fixture-marketplace', installPath: installed, scope: 'user', version: '1.2.3' }, + ]) }); + } + if (request.args[0] === '--plugin-dir') return commandResult({ stdout: JSON.stringify([{ id: 'doctor-fixture@inline' }]) }); + const target = request.args[2] ?? ''; + // The bundle validates clean; the installed copy's plugin.json run carries the recorded warnings. + const findings = target.startsWith(installed) && target.endsWith('plugin.json') && !target.endsWith('marketplace.json'); + return commandResult({ + exitCode: findings ? 1 : 0, + stdout: await recorded(findings ? '2.1.259-plugin-strict-findings.json' : '2.1.259-plugin-strict-passed.json', dirname(dirname(target))), + }); + }; + const host = hostReport(await runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['claude'], + }), 'claude'); + + // One probe for the whole host; the validator never lists plugins again (Doctor holds the verdict). + expect(calls.filter((call) => call[0] === '--version')).toHaveLength(1); + expect(calls.filter((call) => call[0] === '--plugin-dir')).toHaveLength(1); + expect(calls.filter((call) => call[0] === 'plugin' && call[1] === 'validate').map((call) => call[2])).toEqual([ + join(bundle, '.claude-plugin', 'plugin.json'), + join(bundle, '.claude-plugin', 'marketplace.json'), + join(installed, '.claude-plugin', 'plugin.json'), + join(installed, '.claude-plugin', 'marketplace.json'), + ]); + expect(host.bundle?.hostValidation).toEqual([ + expect.objectContaining({ copy: 'bundle', diagnostics: [], pluginDirectory: bundle, status: 'passed', version: '2.1.259' }), + expect.objectContaining({ copy: 'installed', pluginDirectory: installed, scope: 'user', status: 'warnings' }), + ]); + expect(host.bundle?.hostValidation?.[1]).not.toHaveProperty('load'); + const findings = host.diagnostics.filter((entry) => entry.code === 'AB6020'); + expect(findings.length).toBeGreaterThan(0); + expect(findings.every((entry) => entry.severity === 'warning' && entry.target === 'claude')).toBe(true); + expect(findings[0]?.message).toMatch(new RegExp(`^Installed copy at ${JSON.stringify(installed).replaceAll('\\', '\\\\')} \\(scope user\\): `, 'u')); + expect(host.diagnostics.some((entry) => entry.message.startsWith('Bundle at'))).toBe(false); + expect(host.bundle?.comparison?.status).toBe('current'); + } finally { + await fixture.cleanup(); + } +}); + it('fails the Claude registration proof when --plugin-dir plugin list --json carries errors for the bundle', async () => { const fixture = await temporaryDoctor(); try { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index 309a3a5bc..d4c03ffdb 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -9,6 +9,10 @@ import { claudeAdapter } from '../src/adapters/claude.ts'; import { codexAdapter } from '../src/adapters/codex.ts'; import { emitPlanEntries } from '../src/build/emit.ts'; import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; +import { + validateClaudePlugin, + type ClaudePluginValidationReport, +} from '../src/host-contracts/claude-plugin-validation.ts'; const nativeIt = process.env.AGENT_BUNDLE_NATIVE_HOST_CONTRACTS === '1' ? it : it.skip; @@ -17,6 +21,12 @@ interface ClaudeValidation { readonly output: string; } +interface ClaudePluginValidation { + /** Every diagnostic as `CODE: message`, so `toContain` reads like the raw CLI output did. */ + readonly output: string; + readonly report: ClaudePluginValidationReport; +} + interface CodexValidation { readonly code: number | null; readonly stderr: string; @@ -49,9 +59,34 @@ const runClaude = async ( child.once('close', (code) => resolvePromise({ code, output })); }); +/** + * A raw `claude plugin validate --strict ` run. Name a manifest, never the emitted + * plugin directory: with `.claude-plugin/marketplace.json` beside `plugin.json`, a directory run + * is a marketplace run, and "From a marketplace directory, Claude Code doesn't open the plugins' + * skill, agent, command, or hook files" (plugin-marketplaces → Troubleshooting › Marketplace + * validation errors). Tests about the marketplace manifest use this with `marketplace.json`. + */ const runClaudeValidation = async (cwd: string, target: string): Promise => runClaude(cwd, ['plugin', 'validate', '--strict', target]); +/** + * The plugin-mode proof through the production runner `validate --artifact`, `build`, and + * `doctor` share: `plugin.json` first (covers `plugin.json`, `hooks/hooks.json`, `skills/`, + * `agents/`, `commands/` — plugin-marketplaces → Validate a plugin or a directory without a + * manifest › Pick the directory to name), then `marketplace.json`; `--strict` on both, `--json` + * on 2.1.259+. `status: 'passed'` means the strict runs reported nothing (#475). + */ +const validateClaudePluginRoot = async ( + root: string, + options: Readonly<{ loadCheck?: boolean; strict?: boolean }> = {}, +): Promise => { + const report = await validateClaudePlugin({ pluginDirectory: root, target: 'claude', ...options }); + return { + output: report.diagnostics.map((entry) => `${entry.code}: ${entry.message}`).join('\n'), + report, + }; +}; + const runCodex = async ( cwd: string, args: readonly string[], @@ -614,10 +649,9 @@ nativeIt('records that strict validation accepts package metadata without runnin version: '1.0.0', })}\n`), ]); - const validation = await runClaudeValidation(root, root); + const validation = await validateClaudePluginRoot(root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, validation.output).toBe('passed'); expect(validation.output).not.toContain('package.json'); expect(validation.output).not.toContain('package-lock.json'); await expect(access(join(root, 'node_modules'))).rejects.toMatchObject({ code: 'ENOENT' }); @@ -626,7 +660,7 @@ nativeIt('records that strict validation accepts package metadata without runnin } }); -nativeIt('records that strict validation does not catch a path-escaping plugin symlink', async () => { +nativeIt('records that plugin-mode validation warns about a symlinked skill entry a session would follow', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-symlink-validation-')); const externalSkill = `${root}-outside-skill`; @@ -639,11 +673,31 @@ nativeIt('records that strict validation does not catch a path-escaping plugin s ); await mkdir(join(root, 'skills'), { recursive: true }); await symlink(externalSkill, join(root, 'skills', 'outside-skill'), 'dir'); - const validation = await runClaudeValidation(root, root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + // plugin-marketplaces → Validate a plugin or a directory without a manifest › Check files behind + // symlinks: "A linked entry inside a skills, agents, or commands directory: Claude Code skips it + // and warns, per directory, how many entries it skipped that a session would load." Observed on + // Claude Code 2.1.250 (CI pin), 2.1.251, and 2.1.260 as a `skills` directory warning that + // --strict promotes to exit 1. The validator never names the link target, so containment stays + // an install-copy check; this proof is that the plugin run reports the skipped entry at all. + const validation = await validateClaudePluginRoot(root); + expect(validation.report.status, validation.output).toBe('warnings'); + expect(validation.report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB6020', + generatedPath: 'skills', + message: expect.stringMatching(/\(skill skills\): directory: 1 entry here is a symlink and was not read/u), + severity: 'warning', + })]); expect(validation.output).not.toContain('outside-skill'); + expect((await validateClaudePluginRoot(root, { strict: true })).report.status).toBe('failed'); + + // The former proof named the directory, which is a marketplace run: the same bundle passes and + // the skills directory is never opened (Troubleshooting › Marketplace validation errors). + const marketplaceRun = await runClaudeValidation(root, root); + expect(marketplaceRun.code, marketplaceRun.output).toBe(0); + expect(marketplaceRun.output).toContain('Validating marketplace manifest'); + expect(marketplaceRun.output).toContain('Validation passed'); + expect(marketplaceRun.output).not.toMatch(/is a symlink and was not read/u); } finally { await Promise.all([ rm(root, { force: true, recursive: true }), @@ -908,13 +962,13 @@ nativeIt('accepts an emitted Claude artifact whose plugin root carries settings. })); expect(written).toContain('settings.json'); - // Both documented validation entry points: the marketplace manifest and - // the plugin directory itself. - for (const target of [join(root, '.claude-plugin', 'marketplace.json'), root]) { - const validation = await runClaudeValidation(root, target); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); - } + // Both documented validation entry points: the marketplace manifest alone, + // and the plugin-mode run (plugin.json, then marketplace.json). + const marketplace = await runClaudeValidation(root, join(root, '.claude-plugin', 'marketplace.json')); + expect(marketplace.code, marketplace.output).toBe(0); + expect(marketplace.output).toContain('Validation passed'); + const plugin = await validateClaudePluginRoot(root); + expect(plugin.report.status, plugin.output).toBe('passed'); } finally { await rm(root, { force: true, recursive: true }); } @@ -932,12 +986,12 @@ nativeIt('records that strict native validation never inspects plugin settings.j join(root, 'settings.json'), '{"agent":"","statusLine":{"type":"command","command":"rows.sh"},"padding":3}\n', ); - const validation = await runClaudeValidation(root, root); + const validation = await validateClaudePluginRoot(root); - // The host validator ignores settings.json even under --strict, so the - // compiler's own claude.settings.* diagnostics are the only guard an + // The host validator ignores settings.json even in a strict plugin run, so + // the compiler's own claude.settings.* diagnostics are the only guard an // author gets before the plugin is enabled in a session. - expect(validation.code, validation.output).toBe(0); + expect(validation.report.status, validation.output).toBe('passed'); expect(validation.output).not.toContain('settings.json'); } finally { await rm(root, { force: true, recursive: true }); @@ -983,26 +1037,18 @@ nativeIt('records strict native validation behavior for documented and security- try { for (const { fileName, frontmatter, label } of cases) { const caseRoot = join(root, fileName.replace('.md', '')); - const configRoot = join(caseRoot, 'config'); const pluginRoot = join(caseRoot, 'plugin'); await writeClaudeArtifact(pluginRoot, model); - await Promise.all([ - mkdir(join(pluginRoot, 'agents'), { recursive: true }), - mkdir(configRoot, { recursive: true }), - ]); + await mkdir(join(pluginRoot, 'agents'), { recursive: true }); await writeFile( join(pluginRoot, 'agents', fileName), `---\n${frontmatter.join('\n')}\n---\n\nInspect the repository and report findings.\n`, ); - const validation = await runClaude( - pluginRoot, - ['plugin', 'validate', '--strict', pluginRoot], - configRoot, - ); + // A plugin run is the only mode that opens agents/ at all. + const validation = await validateClaudePluginRoot(pluginRoot); - expect(validation.code, `${label}: ${validation.output}`).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, `${label}: ${validation.output}`).toBe('passed'); if (label === 'security-sensitive fields') { expect(validation.output).not.toContain('hooks'); expect(validation.output).not.toContain('mcpServers'); @@ -1039,10 +1085,9 @@ nativeIt('accepts emitted Claude experimental themes and monitors under strict n expect(JSON.parse( await readFile(join(root, 'monitors', 'monitors.json'), 'utf8'), )[0].command).toBe('node ${CLAUDE_PLUGIN_ROOT}/scripts/watch.mjs'); - const validation = await runClaudeValidation(root, root); + const validation = await validateClaudePluginRoot(root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, validation.output).toBe('passed'); } finally { await rm(root, { force: true, recursive: true }); } @@ -1058,11 +1103,24 @@ nativeIt('records whether strict native validation inspects monitors/monitors.js join(root, 'monitors', 'monitors.json'), '[{"name":"missing-command","description":"Missing its required command."}]\n', ); - const validation = await runClaudeValidation(root, root); - - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + // Plugin mode, so a monitors finding would be visible if the validator produced one. It is not: + // both `plugin validate --strict` runs accept the bundle. Only the load check that follows + // (`claude --plugin-dir plugin list --json`) exposes the refusal, as AB7325 (#479 follow-up). + const validation = await validateClaudePluginRoot(root, { loadCheck: false }); + expect(validation.report.status, validation.output).toBe('passed'); expect(validation.output).not.toContain('monitors.json'); + + const loaded = await validateClaudePluginRoot(root); + expect(loaded.report.status, loaded.output).toBe('failed'); + expect(loaded.report.load).toEqual({ + errors: [expect.stringMatching(/^monitors load failed from .*monitors\/monitors\.json: /u)], + status: 'refused', + }); + expect(loaded.report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB7325', + message: expect.stringContaining('although `plugin validate --strict` accepted it; the host reported: monitors load failed'), + severity: 'error', + })]); } finally { await rm(root, { force: true, recursive: true }); } @@ -1078,10 +1136,10 @@ nativeIt('records whether strict native validation inspects plugin theme content join(root, 'themes', 'invalid.json'), '{"name":"Missing base","overrides":{"error":7},"typo":true}\n', ); - const validation = await runClaudeValidation(root, root); + // Plugin mode, so a theme finding would be visible if the validator produced one. + const validation = await validateClaudePluginRoot(root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, validation.output).toBe('passed'); expect(validation.output).not.toContain('invalid.json'); } finally { await rm(root, { force: true, recursive: true }); @@ -1097,9 +1155,9 @@ nativeIt('records that strict native validation rejects the deprecated top-level const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; manifest['monitors'] = './monitors/monitors.json'; await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); - const validation = await runClaudeValidation(root, root); + const validation = await validateClaudePluginRoot(root); - expect(validation.code).not.toBe(0); + expect(validation.report.status, validation.output).not.toBe('passed'); expect(validation.output).toContain('monitors'); } finally { await rm(root, { force: true, recursive: true }); @@ -1190,10 +1248,9 @@ nativeIt('accepts emitted Claude workflows and output styles under strict native await emitPlanEntries({ entries: claudeAdapter.plan(payloadModel).entries, root: outputRoot }); expect(await readFile(join(outputRoot, 'workflows', 'release-audit.js'), 'utf8')).toBe(workflow); expect(await readFile(join(outputRoot, 'output-styles', 'terse.md'), 'utf8')).toBe(outputStyle); - const validation = await runClaudeValidation(outputRoot, outputRoot); + const validation = await validateClaudePluginRoot(outputRoot); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, validation.output).toBe('passed'); } finally { await rm(root, { force: true, recursive: true }); } @@ -1207,9 +1264,9 @@ nativeIt('records whether strict native validation inspects output-style frontma await writeClaudeArtifact(outputRoot, model); await mkdir(join(outputRoot, 'output-styles'), { recursive: true }); await writeFile(join(outputRoot, 'output-styles', 'missing-frontmatter.md'), 'Be concise.\n'); - const validation = await runClaudeValidation(outputRoot, outputRoot); + const validation = await validateClaudePluginRoot(outputRoot); - expect(validation.code, validation.output).toBe(0); + expect(validation.report.status, validation.output).toBe('passed'); expect(validation.output).not.toContain('missing-frontmatter.md'); } finally { await rm(root, { force: true, recursive: true }); @@ -1287,10 +1344,9 @@ nativeIt('accepts emitted Claude channels bound to a plugin MCP server under str try { const written = await writeClaudeArtifact(root, channelModel); expect(written).toContain('.mcp.json'); - const validation = await runClaudeValidation(root, root); + const validation = await validateClaudePluginRoot(root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, validation.output).toBe('passed'); } finally { await rm(root, { force: true, recursive: true }); } @@ -1305,12 +1361,11 @@ nativeIt('records whether strict native validation catches a dangling Claude cha const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; manifest['channels'] = [{ server: 'missing' }]; await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); - const validation = await runClaudeValidation(root, root); + const validation = await validateClaudePluginRoot(root); // Claude Code 2.1.257 validates the channel declaration shape but does // not cross-check `server` against the sibling .mcp.json keys. - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + expect(validation.report.status, validation.output).toBe('passed'); expect(validation.output).not.toContain('missing'); } finally { await rm(root, { force: true, recursive: true }); @@ -1325,10 +1380,22 @@ nativeIt('accepts emitted Claude plugin dependencies under strict native validat { marketplace: 'acme-shared', name: 'audit-logger' }, { marketplace: 'acme-shared', name: 'secrets-vault', version: '~2.1.0' }, ])); - const validation = await runClaudeValidation(root, root); - - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + const validation = await validateClaudePluginRoot(root, { loadCheck: false }); + expect(validation.report.status, validation.output).toBe('passed'); + + // The load check is machine-bound: this machine has no `acme-shared` marketplace, so Claude + // refuses the load for the missing dependency. That is reported as an AB7325 *warning* (the + // artifact is not at fault); `--strict` promotes it like any other host warning. + const loaded = await validateClaudePluginRoot(root); + expect(loaded.report.status, loaded.output).toBe('warnings'); + expect(loaded.report.load?.status).toBe('refused'); + expect(loaded.report.load?.errors).toEqual([expect.stringMatching(/^Dependency "audit-logger@acme-shared" is not installed/u)]); + expect(loaded.report.diagnostics).toEqual([expect.objectContaining({ + code: 'AB7325', + message: expect.stringContaining('(a declared dependency is not installed on this machine)'), + severity: 'warning', + })]); + expect((await validateClaudePluginRoot(root, { strict: true })).report.status).toBe('failed'); } finally { await rm(root, { force: true, recursive: true }); } @@ -1352,9 +1419,8 @@ nativeIt('accepts emitted Claude manifest metadata fields under strict native va metadata: { catalog: 'security', entitlement: { tier: 'team' } }, }); - const validation = await runClaudeValidation(root, root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + const validation = await validateClaudePluginRoot(root); + expect(validation.report.status, validation.output).toBe('passed'); } finally { await rm(root, { force: true, recursive: true }); } @@ -1375,9 +1441,9 @@ nativeIt('accepts a custom flat command path without a default commands director const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; await writeFile(manifestPath, `${JSON.stringify({ ...manifest, commands: './custom/deploy.md' })}\n`); - const validation = await runClaudeValidation(root, root); - expect(validation.code, validation.output).toBe(0); - expect(validation.output).toContain('Validation passed'); + // Plugin mode: the run checks that the custom `commands` path exists (Read the validation results). + const validation = await validateClaudePluginRoot(root); + expect(validation.report.status, validation.output).toBe('passed'); } finally { await rm(root, { force: true, recursive: true }); } diff --git a/packages/agent-bundle/tests/support/packed-native-smoke.ts b/packages/agent-bundle/tests/support/packed-native-smoke.ts index 33ff94622..1fa576e37 100644 --- a/packages/agent-bundle/tests/support/packed-native-smoke.ts +++ b/packages/agent-bundle/tests/support/packed-native-smoke.ts @@ -255,6 +255,39 @@ const summarizeEval = (host: PackedNativeHost, command: CommandResult) => { return Object.freeze({ host, status: passed ? 'passed' as const : 'failed' as const, trials: summary?.trials ?? 0 }); }; +interface PackedClaudeValidationDocument { + readonly hostValidation?: readonly { + readonly diagnostics?: readonly unknown[]; + readonly host?: unknown; + readonly load?: { readonly status?: unknown }; + readonly status?: unknown; + readonly target?: unknown; + readonly version?: unknown; + }[]; +} + +/** + * The packed `validate --artifact --strict --json` document must carry one Claude + * report that passed both the plugin-mode validation runs and the + * `--plugin-dir … plugin list --json` load check against the installed `claude`. + */ +const packedClaudeValidationPassed = (stdout: string, versionNumber: string): boolean => { + let document: PackedClaudeValidationDocument; + try { + document = JSON.parse(stdout) as PackedClaudeValidationDocument; + } catch { + return false; + } + // Other hosts' informational reports (Codex `AB6030`/`AB6031`) share the document; the exit + // code already proves none of them is an error. + const report = document.hostValidation?.find((entry) => entry.host === 'claude' && entry.target === 'claude'); + return report !== undefined + && report.status === 'passed' + && report.version === versionNumber + && report.load?.status === 'loaded' + && (report.diagnostics?.length ?? 0) === 0; +}; + /** * Packed-artifact proof for Claude's developer tools. It requires only the * installed binary, never authentication, and retains no plugin-list output. @@ -300,11 +333,24 @@ export const runPackedClaudePluginProof = async (options: { if (version.exitCode !== 0 || versionNumber === undefined) { throw new Error('packed-claude-proof:version'); } - const validation = await run('claude', ['plugin', 'validate', pluginDirectory, '--strict'], { - cwd: project, - environment, - }); - if (validation.exitCode !== 0) throw new Error('packed-claude-proof:validate'); + // The packed CLI's own host validation: `claude plugin validate --strict` against + // `.claude-plugin/plugin.json` (plugin.json, hooks/hooks.json, skills/, agents/, commands/), + // then `marketplace.json`, then the `--plugin-dir … plugin list --json` load check. A raw + // run against the directory would be a marketplace run that never opens the component + // files (#475), and the shared runner is what `build`, `validate --artifact`, and `doctor` + // execute in production. + const validation = await runNodeEntrypoint(cli, [ + 'validate', + '--root', + project, + '--artifact', + artifact, + '--strict', + '--json', + ], { cwd: project, environment }); + if (validation.exitCode !== 0 || !packedClaudeValidationPassed(validation.stdout, versionNumber)) { + throw new Error('packed-claude-proof:validate'); + } const plugins = await run('claude', ['--plugin-dir', pluginDirectory, 'plugin', 'list', '--json'], { cwd: project, environment, diff --git a/packages/workbench/src/discovery/discovery-client.ts b/packages/workbench/src/discovery/discovery-client.ts index 0c067ca7a..63d668b66 100644 --- a/packages/workbench/src/discovery/discovery-client.ts +++ b/packages/workbench/src/discovery/discovery-client.ts @@ -107,6 +107,7 @@ const durableStateSchema = z.strictObject({ const findingStateSchema = z.enum([ 'conflicted', 'corrupt', + 'disabled', 'drifted', 'failed', 'installed', diff --git a/packages/workbench/src/discovery/discovery-model.ts b/packages/workbench/src/discovery/discovery-model.ts index ee3ded01f..1aa8a96e8 100644 --- a/packages/workbench/src/discovery/discovery-model.ts +++ b/packages/workbench/src/discovery/discovery-model.ts @@ -133,6 +133,8 @@ export const findingPresentationFor = (state: DiscoveryFindingState): DiscoveryP return presentation('Conflicted', 'warning'); case 'corrupt': return presentation('Corrupt', 'warning'); + case 'disabled': + return presentation('Disabled', 'warning'); case 'drifted': return presentation('Drifted', 'warning'); case 'failed': diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index d181ef23c..d4b2a896e 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -116,6 +116,17 @@ place of `current`, because a refused copy contributes no hooks, MCP servers, or installer reads the same array: `agent-bundle install claude` fails with `AB7006` when the freshly installed (or byte-identical existing) copy carries `errors`, instead of reporting `installed`. +For Claude, Doctor also reads each row's `enabled` flag and runs the Claude Code developer +validator. A copy listed with `enabled: false` (switched off with `claude plugin disable` or the +`/plugin` menu) is reported as `disabled` in the inventory, keeps its content verdict in the +`--from` comparison, and adds an `AB7327` warning with the `claude plugin enable` command — a +disabled plugin loads nothing, and reinstalling does not enable it. The validator +(`claude plugin validate --strict` against `plugin.json`, then `marketplace.json`, the same runs +`build` and `validate --artifact` perform) covers the `--from` bundle and every installed copy, and +its findings keep their `AB6019`–`AB6022` codes prefixed with `Bundle at …` or +`Installed copy at … (scope …)`. Doctor never runs the `--plugin-dir` load check twice: its +registration proof and the rows' `errors` already hold that verdict. + | Code | Severity | Meaning | | --- | --- | --- | | `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | @@ -126,6 +137,7 @@ installed (or byte-identical existing) copy carries `errors`, instead of reporti | `AB7323` | warning | `~/.cursor/hooks.json` registers a command that points into an installed plugin directory, so Cursor would deliver that hook twice; manifest registration alone is sufficient. | | `AB7324` | info / warning / error | A marketplace staged by `install cursor --mode marketplace` is imported (`registered`), still awaiting the Customize import step (`unregistered`), or incomplete (`corrupt`). | | `AB7326` | info / warning / error | An Agent Plugins install under `~/.cursor/plugins/local` had its `mcp.json` placeholders expanded by the emitted `install.mjs` and every expanded path still resolves (`expanded`); still carries the spec forms Cursor 3.18.25 does not resolve, so its stdio servers cannot spawn (`unexpanded`); or was moved, duplicated, or edited after the expansion was recorded (`drifted`, entry `corrupt`). Reinstall with the bundle's `install.mjs` at the current location. | +| `AB7327` | warning | `doctor --from` compared an installed Claude copy whose `claude plugin list --json` row reports `enabled: false`; the recovery is `claude plugin enable @ [--scope ]`. | | `AB7320` | error / info | Error when a `.cursor-plugin/plugin.json` install violates Cursor's pinned document schemas or token-location rules (the hooks document checked is the one the manifest `hooks` field names, so the unified `plugin` bundle's Claude-format `hooks/hooks.json` beside `hooks/hooks-cursor.json` is not a finding), or when a local plugin contains a symlink escaping `~/.cursor/plugins/local`; the inventory entry is reported as `corrupt`. Info when a `.claude-plugin/plugin.json` or root `plugin.json` install has no Cursor-side pinned static document contract, and the loader-recognized entry stays `installed`. | ## Next diff --git a/website/docs/en/guide/distribution/validation.mdx b/website/docs/en/guide/distribution/validation.mdx index bba78a784..3307cdda8 100644 --- a/website/docs/en/guide/distribution/validation.mdx +++ b/website/docs/en/guide/distribution/validation.mdx @@ -36,7 +36,10 @@ diagnostic involved — see the [CLI exit codes](../../reference/cli.mdx#exit-co ## Claude Code strict validation When Claude Code is on `PATH`, artifact validation runs the installed `claude plugin validate` -for emitted `claude` and unified `plugin` targets. Claude Code decides what it checks from the +for emitted `claude` and unified `plugin` targets. `agent-bundle build` runs the same checks over +the artifact it just wrote (`--no-host-validation` skips them; `--strict` promotes host warnings to +build errors), and `agent-bundle doctor --host claude --from ` runs them over the +bundle and over every installed copy Claude lists. Claude Code decides what it checks from the manifest it is pointed at, and a bundle directory that holds both `.claude-plugin/plugin.json` and `.claude-plugin/marketplace.json` is treated as a marketplace, whose validation never opens the plugin's hook, skill, agent, or command files. Agent Bundle therefore runs two commands: @@ -56,7 +59,28 @@ claude plugin validate /.claude-plugin/marketplace.json --strict report. The marketplace run's `plugins[0] plugin.json →` copies of manifest findings the plugin run already reported are dropped. - A missing binary is reported as an explicit informational **skip** (`AB6019`), never as - fabricated success; a probe or run that cannot complete is `AB6022`. + fabricated success; a probe or run that cannot complete is `AB6022`. A `build` without `claude` + on `PATH` spawns once and reports the skip once, however many `claude`/`plugin` targets it built. + +### The load check + +Validation is not a load verdict. Claude Code 2.1.250 through 2.1.260 accept, under `--strict`, +manifests and component files that a session then refuses to load — an invalid +`monitors/monitors.json`, or a `hooks` field naming the auto-loaded `hooks/hooks.json`. The only +place the refusal shows is the plugin's row in `claude --plugin-dir plugin list --json` +("Plugins reference" → "plugin list"): a refused row carries an `errors` array with the host's +message, a healthy row has no `errors` key. So `build` and `validate --artifact` follow the two +validation runs with exactly that listing and read the bundle's `@inline` row: + +| `load.status` | Meaning | Code | +| --- | --- | --- | +| `loaded` | The row has no `errors`. | — | +| `refused` | The row carries `errors`; they are quoted verbatim. | `AB7325` (error; a warning when every entry is `Dependency "…" is not installed`, since that describes the validating machine rather than the artifact — `--strict` promotes it) | +| `unregistered` | No row for the bundle: Claude Code did not register the directory. | `AB7311` | +| `failed` | The listing could not be read (nonzero exit, timeout, not a JSON array). | `AB6022` | + +The listing is read-only and needs no login. Doctor does not repeat it: its `--plugin-dir` +registration proof and the inventory rows' `errors` already carry the same verdicts. CI should use strict validation: @@ -81,14 +105,18 @@ visible to a bare `claude plugin list`; put the flag before the subcommand to in claude --plugin-dir artifact/claude plugin list --json ``` -That listing is the load verdict, and it is stricter than validation: `claude plugin validate --strict` -accepts a manifest that Claude Code then refuses at load time (for example a `hooks` field naming the -auto-loaded `hooks/hooks.json`, "Duplicate hooks file detected"). A refused plugin's row carries an -`errors` array with the host's message; a healthy row has no `errors` key. `agent-bundle doctor --host -claude --from ` reads that array on both the `--plugin-dir` registration proof and the installed -copy and reports it as `AB7325` (`load-failed`), and `agent-bundle install claude` fails with `AB7006` when -the installed copy carries it. The pinned Claude `plugin` schema also rejects `hooks: "./hooks/hooks.json"` -up front (`AB6012`), since the manifest field is for *additional* hook files only. +That listing is the load verdict described above, and `build` and `validate --artifact` run it for +you. `agent-bundle doctor --host claude --from ` reads the same `errors` array on both +the `--plugin-dir` registration proof and the installed copy and reports it as `AB7325` +(`load-failed`), and `agent-bundle install claude` fails with `AB7006` when the installed copy +carries it. The pinned Claude `plugin` schema also rejects `hooks: "./hooks/hooks.json"` up front +(`AB6012`), since the manifest field is for *additional* hook files only. + +Doctor also reads each row's `enabled` flag. A copy that `claude plugin disable` switched off +("Plugins reference" → "plugin disable") is still installed and may still be byte-identical to the +bundle, but none of it reaches a session; Doctor lists it as `disabled`, keeps its content verdict, +and adds an `AB7327` warning whose recovery is the exact `claude plugin enable @ +[--scope ]` command — reinstalling does not enable a disabled plugin. ## Codex and Cursor: pinned schemas diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index ec4d01a6b..066d4a8a1 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -76,6 +76,17 @@ Both take the source option group plus: Both build package outputs when the project declares `bin` or `lib`. +`build` alone also takes: + +| Option | Default | Meaning | +| --- | --- | --- | +| `--host-validation` / `--no-host-validation` | on | After the artifact is written, run the installed Claude Code developer validator and load check over every built `claude` and `plugin` target — the same pair `validate --artifact` runs (`AB6019`–`AB6022`, `AB7311`, `AB7325`). Without `claude` on `PATH` the build spawns once and reports one informational `AB6019` skip. | +| `--strict` | off | Promote host-tool warnings to errors. | + +The JSON document carries the reports under `hostValidation`, one per validated target, each +with a `load` verdict (`loaded`, `refused`, `unregistered`, or `failed`) when the load check ran. +Programmatic `build()` calls never run host validation unless `hostValidation: true` is passed. + ## install ```sh @@ -108,7 +119,12 @@ Doctor exits `1` when it reports any error diagnostic, and never modifies anythi it reports the installed copy per host as `current`, `stale` (same version, different content — `AB7308`), `version-mismatch` (`AB7309`), `foreign` (`AB7321`), `load-failed` (`AB7325` — Claude Code lists the copy with `errors` in `claude plugin list --json` and refused to load it; the message carries the host's -text verbatim), or `not-installed` (`AB7307`). For +text verbatim), or `not-installed` (`AB7307`). For Claude it also reads each row's `enabled` flag: a +copy listed with `enabled: false` is `disabled` in the inventory and carries `enabled: false` next to its +content verdict, with an `AB7327` warning naming the `claude plugin enable` command; and it runs the +Claude Code developer validator (`claude plugin validate --strict` against `plugin.json`, then +`marketplace.json`) over the `--from` bundle and every installed copy, reporting findings as +`AB6020`–`AB6022` prefixed with `Bundle at …` or `Installed copy at …`. For Cursor it also proves plugin-scoped hook registration from the installed manifest (`AB7322`), warns when `~/.cursor/hooks.json` would deliver a plugin hook a second time (`AB7323`), and tracks a staged marketplace from `unregistered` to `registered` once Cursor imports it (`AB7324`). For an @@ -120,7 +136,7 @@ Agent Plugins install it proves the emitted installer's placeholder expansion | Option | Default | Meaning | | --- | --- | --- | | `--artifact ` | — | Validate exactly this built artifact instead of project source. | -| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. | +| `--host-validation` / `--no-host-validation` | on | Run installed host developer tools for compatible built targets. For `claude` and `plugin` targets this is the two `claude plugin validate --strict` runs followed by the `claude --plugin-dir plugin list --json` load check (`AB7325` when the row carries `errors`, `AB7311` when there is no row). | | `--strict` | off | Promote host-tool warnings to errors. | ## eval diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 570dade16..8085acf3e 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -98,6 +98,14 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list 该数组而不是 `current`,因为被拒绝的副本不会为会话提供任何 hook、MCP 服务器或技能。安装器读取同一数组:当刚安装的 (或字节完全相同的既有)副本带有 `errors` 时,`agent-bundle install claude` 以 `AB7006` 失败,而不是报告 `installed`。 +对 Claude,Doctor 还会读取每一行的 `enabled` 标志并运行 Claude Code 开发者校验器。被列为 `enabled: false` +的副本(用 `claude plugin disable` 或 `/plugin` 菜单关闭)在清点中报告为 `disabled`,在 `--from` 比对中保留其内容 +结论,并追加一条带 `claude plugin enable` 命令的 `AB7327` warning——被禁用的插件不会加载任何内容,重新安装也不会启用它。 +校验器(对 `plugin.json`、再对 `marketplace.json` 执行 `claude plugin validate --strict`,与 `build` 和 +`validate --artifact` 执行的是同样的运行)覆盖 `--from` 捆绑包以及每一份已安装副本,其发现保留 `AB6019`–`AB6022` +代码,并带有 `Bundle at …` 或 `Installed copy at … (scope …)` 前缀。Doctor 绝不会重复运行 `--plugin-dir` 加载检查: +它的注册证明与各行的 `errors` 已经承载了该结论。 + | 代码 | 严重级别 | 含义 | | --- | --- | --- | | `AB7316` | warning | 某个已安装捆绑包的 `state/` 目录,或它的某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | @@ -108,6 +116,7 @@ Claude Code 列出了该副本却拒绝加载它——它在 `claude plugin list | `AB7323` | warning | `~/.cursor/hooks.json` 注册了指向某个已安装插件目录的命令,Cursor 会把该 hook 投递两次;仅靠清单注册就已足够。 | | `AB7324` | info / warning / error | 由 `install cursor --mode marketplace` 暂存的市场已被导入(`registered`)、仍在等待 Customize 导入步骤(`unregistered`),或不完整(`corrupt`)。 | | `AB7326` | info / warning / error | `~/.cursor/plugins/local` 下的某个 Agent Plugins 安装:其 `mcp.json` 占位符已由输出的 `install.mjs` 展开且每个展开后的路径仍可解析(`expanded`);仍带有 Cursor 3.18.25 不会解析的规范形式,因此其 stdio 服务器无法启动(`unexpanded`);或在记录展开之后被移动、复制或编辑(`drifted`,条目为 `corrupt`)。请在当前位置用捆绑包的 `install.mjs` 重新安装。 | +| `AB7327` | warning | `doctor --from` 比对了一份其 `claude plugin list --json` 行报告 `enabled: false` 的已安装 Claude 副本;修复方式是 `claude plugin enable @ [--scope ]`。 | | `AB7320` | error / info | 当某个 `.cursor-plugin/plugin.json` 安装违反 Cursor 被固定的文档 schema 或 token 位置规则(检查的 hooks 文档是清单 `hooks` 字段所指定的那个,因此统一 `plugin` 捆绑包中与 `hooks/hooks-cursor.json` 并存的 Claude 格式 `hooks/hooks.json` 不构成发现),或某个本地插件包含逃逸出 `~/.cursor/plugins/local` 的符号链接时为 error,该清点条目被报告为 `corrupt`。当某个 `.claude-plugin/plugin.json` 或根 `plugin.json` 安装没有 Cursor 侧被固定的静态文档契约时为 info,被加载器识别的条目仍为 `installed`。 | ## 下一步 diff --git a/website/docs/zh/guide/distribution/validation.mdx b/website/docs/zh/guide/distribution/validation.mdx index 49e7c6268..95059e63c 100644 --- a/website/docs/zh/guide/distribution/validation.mdx +++ b/website/docs/zh/guide/distribution/validation.mdx @@ -31,7 +31,10 @@ npx agent-bundle validate --artifact artifact --strict # 已构建字节, ## Claude Code 严格校验 当 Claude Code 位于 `PATH` 上时,产物校验会为输出的 `claude` target 以及统一的 `plugin` target 运行 -已安装的 `claude plugin validate`。Claude Code 根据被指向的清单决定检查什么:一个同时包含 +已安装的 `claude plugin validate`。`agent-bundle build` 会对刚写出的产物运行同样的检查 +(`--no-host-validation` 跳过;`--strict` 把宿主 warning 提升为构建 error),而 +`agent-bundle doctor --host claude --from ` 会对该 bundle 以及 Claude 列出的每一份已安装副本运行 +这些检查。Claude Code 根据被指向的清单决定检查什么:一个同时包含 `.claude-plugin/plugin.json` 与 `.claude-plugin/marketplace.json` 的 bundle 目录会被当作 marketplace, 而 marketplace 校验从不打开插件的 hook、skill、agent 或 command 文件。因此 Agent Bundle 运行两条命令: @@ -49,7 +52,26 @@ claude plugin validate /.claude-plugin/marketplace.json --strict 及更高版本上,Agent Bundle 读取 CLI 的 `--json` 报告;更早的版本则解析文本报告。marketplace 那次 运行中对插件运行已报告过的清单发现的 `plugins[0] plugin.json →` 副本会被丢弃。 - 二进制文件缺失会被报告为一次显式的信息性**跳过**(`AB6019`),绝不会伪造成功;探测或运行无法完成 - 时为 `AB6022`。 + 时为 `AB6022`。`PATH` 上没有 `claude` 时,`build` 只会启动一次进程并只报告一次跳过,无论它构建了多少个 + `claude`/`plugin` target。 + +### 加载检查 + +校验不是加载结论。Claude Code 2.1.250 到 2.1.260 在 `--strict` 下会接受一些随后会被会话拒绝加载的清单与 +组件文件——例如无效的 `monitors/monitors.json`,或指向自动加载的 `hooks/hooks.json` 的 `hooks` 字段。拒绝 +唯一显现的地方是 `claude --plugin-dir plugin list --json`("Plugins reference" → "plugin list") +中该插件所在的行:被拒绝的行带有 `errors` 数组,内含宿主的消息;健康的行没有 `errors` 键。因此 `build` 与 +`validate --artifact` 在两次校验运行之后正好执行这条列表命令,并读取 bundle 的 `@inline` 行: + +| `load.status` | 含义 | 代码 | +| --- | --- | --- | +| `loaded` | 该行没有 `errors`。 | — | +| `refused` | 该行带有 `errors`;逐字引用。 | `AB7325`(error;当每一项都是 `Dependency "…" is not installed` 时为 warning,因为它描述的是执行校验的机器而非产物——`--strict` 会提升它) | +| `unregistered` | 没有该 bundle 的行:Claude Code 没有把该目录注册为插件。 | `AB7311` | +| `failed` | 列表无法读取(非零退出、超时、不是 JSON 数组)。 | `AB6022` | + +这条列表命令是只读的,也不需要登录。Doctor 不会重复它:其 `--plugin-dir` 注册证明与清单行中的 `errors` +已经承载了同样的结论。 CI 应当使用严格校验: @@ -74,12 +96,16 @@ claude --plugin-dir artifact/claude claude --plugin-dir artifact/claude plugin list --json ``` -这份列表才是加载结论,而且比校验更严格:`claude plugin validate --strict` 会接受一份 Claude Code 随后在加载时 -拒绝的清单(例如 `hooks` 字段指向自动加载的 `hooks/hooks.json`——"Duplicate hooks file detected")。被拒绝插件 -所在的行带有 `errors` 数组,内含宿主的消息;健康的行没有 `errors` 键。`agent-bundle doctor --host claude --from -` 会在 `--plugin-dir` 注册证明与已安装副本两处读取该数组,并报告为 `AB7325`(`load-failed`);当已安装 -副本带有该数组时,`agent-bundle install claude` 以 `AB7006` 失败。被固定的 Claude `plugin` schema 也会预先拒绝 -`hooks: "./hooks/hooks.json"`(`AB6012`),因为清单中的该字段只用于*额外的* hook 文件。 +这份列表就是上文描述的加载结论,`build` 与 `validate --artifact` 会替你运行它。`agent-bundle doctor --host +claude --from ` 会在 `--plugin-dir` 注册证明与已安装副本两处读取同一个 `errors` 数组,并报告为 +`AB7325`(`load-failed`);当已安装副本带有该数组时,`agent-bundle install claude` 以 `AB7006` 失败。被固定的 +Claude `plugin` schema 也会预先拒绝 `hooks: "./hooks/hooks.json"`(`AB6012`),因为清单中的该字段只用于*额外的* +hook 文件。 + +Doctor 还会读取每一行的 `enabled` 标志。被 `claude plugin disable` 关闭的副本("Plugins reference" → +"plugin disable")仍然处于已安装状态,甚至可能与 bundle 字节一致,但它的任何内容都不会进入会话;Doctor 把它列为 +`disabled`,保留其内容结论,并追加一条 `AB7327` warning,其修复建议就是精确的 +`claude plugin enable @ [--scope ]` 命令——重新安装不会启用一个被禁用的插件。 ## Codex 与 Cursor:被固定的 schema diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 7db6314f8..71b2d2b97 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -76,6 +76,17 @@ proxy 从父命令 `dev` 继承 `--root`,并把诊断写到 stderr。 当项目声明了 `bin` 或 `lib` 时,两者都会构建包输出。 +仅 `build` 还接受: + +| 选项 | 默认值 | 含义 | +| --- | --- | --- | +| `--host-validation` / `--no-host-validation` | 开启 | 产物写出后,对每个已构建的 `claude` 与 `plugin` target 运行已安装的 Claude Code 开发者校验器与加载检查——与 `validate --artifact` 运行的是同一对检查(`AB6019`–`AB6022`、`AB7311`、`AB7325`)。`PATH` 上没有 `claude` 时,构建只启动一次进程并报告一条信息性的 `AB6019` 跳过。 | +| `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | + +JSON 文档在 `hostValidation` 下携带这些报告,每个被校验的 target 一份;加载检查运行过时,每份报告带有 +`load` 结论(`loaded`、`refused`、`unregistered` 或 `failed`)。以编程方式调用 `build()` 时,除非传入 +`hostValidation: true`,否则绝不运行宿主校验。 + ## install ```sh @@ -105,7 +116,11 @@ agent-bundle install [--from ] [--scope ] [--mode [--from ] [--scope ] [--mode ` | —— | 精确校验这份已构建产物,而不是项目源码。 | -| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。 | +| `--host-validation` / `--no-host-validation` | 开启 | 为兼容的已构建 target 运行已安装的宿主开发者工具。对 `claude` 与 `plugin` target,这是两次 `claude plugin validate --strict` 运行,随后是 `claude --plugin-dir plugin list --json` 加载检查(该行带有 `errors` 时为 `AB7325`,没有该行时为 `AB7311`)。 | | `--strict` | 关闭 | 把宿主工具的 warning 提升为 error。 | ## eval diff --git a/website/plugins/generated-reference.ts b/website/plugins/generated-reference.ts index 83b02fe92..7b35169d2 100644 --- a/website/plugins/generated-reference.ts +++ b/website/plugins/generated-reference.ts @@ -169,6 +169,7 @@ const messages = { reason: 'Reason', channel: 'Channel', hosts: 'Hosts', + detail: 'Detail', }, unavailable: 'unavailable', notApplicable: '—', @@ -249,6 +250,7 @@ const messages = { reason: '原因', channel: '通道', hosts: '宿主', + detail: '详情', }, unavailable: 'unavailable', notApplicable: '—',