From 3a0854362fd5ac44e4572a42417dc2eabf245702 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 23:23:14 +0000 Subject: [PATCH 1/2] feat(doctor): read-only agent-bundle doctor for host installs and runtime endpoints (#101 stage 1) --- .changeset/doctor-read-only.md | 5 + docs/diagnostics.md | 2 + packages/agent-bundle/src/cli.ts | 67 +- packages/agent-bundle/src/install/doctor.ts | 935 +++++++++++++++++++ packages/agent-bundle/src/install/install.ts | 2 +- packages/agent-bundle/tests/doctor.test.ts | 621 ++++++++++++ 6 files changed, 1630 insertions(+), 2 deletions(-) create mode 100644 .changeset/doctor-read-only.md create mode 100644 packages/agent-bundle/src/install/doctor.ts create mode 100644 packages/agent-bundle/tests/doctor.test.ts diff --git a/.changeset/doctor-read-only.md b/.changeset/doctor-read-only.md new file mode 100644 index 000000000..7bfcd693a --- /dev/null +++ b/.changeset/doctor-read-only.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add a read-only `agent-bundle doctor` command for inspecting host availability, installed bundles, bundle drift, and runtime endpoint health without applying repairs. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 438b78650..812212e32 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -24,7 +24,9 @@ gate a build, a validation, or a dev rebuild. | `AB473x` | Migration nudges (informational; see below). | | `AB474x`/`AB4750` | Prebuilt payloads and prebuilt entries (see below). | | `AB5000` | General CLI and adapter failures. | +| `AB700x` | Host installation: bundle identity, host availability, scope, command failure, and collision checks. | | `AB7xxx` | Project preparation and development rebuilds. | +| `AB7300`–`AB7315` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, and runtime endpoint health. | | `AB8xxx` | Development server configuration. | | `AB9xxx` | Eval selection, harnesses, and persisted runs. | diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 3232ddd6c..a6979559b 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command, CommanderError } from 'commander'; +import { Command, CommanderError, InvalidArgumentError } from 'commander'; import { readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; @@ -24,6 +24,11 @@ import type { InstallResult, InstallScope, } from './install/install.ts'; +import type { + DoctorHost, + DoctorReport, + runDoctor, +} from './install/doctor.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; @@ -47,6 +52,7 @@ interface CliSignalSource { export interface CliDependencies { readonly installBundle?: typeof installBundle; + readonly runDoctor?: typeof runDoctor; /** Injectable only to make foreground shutdown behavior deterministic in tests. */ readonly signals?: CliSignalSource; readonly startDevServer?: typeof startDevServer; @@ -74,6 +80,12 @@ interface InstallCommandOptions { readonly scope: string; } +interface DoctorCommandOptions { + readonly from?: string; + readonly host: readonly DoctorHost[]; + readonly json?: boolean; +} + interface EvalCommandOptions extends SourceCommandOptions { readonly artifact?: string; readonly case?: readonly string[]; @@ -141,6 +153,14 @@ const installScope = (value: string): InstallScope => { throw new TypeError('Install scope must be user, project, or local.'); }; +const doctorHost = (value: string): DoctorHost => { + if (value === 'claude' || value === 'codex' || value === 'cursor') return value; + throw new InvalidArgumentError('Doctor host must be claude, codex, or cursor.'); +}; + +const collectDoctorHost = (value: string, previous: readonly DoctorHost[]): readonly DoctorHost[] => + [...previous, doctorHost(value)]; + const configureSourceOptions = (command: Command): Command => command .option('--root ', 'Project root', process.cwd()) .option('--config ', 'Configuration file relative to --root') @@ -238,6 +258,35 @@ const writeHumanInstall = (output: Output, result: InstallResult): void => { ); }; +const writeHumanDoctor = (output: Output, result: DoctorReport): void => { + for (const host of result.hosts) { + const detail = host.probe.version ?? host.probe.evidence; + output.write(`${host.host}: ${host.probe.status}${detail === undefined ? '' : ` (${detail})`}\n`); + output.write( + ` inventory: ${host.inventory.status}` + + `${host.inventory.status === 'known' ? ` (${host.inventory.findings.length} finding(s))` : ''}\n`, + ); + if (host.bundle !== undefined) { + const identity = host.bundle.name === undefined + ? '' + : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; + output.write(` bundle:${identity} ${host.bundle.state}\n`); + } + } + output.write( + `runtime endpoints: ${result.endpoints.status}; ${result.endpoints.summary.live} live, ` + + `${result.endpoints.summary.staleSockets} stale socket(s), ` + + `${result.endpoints.summary.staleLocks} stale lock(s)\n`, + ); + for (const entry of result.diagnostics) { + output.write(`${entry.code}: ${entry.message}\nRecovery: ${entry.recovery}\n`); + } + output.write( + `Doctor summary: ${result.summary.errors} error(s), ${result.summary.warnings} warning(s), ` + + `${result.summary.infos} info(s)\n`, + ); +}; + const writeHumanInspect = (output: Output, result: Awaited>): void => { if (result.state === 'invalid') { for (const diagnostic of result.diagnostics) { @@ -418,6 +467,22 @@ export const runCli = async ( else writeHumanInstall(stdout, result); }); + const doctorCommand = program.command('doctor') + .description('Inspect host installs and runtime endpoints without changing them') + .option('--host ', 'Host to inspect (repeatable)', collectDoctorHost, []) + .option('--from ', 'Target bundle directory or artifact root') + .option('--json', 'Write one machine-readable JSON document'); + doctorCommand.action(async (options: DoctorCommandOptions) => { + const doctor = dependencies.runDoctor ?? (await import('./install/doctor.ts')).runDoctor; + const result = await doctor({ + ...(options.from === undefined ? {} : { from: options.from }), + ...(options.host.length === 0 ? {} : { hosts: options.host }), + }); + if (options.json === true) writeMachine(stdout, result); + else writeHumanDoctor(stdout, result); + if (result.diagnostics.some((entry) => entry.severity === 'error')) exitCode = 1; + }); + const validateCommand = configureSourceOptions( program.command('validate').description('Validate project source or one artifact'), ) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts new file mode 100644 index 000000000..9d6643561 --- /dev/null +++ b/packages/agent-bundle/src/install/doctor.ts @@ -0,0 +1,935 @@ +import { lstat, readFile, readdir } from 'node:fs/promises'; +import { createConnection } from 'node:net'; +import { homedir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { + freezeDiagnostics, + type Diagnostic, + type DiagnosticSeverity, +} from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; +import type { + BoundedChildProcessRequest, + BoundedChildProcessResult, +} from '../host-contracts/process.ts'; +import { runBoundedChildProcess } from '../host-contracts/process.ts'; +import { treeHash, type InstallHost } from './install.ts'; + +export type DoctorHost = InstallHost; +export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; +export type DoctorInventoryStatus = 'known' | 'skipped' | 'unknown'; +export type DoctorFindingState = + | 'conflicted' + | 'corrupt' + | 'drifted' + | 'failed' + | 'installed' + | 'interrupted-install' + | 'live' + | 'missing' + | 'registered' + | 'skipped' + | 'stale-lock' + | 'stale-socket' + | 'unknown' + | 'unregistered'; + +export type DoctorCommandTermination = 'output-limit' | 'timed-out'; +export type DoctorCommandResult = BoundedChildProcessResult; +export type DoctorCommandRunner = ( + request: BoundedChildProcessRequest, +) => Promise; + +export interface DoctorOptions { + readonly commandRunner?: DoctorCommandRunner; + readonly endpointDirectory?: string; + readonly from?: string; + readonly home?: string; + readonly hosts?: readonly DoctorHost[]; + readonly platform?: NodeJS.Platform; +} + +export interface DoctorHostProbe { + readonly evidence?: 'directory'; + readonly status: DoctorHostProbeStatus; + readonly version?: string; +} + +export interface DoctorFinding { + readonly entry?: string; + readonly manifest?: string; + readonly name?: string; + readonly path?: string; + readonly state: DoctorFindingState; + readonly version?: string; +} + +export interface DoctorInventory { + readonly findings: readonly DoctorFinding[]; + readonly status: DoctorInventoryStatus; +} + +export interface DoctorHostReport { + readonly bundle?: DoctorFinding & { + readonly bundleRoot?: string; + readonly marketplace?: string; + }; + readonly diagnostics: readonly Diagnostic[]; + readonly host: DoctorHost; + readonly inventory: DoctorInventory; + readonly probe: DoctorHostProbe; +} + +export interface DoctorEndpointReport { + readonly diagnostics: readonly Diagnostic[]; + readonly directory: string; + readonly findings: readonly DoctorFinding[]; + readonly status: 'failed' | 'healthy' | 'skipped' | 'warnings'; + readonly summary: { + readonly live: number; + readonly staleLocks: number; + readonly staleSockets: number; + }; +} + +export interface DoctorReport { + readonly diagnostics: readonly Diagnostic[]; + readonly endpoints: DoctorEndpointReport; + readonly hosts: readonly DoctorHostReport[]; + readonly summary: { + readonly errors: number; + readonly infos: number; + readonly warnings: number; + }; +} + +export const doctorEndpointDirectory = (): string => { + const user = typeof process.getuid === 'function' ? String(process.getuid()) : 'user'; + // Keep this derivation paired with events/ipc.ts; the regression test guards drift. + return join('/tmp', `agent-bundle-${user}`); +}; + +interface PluginIdentity { + readonly bundleRoot: string; + readonly marketplace?: string; + readonly name: string; + readonly version: string; +} + +const maximumOutputBytes = 1024 * 1024; + +const defaultCommandRunner: DoctorCommandRunner = (request) => + runBoundedChildProcess(request, { + labels: { outputLimit: 'output-limit', timedOut: 'timed-out' }, + maxOutputBytes: maximumOutputBytes, + timeoutMs: request.args[0] === '--version' ? 5_000 : 15_000, + windowsHide: true, + }); + +const diagnostic = ( + code: `AB73${number}`, + message: string, + recovery: string, + severity: DiagnosticSeverity, + target?: DoctorHost, +): Diagnostic => Object.freeze({ + code, + message, + recovery, + severity, + ...(target === undefined ? {} : { target }), +}); + +const versionFrom = (output: string): string | undefined => + /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(output)?.[1]; + +const manifestPath = (host: DoctorHost): string => { + switch (host) { + case 'claude': + return '.claude-plugin/plugin.json'; + case 'codex': + return '.codex-plugin/plugin.json'; + case 'cursor': + return '.cursor-plugin/plugin.json'; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown Doctor host ${String(exhaustive)}.`); + } + } +}; + +const marketplacePath = (host: Exclude): string => + host === 'claude' + ? '.claude-plugin/marketplace.json' + : '.agents/plugins/marketplace.json'; + +const exists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if (isErrno(error, 'ENOENT')) return false; + throw error; + } +}; + +const readRecord = async (path: string, kind: string): Promise> => { + let value: unknown; + try { + value = JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch { + throw new Error(`Cannot read a valid ${kind} at ${JSON.stringify(path)}.`); + } + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${kind} at ${JSON.stringify(path)} must be a JSON object.`); + } + return value as Record; +}; + +const readString = ( + record: Readonly>, + key: string, + kind: string, +): string => { + const value = record[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error(`${kind} must declare a nonempty ${key}.`); + } + return value; +}; + +const resolveBundleRoot = async (from: string, host: DoctorHost): Promise => { + const root = resolve(from); + const manifest = manifestPath(host); + if (await exists(join(root, manifest))) return root; + const targetRoot = join(root, host); + if (await exists(join(targetRoot, manifest))) return targetRoot; + throw new Error( + `No ${host} bundle manifest was found in ${JSON.stringify(root)} or its ` + + `${JSON.stringify(host)} target directory.`, + ); +}; + +const readIdentity = async (from: string, host: DoctorHost): Promise => { + const bundleRoot = await resolveBundleRoot(from, host); + const kind = `${host} plugin manifest`; + const pluginDocument = await readRecord(join(bundleRoot, manifestPath(host)), kind); + const name = readString(pluginDocument, 'name', kind); + const version = readString(pluginDocument, 'version', kind); + if ( + host === 'cursor' && + (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(name) || name.length > 64) + ) { + throw new Error(`Cursor plugin name ${JSON.stringify(name)} is not a safe local plugin name.`); + } + if (host === 'cursor') return Object.freeze({ bundleRoot, name, version }); + const marketplaceKind = `${host} marketplace`; + const marketplace = await readRecord(join(bundleRoot, marketplacePath(host)), marketplaceKind); + return Object.freeze({ + bundleRoot, + marketplace: readString(marketplace, 'name', marketplaceKind), + name, + version, + }); +}; + +const freezeFinding = (finding: DoctorFinding): DoctorFinding => + Object.freeze({ ...finding }); + +const freezeInventory = ( + status: DoctorInventoryStatus, + findings: readonly DoctorFinding[] = [], +): DoctorInventory => Object.freeze({ + findings: Object.freeze(findings.map(freezeFinding)), + status, +}); + +const probeBinary = async ( + host: Exclude, + cwd: string, + run: DoctorCommandRunner, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly probe: DoctorHostProbe }> => { + let result: DoctorCommandResult; + try { + result = await run(Object.freeze({ + args: Object.freeze(['--version']), + cwd, + executable: host, + })); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7300', + `The ${host} CLI is not installed or is not on PATH; host CLI checks were skipped.`, + `Install ${host} and ensure \`${host}\` is on PATH, then rerun \`agent-bundle doctor\`.`, + 'info', + host, + )]), + probe: Object.freeze({ status: 'unavailable' }), + }; + } + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7301', + `The ${host} CLI version probe could not be started.`, + `Verify \`${host} --version\` starts successfully, then rerun \`agent-bundle doctor\`.`, + 'error', + host, + )]), + probe: Object.freeze({ status: 'failed' }), + }; + } + if (result.exitCode !== 0 || result.termination !== undefined) { + const reason = result.termination === 'timed-out' + ? 'timed out' + : result.termination === 'output-limit' + ? 'exceeded its output limit' + : `exited with code ${result.exitCode ?? 'unknown'}`; + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7301', + `The ${host} CLI version probe ${reason}.`, + `Verify \`${host} --version\` completes successfully, then rerun \`agent-bundle doctor\`.`, + 'error', + host, + )]), + probe: Object.freeze({ status: 'failed' }), + }; + } + const version = versionFrom(`${result.stdout}\n${result.stderr}`); + return { + diagnostics: Object.freeze([]), + probe: Object.freeze({ + status: 'available', + ...(version === undefined ? {} : { version }), + }), + }; +}; + +const probeCursor = async ( + home: string, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly probe: DoctorHostProbe }> => { + const cursorRoot = join(home, '.cursor'); + let metadata: Awaited>; + try { + metadata = await lstat(cursorRoot); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7300', + `Cursor is not installed in ${JSON.stringify(cursorRoot)}; Cursor checks were skipped.`, + 'Install Cursor for this home directory, then rerun `agent-bundle doctor`.', + 'info', + 'cursor', + )]), + probe: Object.freeze({ status: 'unavailable' }), + }; + } + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7302', + `Cursor home ${JSON.stringify(cursorRoot)} could not be inspected.`, + 'Repair permissions for the Cursor home directory, then rerun `agent-bundle doctor`.', + 'error', + 'cursor', + )]), + probe: Object.freeze({ status: 'failed' }), + }; + } + if (!metadata.isDirectory()) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7302', + `Cursor home ${JSON.stringify(cursorRoot)} is not a directory.`, + 'Move the conflicting entry and restore the Cursor home directory, then rerun Doctor.', + 'error', + 'cursor', + )]), + probe: Object.freeze({ status: 'failed' }), + }; + } + return { + diagnostics: Object.freeze([]), + probe: Object.freeze({ evidence: 'directory', status: 'available' }), + }; +}; + +const cursorManifestCandidates = Object.freeze([ + '.cursor-plugin/plugin.json', + '.claude-plugin/plugin.json', + 'plugin.json', +]); + +const readInstalledManifest = async ( + root: string, +): Promise<{ readonly manifest: string; readonly name: string; readonly version: string } | undefined> => { + for (const manifest of cursorManifestCandidates) { + try { + const value = JSON.parse(await readFile(join(root, manifest), 'utf8')) as unknown; + if ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { readonly name?: unknown }).name === 'string' && + typeof (value as { readonly version?: unknown }).version === 'string' + ) { + return Object.freeze({ + manifest, + name: (value as { readonly name: string }).name, + version: (value as { readonly version: string }).version, + }); + } + } catch (error) { + if (!isErrno(error, 'ENOENT') && !(error instanceof SyntaxError)) throw error; + } + } + return undefined; +}; + +const cursorInventory = async ( + home: string, + available: boolean, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory }> => { + if (!available) return { diagnostics: Object.freeze([]), inventory: freezeInventory('skipped') }; + const installRoot = join(home, '.cursor', 'plugins', 'local'); + let entries: readonly string[]; + try { + entries = (await readdir(installRoot)).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return { diagnostics: Object.freeze([]), inventory: freezeInventory('known') }; + } + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7304', + `Cursor local plugins at ${JSON.stringify(installRoot)} could not be read.`, + 'Repair permissions for the Cursor local plugin directory or reinstall the affected plugin.', + 'error', + 'cursor', + )]), + inventory: freezeInventory('unknown'), + }; + } + const findings: DoctorFinding[] = []; + const diagnostics: Diagnostic[] = []; + for (const entry of entries) { + const path = join(installRoot, entry); + let metadata: Awaited>; + try { + metadata = await lstat(path); + } catch (error) { + if (isErrno(error, 'ENOENT')) continue; + diagnostics.push(diagnostic( + 'AB7304', + `Cursor plugin entry ${JSON.stringify(path)} could not be inspected.`, + 'Repair permissions or reinstall with `install.mjs` or `agent-bundle install cursor`.', + 'error', + 'cursor', + )); + findings.push({ entry, path, state: 'corrupt' }); + continue; + } + if (metadata.isSymbolicLink()) { + findings.push({ entry, path, state: 'corrupt' }); + diagnostics.push(diagnostic( + 'AB7304', + `Cursor plugin entry ${JSON.stringify(path)} is a symbolic link.`, + 'Reinstall the plugin with `install.mjs` or `agent-bundle install cursor`.', + 'error', + 'cursor', + )); + continue; + } + if (/^\..+\.stage-.+/u.test(entry)) { + findings.push({ entry, path, state: 'interrupted-install' }); + diagnostics.push(diagnostic( + 'AB7305', + `Cursor plugin staging directory ${JSON.stringify(path)} was left by an interrupted install.`, + 'After verifying no installer is running, remove the staged directory manually.', + 'warning', + 'cursor', + )); + continue; + } + if (!metadata.isDirectory()) { + findings.push({ entry, path, state: 'corrupt' }); + diagnostics.push(diagnostic( + 'AB7304', + `Cursor plugin entry ${JSON.stringify(path)} is not a directory.`, + 'Remove the invalid entry and reinstall with `install.mjs` or `agent-bundle install cursor`.', + 'error', + 'cursor', + )); + continue; + } + let manifest; + try { + manifest = await readInstalledManifest(path); + } catch { + manifest = undefined; + } + if (manifest === undefined) { + findings.push({ entry, path, state: 'corrupt' }); + diagnostics.push(diagnostic( + 'AB7304', + `Cursor plugin entry ${JSON.stringify(path)} has no valid loader manifest.`, + 'Reinstall the plugin with `install.mjs` or `agent-bundle install cursor`.', + 'error', + 'cursor', + )); + continue; + } + findings.push({ + entry, + manifest: manifest.manifest, + name: manifest.name, + path, + state: 'installed', + version: manifest.version, + }); + } + return { + diagnostics: freezeDiagnostics(diagnostics), + inventory: freezeInventory('known', findings), + }; +}; + +const unknownInventory = ( + host: Exclude, +): { readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory } => ({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB7303', + `${host} owns its plugin registry and Agent Bundle has no pinned read-only inventory verb.`, + host === 'claude' + ? 'Use `claude plugin details ` to inspect a known plugin.' + : 'Use Codex-owned commands to inspect installed plugins.', + 'info', + host, + )]), + inventory: freezeInventory('unknown'), +}); + +const malformedBundle = ( + host: DoctorHost, + error: unknown, +): { + readonly diagnostics: readonly Diagnostic[]; + readonly finding: DoctorHostReport['bundle']; +} => { + const message = error instanceof Error ? error.message : String(error); + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7306', + message, + `Rebuild the ${host} artifact with valid host and marketplace manifests, then rerun Doctor.`, + 'error', + host, + )]), + finding: Object.freeze({ state: 'failed' }), + }; +}; + +const cursorBundle = async ( + identity: PluginIdentity, + home: string, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { + const destination = join(home, '.cursor', 'plugins', 'local', identity.name); + const base = { + bundleRoot: identity.bundleRoot, + name: identity.name, + path: destination, + version: identity.version, + } as const; + try { + await treeHash(identity.bundleRoot); + if (!await exists(destination)) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7307', + `${identity.name}@${identity.version} is not installed for Cursor.`, + 'Run `agent-bundle install cursor --from ` or the bundle\'s `install.mjs`.', + 'info', + 'cursor', + )]), + finding: Object.freeze({ ...base, state: 'missing' }), + }; + } + const installed = await readInstalledManifest(destination); + if (installed === undefined) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7310', + `Cursor destination ${JSON.stringify(destination)} has no valid loader manifest.`, + 'Remove the corrupt copy manually and reinstall the Cursor plugin.', + 'error', + 'cursor', + )]), + finding: Object.freeze({ ...base, state: 'corrupt' }), + }; + } + if (installed.version !== identity.version) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7309', + `Cursor version collision at ${destination}: found ${installed.version}, expected ${identity.version}.`, + 'Choose the intended version, remove the conflicting copy manually, and reinstall.', + 'warning', + 'cursor', + )]), + finding: Object.freeze({ ...base, state: 'conflicted' }), + }; + } + const [sourceHash, installedHash] = await Promise.all([ + treeHash(identity.bundleRoot), + treeHash(destination), + ]); + if (sourceHash === installedHash) { + return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'installed' }) }; + } + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7308', + `Cursor plugin ${identity.name}@${identity.version} differs from the current bundle.`, + 'Reinstall the Cursor plugin from the current bundle.', + 'warning', + 'cursor', + )]), + finding: Object.freeze({ ...base, state: 'drifted' }), + }; + } catch (error) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7310', + `Cursor bundle comparison failed: ${error instanceof Error ? error.message : String(error)}`, + 'Remove unsafe links or repair unreadable entries, then reinstall the Cursor plugin.', + 'error', + 'cursor', + )]), + finding: Object.freeze({ ...base, state: 'corrupt' }), + }; + } +}; + +const containsPluginName = (value: unknown, name: string): boolean => { + if (Array.isArray(value)) return value.some((entry) => containsPluginName(entry, name)); + if (value === null || typeof value !== 'object') return false; + const record = value as Readonly>; + if (record.name === name) return true; + return Object.values(record).some((entry) => containsPluginName(entry, name)); +}; + +const claudeBundle = async ( + identity: PluginIdentity, + probe: DoctorHostProbe, + run: DoctorCommandRunner, +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] }> => { + const base = { + bundleRoot: identity.bundleRoot, + marketplace: identity.marketplace, + name: identity.name, + version: identity.version, + } as const; + if (probe.status !== 'available') { + return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'skipped' }) }; + } + let result: DoctorCommandResult; + try { + result = await run(Object.freeze({ + args: Object.freeze(['--plugin-dir', identity.bundleRoot, 'plugin', 'list', '--json']), + cwd: identity.bundleRoot, + executable: 'claude', + })); + } catch { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7312', + 'Claude registration proof could not be started.', + `Run \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and repair the reported issue.`, + 'error', + 'claude', + )]), + finding: Object.freeze({ ...base, state: 'failed' }), + }; + } + if (result.exitCode !== 0 || result.termination !== undefined) { + const detail = result.termination ?? result.stderr.trim(); + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7312', + `Claude registration proof failed: ${detail || `exit code ${result.exitCode ?? 'unknown'}`}.`, + `Run \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and repair the reported issue.`, + 'error', + 'claude', + )]), + finding: Object.freeze({ ...base, state: 'failed' }), + }; + } + let inventory: unknown; + try { + inventory = JSON.parse(result.stdout) as unknown; + } catch { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7312', + 'Claude registration proof returned output that is not valid JSON.', + `Inspect \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and repair the host setup.`, + 'error', + 'claude', + )]), + finding: Object.freeze({ ...base, state: 'failed' }), + }; + } + if (!containsPluginName(inventory, identity.name)) { + return { + diagnostics: freezeDiagnostics([diagnostic( + 'AB7311', + `Claude registration proof did not contain plugin ${JSON.stringify(identity.name)}.`, + `Inspect \`claude --plugin-dir ${identity.bundleRoot} plugin list --json\` and register the intended bundle.`, + 'error', + 'claude', + )]), + finding: Object.freeze({ ...base, state: 'unregistered' }), + }; + } + return { diagnostics: Object.freeze([]), finding: Object.freeze({ ...base, state: 'registered' }) }; +}; + +const codexBundle = ( + identity: PluginIdentity, +): { readonly diagnostics: readonly Diagnostic[]; readonly finding: DoctorHostReport['bundle'] } => ({ + diagnostics: freezeDiagnostics([diagnostic( + 'AB7313', + 'Codex bundle registration is unknown because no read-only inventory verb is pinned.', + 'Use Codex-owned commands to inspect registration; stage 1 intentionally does not guess.', + 'info', + 'codex', + )]), + finding: Object.freeze({ + bundleRoot: identity.bundleRoot, + marketplace: identity.marketplace, + name: identity.name, + state: 'unknown', + version: identity.version, + }), +}); + +type EndpointProbe = 'live' | 'missing' | 'stale'; + +const probeEndpoint = (path: string): Promise => new Promise((resolvePromise, reject) => { + const socket = createConnection(path); + const cleanup = (): void => { + socket.removeListener('connect', onConnect); + socket.removeListener('error', onError); + }; + const finish = (state: EndpointProbe): void => { + cleanup(); + socket.destroy(); + resolvePromise(state); + }; + const onConnect = (): void => { finish('live'); }; + const onError = (error: NodeJS.ErrnoException): void => { + if (error.code === 'ENOENT') { + finish('missing'); + return; + } + if (error.code === 'ECONNREFUSED') { + finish('stale'); + return; + } + cleanup(); + socket.destroy(); + reject(error); + }; + socket.once('connect', onConnect); + socket.once('error', onError); +}); + +const scanEndpoints = async ( + directory: string, + platform: NodeJS.Platform, +): Promise => { + if (platform === 'win32') { + const diagnostics = freezeDiagnostics([diagnostic( + 'AB7315', + 'Runtime endpoint scan was skipped because named-pipe enumeration has no pinned contract.', + 'Inspect active Agent Bundle runtime processes with Windows host tools.', + 'info', + )]); + return Object.freeze({ + diagnostics, + directory, + findings: Object.freeze([]), + status: 'skipped', + summary: Object.freeze({ live: 0, staleLocks: 0, staleSockets: 0 }), + }); + } + let entries: readonly string[]; + try { + entries = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); + } catch (error) { + if (isErrno(error, 'ENOENT')) { + return Object.freeze({ + diagnostics: Object.freeze([]), + directory, + findings: Object.freeze([]), + status: 'healthy', + summary: Object.freeze({ live: 0, staleLocks: 0, staleSockets: 0 }), + }); + } + const diagnostics = freezeDiagnostics([diagnostic( + 'AB7315', + `Runtime endpoint directory ${JSON.stringify(directory)} could not be read.`, + 'Repair directory permissions, then rerun `agent-bundle doctor`.', + 'error', + )]); + return Object.freeze({ + diagnostics, + directory, + findings: Object.freeze([]), + status: 'failed', + summary: Object.freeze({ live: 0, staleLocks: 0, staleSockets: 0 }), + }); + } + const findings: DoctorFinding[] = []; + const diagnostics: Diagnostic[] = []; + let live = 0; + let staleLocks = 0; + let staleSockets = 0; + const socketEntries = entries.filter((entry) => /^event-.+\.sock$/u.test(entry)); + for (const entry of socketEntries) { + const path = join(directory, entry); + try { + const state = await probeEndpoint(path); + if (state === 'missing') continue; + if (state === 'live') { + live += 1; + findings.push({ path, state: 'live' }); + continue; + } + staleSockets += 1; + findings.push({ path, state: 'stale-socket' }); + diagnostics.push(diagnostic( + 'AB7314', + `Runtime socket ${JSON.stringify(path)} refuses connections and is stale.`, + 'Remove the stale socket manually or start the runtime; Doctor never removes it.', + 'warning', + )); + } catch (error) { + diagnostics.push(diagnostic( + 'AB7315', + `Runtime socket ${JSON.stringify(path)} could not be probed: ` + + `${error instanceof Error ? error.message : String(error)}`, + 'Inspect the socket and directory permissions, then rerun Doctor.', + 'error', + )); + } + } + for (const entry of entries.filter((candidate) => /^event-.+\.lock$/u.test(candidate))) { + const path = join(directory, entry); + const sibling = path.slice(0, -'.lock'.length); + try { + const siblingState = await probeEndpoint(sibling); + if (siblingState === 'live') { + findings.push({ path, state: 'live' }); + continue; + } + staleLocks += 1; + findings.push({ path, state: 'stale-lock' }); + diagnostics.push(diagnostic( + 'AB7314', + `Runtime claim ${JSON.stringify(path)} has no live socket.`, + 'The runtime uses bounded retries rather than stealing claims; after verifying no runtime is starting, remove the lock manually.', + 'warning', + )); + } catch (error) { + diagnostics.push(diagnostic( + 'AB7315', + `Runtime claim ${JSON.stringify(path)} could not be inspected: ` + + `${error instanceof Error ? error.message : String(error)}`, + 'Inspect the claim and directory permissions, then rerun Doctor.', + 'error', + )); + } + } + const frozenDiagnostics = freezeDiagnostics(diagnostics); + return Object.freeze({ + diagnostics: frozenDiagnostics, + directory, + findings: Object.freeze(findings.map(freezeFinding)), + status: frozenDiagnostics.some((entry) => entry.severity === 'error') + ? 'failed' + : frozenDiagnostics.some((entry) => entry.severity === 'warning') + ? 'warnings' + : 'healthy', + summary: Object.freeze({ live, staleLocks, staleSockets }), + }); +}; + +const doctorHost = async ( + host: DoctorHost, + options: DoctorOptions, + home: string, + run: DoctorCommandRunner, +): Promise => { + const probed = host === 'cursor' + ? await probeCursor(home) + : await probeBinary(host, home, run); + const inventoried = host === 'cursor' + ? await cursorInventory(home, probed.probe.status === 'available') + : unknownInventory(host); + const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics]; + let bundle: DoctorHostReport['bundle']; + if (options.from !== undefined) { + try { + const identity = await readIdentity(options.from, host); + const checked = host === 'cursor' + ? await cursorBundle(identity, home) + : host === 'claude' + ? await claudeBundle(identity, probed.probe, run) + : codexBundle(identity); + diagnostics.push(...checked.diagnostics); + bundle = checked.finding; + } catch (error) { + const malformed = malformedBundle(host, error); + diagnostics.push(...malformed.diagnostics); + bundle = malformed.finding; + } + } + return Object.freeze({ + diagnostics: freezeDiagnostics(diagnostics), + host, + inventory: inventoried.inventory, + probe: probed.probe, + ...(bundle === undefined ? {} : { bundle }), + }); +}; + +export const runDoctor = async (options: DoctorOptions = {}): Promise => { + const home = options.home ?? homedir(); + const run = options.commandRunner ?? defaultCommandRunner; + const hosts = options.hosts ?? Object.freeze(['claude', 'codex', 'cursor'] as const); + const uniqueHosts = [...new Set(hosts)]; + const hostReports: DoctorHostReport[] = []; + for (const host of uniqueHosts) hostReports.push(await doctorHost(host, options, home, run)); + const endpoints = await scanEndpoints( + options.endpointDirectory ?? doctorEndpointDirectory(), + options.platform ?? process.platform, + ); + const diagnostics = freezeDiagnostics([ + ...hostReports.flatMap((report) => report.diagnostics), + ...endpoints.diagnostics, + ]); + return Object.freeze({ + diagnostics, + endpoints, + hosts: Object.freeze(hostReports), + summary: Object.freeze({ + errors: diagnostics.filter((entry) => entry.severity === 'error').length, + infos: diagnostics.filter((entry) => entry.severity === 'info').length, + warnings: diagnostics.filter((entry) => entry.severity === 'warning').length, + }), + }); +}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index fd84ebb45..1053ba754 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -247,7 +247,7 @@ const installPublicCli = async ( }; }; -const treeHash = async (root: string): Promise => { +export const treeHash = async (root: string): Promise => { const rootMetadata = await lstat(root); if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) { throw new Error('Refusing unsupported filesystem entry ".".'); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts new file mode 100644 index 000000000..af342331e --- /dev/null +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -0,0 +1,621 @@ +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:net'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { runCli } from '../src/cli.ts'; +import { eventRuntimeEndpoint } from '../src/events/ipc.ts'; +import { + doctorEndpointDirectory, + runDoctor, + type DoctorCommandRunner, + type DoctorHost, + type DoctorReport, +} from '../src/install/doctor.ts'; + +const writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value)}\n`); +}; + +const errno = (code: string): NodeJS.ErrnoException => { + const error = new Error(code) as NodeJS.ErrnoException; + error.code = code; + return error; +}; + +const commandResult = ( + overrides: Partial>> = {}, +): Awaited> => Object.freeze({ + exitCode: 0, + signal: null, + stderr: '', + stdout: '', + ...overrides, +}); + +const versionRunner: DoctorCommandRunner = async (request) => + commandResult({ stdout: `${request.executable} 1.2.3\n` }); + +const temporaryDoctor = async (): Promise<{ + readonly cleanup: () => Promise; + readonly endpointDirectory: string; + readonly home: string; + readonly root: string; +}> => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-doctor-')); + const home = join(root, 'home'); + const endpointDirectory = join(root, 'endpoints'); + await mkdir(home, { recursive: true }); + return { + cleanup: () => rm(root, { force: true, recursive: true }), + endpointDirectory, + home, + root, + }; +}; + +const createBundle = async ( + root: string, + host: DoctorHost, + version = '1.2.3', +): Promise => { + const bundle = join(root, `bundle-${host}-${version}`); + await mkdir(bundle, { recursive: true }); + await writeFile(join(bundle, 'payload.txt'), 'payload\n'); + if (host === 'claude') { + await Promise.all([ + writeJson(join(bundle, '.claude-plugin/plugin.json'), { name: 'doctor-fixture', version }), + writeJson(join(bundle, '.claude-plugin/marketplace.json'), { + name: 'doctor-fixture-marketplace', + }), + ]); + } else if (host === 'codex') { + await Promise.all([ + writeJson(join(bundle, '.codex-plugin/plugin.json'), { name: 'doctor-fixture', version }), + writeJson(join(bundle, '.agents/plugins/marketplace.json'), { + name: 'doctor-fixture-marketplace', + }), + ]); + } else { + await writeJson(join(bundle, '.cursor-plugin/plugin.json'), { name: 'doctor-fixture', version }); + } + return bundle; +}; + +const hostReport = (report: DoctorReport, host: DoctorHost) => { + const found = report.hosts.find((entry) => entry.host === host); + if (found === undefined) throw new Error(`Missing ${host} report.`); + return found; +}; + +it.each(['claude', 'codex'] as const)( + 'reports %s version probes as available, unavailable, or failed', + async (host) => { + const fixture = await temporaryDoctor(); + try { + const available = await runDoctor({ + commandRunner: versionRunner, + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [host], + }); + expect(hostReport(available, host).probe).toMatchObject({ + status: 'available', + version: '1.2.3', + }); + + const unavailable = await runDoctor({ + commandRunner: async () => { throw errno('ENOENT'); }, + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [host], + }); + expect(hostReport(unavailable, host).probe.status).toBe('unavailable'); + expect(unavailable.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7300', severity: 'info' }), + ])); + + const failed = await runDoctor({ + commandRunner: async () => commandResult({ termination: 'timed-out' }), + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [host], + }); + expect(hostReport(failed, host).probe.status).toBe('failed'); + expect(failed.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7301', severity: 'error' }), + ])); + } finally { + await fixture.cleanup(); + } + }, +); + +it('reports Cursor directory evidence as available, unavailable, or failed', async () => { + const fixture = await temporaryDoctor(); + try { + await mkdir(join(fixture.home, '.cursor')); + const available = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(available, 'cursor').probe).toMatchObject({ + evidence: 'directory', + status: 'available', + }); + + await rm(join(fixture.home, '.cursor'), { recursive: true }); + const unavailable = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(unavailable, 'cursor').probe.status).toBe('unavailable'); + + await writeFile(join(fixture.home, '.cursor'), 'not a directory'); + const failed = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(failed, 'cursor').probe.status).toBe('failed'); + expect(failed.diagnostics).toMatchObject([{ code: 'AB7302', severity: 'error' }]); + } finally { + await fixture.cleanup(); + } +}); + +it('inventories all pinned Cursor manifest candidates in loader order', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + try { + for (const [index, manifest] of [ + '.cursor-plugin/plugin.json', + '.claude-plugin/plugin.json', + 'plugin.json', + ].entries()) { + await writeJson(join(installRoot, `fixture-${index}`, manifest), { + name: `fixture-${index}`, + version: `${index}.0.0`, + }); + } + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').inventory).toMatchObject({ + findings: [ + { manifest: '.cursor-plugin/plugin.json', state: 'installed' }, + { manifest: '.claude-plugin/plugin.json', state: 'installed' }, + { manifest: 'plugin.json', state: 'installed' }, + ], + status: 'known', + }); + } finally { + await fixture.cleanup(); + } +}); + +it('reports corrupt, symlinked, and interrupted Cursor inventory entries', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + try { + await mkdir(join(installRoot, 'no-manifest'), { recursive: true }); + await mkdir(join(installRoot, 'bad-json'), { recursive: true }); + await writeFile(join(installRoot, 'bad-json', 'plugin.json'), '{'); + await symlink(join(installRoot, 'no-manifest'), join(installRoot, 'linked')); + await mkdir(join(installRoot, '.fixture.stage-dead', 'bundle'), { recursive: true }); + + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const findings = hostReport(report, 'cursor').inventory.findings; + expect(findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ entry: 'no-manifest', state: 'corrupt' }), + expect.objectContaining({ entry: 'bad-json', state: 'corrupt' }), + expect.objectContaining({ entry: 'linked', state: 'corrupt' }), + expect.objectContaining({ entry: '.fixture.stage-dead', state: 'interrupted-install' }), + ])); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7304', severity: 'error' }), + expect.objectContaining({ code: 'AB7305', severity: 'warning' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('reports unreadable Cursor local plugin directory as unknown inventory', async () => { + const fixture = await temporaryDoctor(); + const installRoot = join(fixture.home, '.cursor', 'plugins', 'local'); + try { + await mkdir(dirname(installRoot), { recursive: true }); + await writeFile(installRoot, 'not a directory'); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').inventory.status).toBe('unknown'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7304', severity: 'error' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('reports host-owned Claude and Codex inventories as honestly unknown', async () => { + const fixture = await temporaryDoctor(); + try { + const report = await runDoctor({ + commandRunner: versionRunner, + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['claude', 'codex'], + }); + expect(report.hosts.map((entry) => entry.inventory.status)).toEqual(['unknown', 'unknown']); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7303')).toHaveLength(2); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a malformed host bundle as a Doctor error', async () => { + const fixture = await temporaryDoctor(); + try { + const report = await runDoctor({ + commandRunner: versionRunner, + endpointDirectory: fixture.endpointDirectory, + from: join(fixture.root, 'missing-bundle'), + home: fixture.home, + hosts: ['claude'], + }); + expect(hostReport(report, 'claude').bundle?.state).toBe('failed'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7306', severity: 'error' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('classifies Cursor bundle state as installed, missing, drifted, or conflicted', async () => { + const cases = [ + { expected: 'installed', mutate: async (_destination: string): Promise => {} }, + { + expected: 'missing', + mutate: async (destination: string): Promise => rm(destination, { recursive: true }), + }, + { + expected: 'drifted', + mutate: async (destination: string): Promise => + writeFile(join(destination, 'payload.txt'), 'drift\n'), + }, + { + expected: 'conflicted', + mutate: async (destination: string): Promise => + writeJson(join(destination, '.cursor-plugin/plugin.json'), { + name: 'doctor-fixture', + version: '9.0.0', + }), + }, + ] as const; + for (const testCase of cases) { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); + await mkdir(dirname(destination), { recursive: true }); + await cp(bundle, destination, { recursive: true }); + await testCase.mutate(destination); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').bundle?.state).toBe(testCase.expected); + } finally { + await fixture.cleanup(); + } + } +}); + +it('turns a symlink inside a Cursor bundle into a corrupt finding', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + await mkdir(join(fixture.home, '.cursor'), { recursive: true }); + await symlink('/tmp', join(bundle, 'unsafe')); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').bundle?.state).toBe('corrupt'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7310', severity: 'error' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a Cursor destination without a valid manifest as corrupt', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'cursor'); + const destination = join(fixture.home, '.cursor', 'plugins', 'local', 'doctor-fixture'); + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, 'payload.txt'), 'payload\n'); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['cursor'], + }); + expect(hostReport(report, 'cursor').bundle?.state).toBe('corrupt'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7310', severity: 'error' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it.each([ + { + expectedCode: undefined, + expectedState: 'registered', + registration: commandResult({ stdout: JSON.stringify([{ name: 'doctor-fixture' }]) }), + }, + { + expectedCode: 'AB7311', + expectedState: 'unregistered', + registration: commandResult({ stdout: JSON.stringify([{ name: 'other' }]) }), + }, + { + expectedCode: 'AB7312', + expectedState: 'failed', + registration: commandResult({ stdout: 'not json' }), + }, +] as const)( + 'reports Claude registration proof as $expectedState', + async ({ expectedCode, expectedState, registration }) => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'claude'); + const runner: DoctorCommandRunner = async (request) => + request.args[0] === '--version' + ? commandResult({ stdout: 'claude 2.1.250' }) + : registration; + const report = await runDoctor({ + commandRunner: runner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['claude'], + }); + expect(hostReport(report, 'claude').bundle?.state).toBe(expectedState); + if (expectedCode !== undefined) { + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: expectedCode, severity: 'error' }), + ])); + } + } finally { + await fixture.cleanup(); + } + }, +); + +it('skips Claude registration when its binary is unavailable', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'claude'); + const report = await runDoctor({ + commandRunner: async () => { throw errno('ENOENT'); }, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['claude'], + }); + expect(hostReport(report, 'claude').bundle?.state).toBe('skipped'); + } finally { + await fixture.cleanup(); + } +}); + +it('reports Codex bundle registration as unknown', async () => { + const fixture = await temporaryDoctor(); + try { + const bundle = await createBundle(fixture.root, 'codex'); + const report = await runDoctor({ + commandRunner: versionRunner, + endpointDirectory: fixture.endpointDirectory, + from: bundle, + home: fixture.home, + hosts: ['codex'], + }); + expect(hostReport(report, 'codex').bundle?.state).toBe('unknown'); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB7313', severity: 'info' }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +const listen = async (path: string): Promise => { + await mkdir(dirname(path), { recursive: true }); + const server = createServer(); + await new Promise((resolvePromise, reject) => { + server.once('error', reject); + server.listen(path, resolvePromise); + }); + return server; +}; + +const close = (server: Server): Promise => new Promise((resolvePromise, reject) => { + server.close((error) => { + if (error === undefined) resolvePromise(); + else reject(error); + }); +}); + +it('scans live sockets and a lock with a live sibling without warnings', async () => { + const fixture = await temporaryDoctor(); + const endpoint = join(fixture.endpointDirectory, 'event-live.sock'); + const server = await listen(endpoint); + try { + await writeFile(`${endpoint}.lock`, ''); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: endpoint, state: 'live' }), + expect.objectContaining({ path: `${endpoint}.lock`, state: 'live' }), + ])); + expect(report.endpoints.summary).toMatchObject({ live: 1, staleLocks: 0, staleSockets: 0 }); + } finally { + await close(server); + await fixture.cleanup(); + } +}); + +it('reports stale sockets and stale locks as warnings', async () => { + const fixture = await temporaryDoctor(); + const staleSocket = join(fixture.endpointDirectory, 'event-stale.sock'); + const staleLock = join(fixture.endpointDirectory, 'event-claimed.sock.lock'); + const staleServer = await listen(staleSocket); + await close(staleServer); + await writeFile(staleSocket, ''); + await writeFile(staleLock, ''); + try { + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: staleSocket, state: 'stale-socket' }), + expect.objectContaining({ path: staleLock, state: 'stale-lock' }), + ])); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7314')).toHaveLength(2); + expect(report.diagnostics.filter((entry) => entry.code === 'AB7314')[0]?.severity).toBe('warning'); + } finally { + await fixture.cleanup(); + } +}); + +it('treats an absent endpoint directory as a healthy empty scan', async () => { + const fixture = await temporaryDoctor(); + try { + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints).toMatchObject({ + findings: [], + status: 'healthy', + summary: { live: 0, staleLocks: 0, staleSockets: 0 }, + }); + } finally { + await fixture.cleanup(); + } +}); + +it('derives the same default endpoint directory as eventRuntimeEndpoint', () => { + expect(doctorEndpointDirectory()).toBe(dirname(eventRuntimeEndpoint('probe'))); +}); + +const cliReport = ( + diagnostics: DoctorReport['diagnostics'] = [], + hosts: DoctorReport['hosts'] = [], +): DoctorReport => Object.freeze({ + diagnostics, + endpoints: Object.freeze({ + diagnostics: Object.freeze([]), + directory: '/tmp/endpoints', + findings: Object.freeze([]), + status: 'healthy', + summary: Object.freeze({ live: 0, staleLocks: 0, staleSockets: 0 }), + }), + hosts, + summary: Object.freeze({ + errors: diagnostics.filter((entry) => entry.severity === 'error').length, + infos: diagnostics.filter((entry) => entry.severity === 'info').length, + warnings: diagnostics.filter((entry) => entry.severity === 'warning').length, + }), +}); + +it('prints human Doctor output and exits zero for warnings', async () => { + const stdout: string[] = []; + const report = cliReport([{ + code: 'AB7314', + message: 'Stale endpoint.', + recovery: 'Remove it manually.', + severity: 'warning', + }], [{ + diagnostics: Object.freeze([]), + host: 'cursor', + inventory: Object.freeze({ findings: Object.freeze([]), status: 'known' }), + probe: Object.freeze({ evidence: 'directory', status: 'available' }), + }]); + const code = await runCli( + ['doctor'], + { stdout: { write: (chunk: string) => stdout.push(chunk) } }, + { runDoctor: async () => report }, + ); + expect(code).toBe(0); + expect(stdout.join('')).toContain('cursor: available (directory)'); + expect(stdout.join('')).toContain('AB7314: Stale endpoint.'); + expect(stdout.join('')).toContain('Recovery: Remove it manually.'); + expect(stdout.join('')).toContain('Doctor summary: 0 error(s), 1 warning(s), 0 info(s)'); +}); + +it('prints one stable JSON report, forwards filters, and gates only errors', async () => { + const stdout: string[] = []; + const calls: unknown[] = []; + const report = cliReport([{ + code: 'AB7301', + message: 'Probe failed.', + recovery: 'Repair the host CLI.', + severity: 'error', + }]); + const code = await runCli( + ['doctor', '--host', 'claude', '--host', 'cursor', '--from', '/bundle', '--json'], + { stdout: { write: (chunk: string) => stdout.push(chunk) } }, + { + runDoctor: async (options) => { + calls.push(options); + return report; + }, + }, + ); + expect(code).toBe(1); + expect(calls).toEqual([{ from: '/bundle', hosts: ['claude', 'cursor'] }]); + expect(JSON.parse(stdout.join(''))).toEqual(report); + expect(stdout.join('').trim()).toBe(JSON.stringify(JSON.parse(stdout.join('')))); +}); + +it('rejects an invalid Doctor host as a usage error', async () => { + const stderr: string[] = []; + const code = await runCli( + ['doctor', '--host', 'portable'], + { stderr: { write: (chunk: string) => stderr.push(chunk) } }, + ); + expect(code).toBe(2); + expect(stderr.join('')).toContain('Doctor host must be claude, codex, or cursor.'); +}); From b5551722e260aba29f97f9ec6e15946a98a1952a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 23:28:13 +0000 Subject: [PATCH 2/2] fix(doctor): report claim-owner liveness per the #229 reclaim semantics --- packages/agent-bundle/src/install/doctor.ts | 100 +++++++++++++++++- packages/agent-bundle/tests/doctor.test.ts | 111 +++++++++++++++++++- 2 files changed, 204 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 9d6643561..a8f0e6e13 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -718,6 +718,67 @@ const codexBundle = ( type EndpointProbe = 'live' | 'missing' | 'stale'; +interface EndpointClaimOwner { + readonly linuxStartTime?: string; + readonly pid: number; +} + +// Keep claim-owner validation paired with events/ipc.ts endpointClaimOwnerSchema. +const parseEndpointClaimOwner = (raw: string): EndpointClaimOwner | undefined => { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; + const record = parsed as Record; + for (const key of Object.keys(record)) { + if (key !== 'pid' && key !== 'linuxStartTime') return undefined; + } + if (typeof record.pid !== 'number' || !Number.isInteger(record.pid) || record.pid <= 0) { + return undefined; + } + if (record.linuxStartTime !== undefined) { + if (typeof record.linuxStartTime !== 'string' || !/^\d+$/u.test(record.linuxStartTime)) { + return undefined; + } + } + return { + ...(record.linuxStartTime !== undefined ? { linuxStartTime: record.linuxStartTime } : {}), + pid: record.pid, + }; +}; + +const linuxProcessStartTime = async (pid: number): Promise => { + const processStat = await readFile(`/proc/${pid}/stat`, 'utf8'); + const commEnd = processStat.lastIndexOf(')'); + if (commEnd === -1) throw new Error(`Unable to parse process stat for pid ${pid}.`); + const fieldsAfterComm = processStat.slice(commEnd + 1).trim().split(/\s+/u); + const startTime = fieldsAfterComm[19]; + if (startTime === undefined) throw new Error(`Process stat for pid ${pid} has no start time.`); + return startTime; +}; + +const isEndpointClaimOwnerProvablyDead = async ( + owner: EndpointClaimOwner, + platform: NodeJS.Platform, +): Promise => { + try { + process.kill(owner.pid, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH') return true; + if (code !== 'EPERM') return false; + } + if (platform !== 'linux' || owner.linuxStartTime === undefined) return false; + try { + return await linuxProcessStartTime(owner.pid) !== owner.linuxStartTime; + } catch { + return false; + } +}; + const probeEndpoint = (path: string): Promise => new Promise((resolvePromise, reject) => { const socket = createConnection(path); const cleanup = (): void => { @@ -836,13 +897,42 @@ const scanEndpoints = async ( findings.push({ path, state: 'live' }); continue; } - staleLocks += 1; - findings.push({ path, state: 'stale-lock' }); + let rawOwner: string; + try { + rawOwner = await readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT')) continue; + throw error; + } + const owner = parseEndpointClaimOwner(rawOwner); + if (owner === undefined) { + staleLocks += 1; + findings.push({ path, state: 'stale-lock' }); + diagnostics.push(diagnostic( + 'AB7314', + `Runtime claim ${JSON.stringify(path)} has no valid owner record, so the runtime cannot verify it and fails closed.`, + 'After verifying no runtime is starting, remove the lock manually.', + 'warning', + )); + continue; + } + if (await isEndpointClaimOwnerProvablyDead(owner, platform)) { + staleLocks += 1; + findings.push({ path, state: 'stale-lock' }); + diagnostics.push(diagnostic( + 'AB7314', + `Runtime claim ${JSON.stringify(path)} is orphaned because owner pid ${owner.pid} is provably dead.`, + 'The runtime reclaims provably-dead claims automatically at the next start, or remove the lock manually.', + 'warning', + )); + continue; + } + findings.push({ path, state: 'live' }); diagnostics.push(diagnostic( 'AB7314', - `Runtime claim ${JSON.stringify(path)} has no live socket.`, - 'The runtime uses bounded retries rather than stealing claims; after verifying no runtime is starting, remove the lock manually.', - 'warning', + `Runtime claim ${JSON.stringify(path)} is held by pid ${owner.pid}, which cannot be proven dead, so the runtime fails closed rather than stealing it.`, + 'If runtimes hang at startup, verify the owning process and remove the lock manually only once it is gone.', + 'info', )); } catch (error) { diagnostics.push(diagnostic( diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index af342331e..287020932 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -471,6 +471,17 @@ const close = (server: Server): Promise => new Promise((resolvePromise, re }); }); +const findDeadPid = (): number => { + for (let pid = 4_194_000; pid > 0; pid -= 1) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return pid; + } + } + throw new Error('No dead pid found.'); +}; + it('scans live sockets and a lock with a live sibling without warnings', async () => { const fixture = await temporaryDoctor(); const endpoint = join(fixture.endpointDirectory, 'event-live.sock'); @@ -511,8 +522,104 @@ it('reports stale sockets and stale locks as warnings', async () => { expect.objectContaining({ path: staleSocket, state: 'stale-socket' }), expect.objectContaining({ path: staleLock, state: 'stale-lock' }), ])); - expect(report.diagnostics.filter((entry) => entry.code === 'AB7314')).toHaveLength(2); - expect(report.diagnostics.filter((entry) => entry.code === 'AB7314')[0]?.severity).toBe('warning'); + expect(report.endpoints.summary.staleLocks).toBe(1); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7314', + message: expect.stringMatching(/no valid owner record/u), + severity: 'warning', + }), + expect.objectContaining({ + code: 'AB7314', + message: expect.stringMatching(/refuses connections and is stale/u), + severity: 'warning', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a lock with a provably dead owner as a stale lock warning', async () => { + const fixture = await temporaryDoctor(); + const staleLock = join(fixture.endpointDirectory, 'event-dead-claim.sock.lock'); + const deadPid = findDeadPid(); + try { + await mkdir(fixture.endpointDirectory, { recursive: true }); + await writeFile(staleLock, `${JSON.stringify({ pid: deadPid })}\n`); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: staleLock, state: 'stale-lock' }), + ])); + expect(report.endpoints.summary.staleLocks).toBe(1); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7314', + message: expect.stringMatching(new RegExp(`owner pid ${deadPid} is provably dead`, 'u')), + severity: 'warning', + }), + ])); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a lock held by a live owner as live with an info diagnostic', async () => { + const fixture = await temporaryDoctor(); + const liveLock = join(fixture.endpointDirectory, 'event-live-claim.sock.lock'); + try { + await mkdir(fixture.endpointDirectory, { recursive: true }); + await writeFile(liveLock, `${JSON.stringify({ pid: process.pid })}\n`); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: liveLock, state: 'live' }), + ])); + expect(report.endpoints.summary).toMatchObject({ staleLocks: 0 }); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7314', + message: expect.stringMatching(new RegExp(`held by pid ${process.pid}`, 'u')), + severity: 'info', + }), + ])); + expect(report.diagnostics.some((entry) => entry.severity === 'warning')).toBe(false); + expect(report.diagnostics.some((entry) => entry.severity === 'error')).toBe(false); + expect(report.summary).toMatchObject({ errors: 0, warnings: 0, infos: 1 }); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a lock with an invalid owner shape as a stale lock warning', async () => { + const fixture = await temporaryDoctor(); + const invalidLock = join(fixture.endpointDirectory, 'event-invalid-claim.sock.lock'); + try { + await mkdir(fixture.endpointDirectory, { recursive: true }); + await writeFile(invalidLock, `${JSON.stringify({ pid: -1 })}\n`); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: [], + }); + expect(report.endpoints.findings).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: invalidLock, state: 'stale-lock' }), + ])); + expect(report.endpoints.summary.staleLocks).toBe(1); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7314', + message: expect.stringMatching(/no valid owner record/u), + severity: 'warning', + }), + ])); } finally { await fixture.cleanup(); }