diff --git a/.changeset/claude-validation-followups.md b/.changeset/claude-validation-followups.md new file mode 100644 index 000000000..a28238318 --- /dev/null +++ b/.changeset/claude-validation-followups.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Resolve Claude plugin directories before invoking the host validator, and fail validation when the Claude CLI version probe cannot complete successfully. 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 86149652f..db60988ce 100644 --- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts @@ -1,4 +1,4 @@ -import { dirname } from 'node:path'; +import { dirname, resolve } from 'node:path'; import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; @@ -61,7 +61,9 @@ const diagnostic = ( message, recovery: code === 'AB6019' ? 'Install Claude Code and ensure `claude` is on PATH, then rerun artifact validation.' - : 'Run `claude plugin validate --strict`, repair the reported Claude artifact, and rebuild.', + : code === 'AB6022' + ? 'Verify the Claude CLI starts and responds, then rerun `claude plugin validate --strict`.' + : 'Run `claude plugin validate --strict`, repair the reported Claude artifact, and rebuild.', severity, target, }); @@ -88,33 +90,49 @@ const issueLines = (output: string): readonly { readonly message: string; readon 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(options.pluginDirectory); + const cwd = dirname(pluginDirectory); let version: string | undefined; try { const probe = await run(Object.freeze({ args: Object.freeze(['--version']), cwd, executable })); if (probe.exitCode !== 0 || probe.termination !== undefined) { return Object.freeze({ diagnostics: freezeDiagnostics([diagnostic( - 'AB6019', - 'The Claude CLI is unavailable for host artifact validation.', - 'info', + 'AB6022', + 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'}.`, + 'error', options.target, )]), host: 'claude', - status: 'unavailable', + status: 'failed', target: options.target, }); } version = versionFrom(`${probe.stdout}\n${probe.stderr}`); } catch (error) { + if (!isErrno(error, 'ENOENT')) { + return Object.freeze({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB6022', + 'Claude CLI version probe could not be started.', + 'error', + options.target, + )]), + host: 'claude', + status: 'failed', + target: options.target, + }); + } return Object.freeze({ diagnostics: freezeDiagnostics([diagnostic( 'AB6019', - isErrno(error, 'ENOENT') - ? 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.' - : 'The Claude CLI could not be started; host artifact validation was skipped.', + 'The Claude CLI is not installed or is not on PATH; host artifact validation was skipped.', 'info', options.target, )]), @@ -127,7 +145,7 @@ export const validateClaudePlugin = async ( let result: ClaudePluginCommandResult; try { result = await run(Object.freeze({ - args: Object.freeze(['plugin', 'validate', options.pluginDirectory, '--strict']), + args: Object.freeze(['plugin', 'validate', pluginDirectory, '--strict']), cwd, executable, })); diff --git a/packages/agent-bundle/tests/claude-plugin-validation.test.ts b/packages/agent-bundle/tests/claude-plugin-validation.test.ts index 15c156ddd..05590ae99 100644 --- a/packages/agent-bundle/tests/claude-plugin-validation.test.ts +++ b/packages/agent-bundle/tests/claude-plugin-validation.test.ts @@ -1,3 +1,5 @@ +import { dirname, resolve } from 'node:path'; + import { expect, it } from '@rstest/core'; import { @@ -44,6 +46,24 @@ it('runs the installed Claude validator without shell interpolation', async () = }); }); +it('resolves a multi-segment relative plugin directory before invoking Claude', async () => { + const fixture = runWith({ exitCode: 0, stdout: '✔ Validation passed\n' }); + const pluginDirectory = resolve('fixtures/plugin'); + await validateClaudePlugin({ + pluginDirectory: 'fixtures/plugin', + run: fixture.run, + target: 'claude', + }); + + expect(fixture.calls).toEqual([ + expect.objectContaining({ args: ['--version'], cwd: dirname(pluginDirectory) }), + expect.objectContaining({ + args: ['plugin', 'validate', pluginDirectory, '--strict'], + cwd: dirname(pluginDirectory), + }), + ]); +}); + it('keeps host warnings as warnings unless framework strict mode is enabled', async () => { const output = [ '⚠ Found 2 warnings:', @@ -122,3 +142,89 @@ it('reports an honest informational skip when Claude is absent', async () => { target: 'claude', }); }); + +it('fails host validation when the Claude version probe times out', async () => { + const report = await validateClaudePlugin({ + pluginDirectory: '/tmp/plugin', + run: async () => ({ + exitCode: null, + signal: 'SIGTERM', + stderr: '', + stdout: '', + termination: 'timed-out', + }), + target: 'claude', + }); + + expect(report).toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB6022', + message: expect.stringContaining('version probe timed out'), + severity: 'error', + })], + status: 'failed', + }); +}); + +it('fails host validation when the Claude version probe exits nonzero', async () => { + const report = await validateClaudePlugin({ + pluginDirectory: '/tmp/plugin', + run: async () => ({ + exitCode: 2, + signal: null, + stderr: 'version failed', + stdout: '', + }), + target: 'claude', + }); + + expect(report).toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB6022', + message: expect.stringContaining('version probe exited with code 2'), + severity: 'error', + })], + status: 'failed', + }); +}); + +it('fails host validation when the Claude version probe exceeds its output limit', async () => { + const report = await validateClaudePlugin({ + pluginDirectory: '/tmp/plugin', + run: async () => ({ + exitCode: null, + signal: 'SIGTERM', + stderr: '', + stdout: '', + termination: 'output-limit', + }), + target: 'claude', + }); + + expect(report).toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB6022', + message: expect.stringContaining('version probe exceeded its output limit'), + severity: 'error', + })], + status: 'failed', + }); +}); + +it('fails host validation when the Claude version probe cannot be spawned', async () => { + const denied = Object.assign(new Error('spawn claude EACCES'), { code: 'EACCES' }); + const report = await validateClaudePlugin({ + pluginDirectory: '/tmp/plugin', + run: async () => { throw denied; }, + target: 'claude', + }); + + expect(report).toMatchObject({ + diagnostics: [expect.objectContaining({ + code: 'AB6022', + message: expect.stringContaining('version probe could not be started'), + severity: 'error', + })], + status: 'failed', + }); +});