From 6b8bb0c57ee6a8baa27d4f0a56843357a203fe08 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 17:35:24 +0000 Subject: [PATCH] feat(test): add installed-host contract matrix Prove emitted host layouts through live stdio sessions and fail closed when source, built, installed, or running versions drift. --- .changeset/contract-matrix-stage3.md | 5 + packages/agent-bundle/README.md | 51 +- packages/agent-bundle/src/test/contract.ts | 94 +++- packages/agent-bundle/src/test/index.ts | 19 +- packages/agent-bundle/src/test/installed.ts | 505 ++++++++++++++++++ .../host-install/agent-bundle.config.ts | 3 + .../fixtures/host-install/src/mcp/probe.ts | 19 +- .../tests/host-install-proof.test.ts | 153 ++++++ .../tests/support/host-install.ts | 129 ++++- 9 files changed, 952 insertions(+), 26 deletions(-) create mode 100644 .changeset/contract-matrix-stage3.md create mode 100644 packages/agent-bundle/src/test/installed.ts diff --git a/.changeset/contract-matrix-stage3.md b/.changeset/contract-matrix-stage3.md new file mode 100644 index 000000000..306b5fd7c --- /dev/null +++ b/.changeset/contract-matrix-stage3.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add the stage-3 installed-host contract matrix boundary (#218). `openInstalledHostMcpServer` verifies and discovers a clean installed host layout, spawns its emitted MCP command over stdio, and observes the live initialize identity. `runInstalledHostContractMatrix` reuses the shared matrix at `host-install` proof level and reports a fail-closed source, built-artifact, installed-artifact, and running-process version quadruple with host binary, adapter, manifest/schema, and framework metadata. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 152bfcf54..38beb0f46 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -287,7 +287,7 @@ is never a receipt for another. | `cli-dispatch` | `invokeCli`, `cliJson`, `cliNdjson` | a plain or rendered argv vector resolved and run through the routed CLI's own shell, including rendered Markdown, explicit TTY, JSON, and NDJSON modes, in-process | | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | a built artifact's generated entry running as a real process over stdio | | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | the packed stdio process still runs after project source and configuration are removed and verified absent | -| `host-install` | repository real-host install proof | a built bundle installed into an isolated real host home through the public install path, with registration observed through the host's own CLI | +| `host-install` | `openInstalledHostMcpServer`, `runInstalledHostContractMatrix` | a built bundle staged into an isolated host root, discovered in the emitted host format, and spawned from the installed layout | ```ts import { cliJson, cliNdjson, expectEvents, invokeCli, invokeMcpTool } from 'agent-bundle/test'; @@ -322,14 +322,15 @@ once, build once, remove and verify source once, spawn once, and iterate every per-route assertion inside that one session. The deleted-source journey also reads the embedded MCP App resource from the generated server; it does not prove native-host install or dispatch, or an install mode that copies the -artifact elsewhere. `host-install` is separate real-host process evidence for -built-bundle acceptance and registration, not packed provenance or session -behavior. +artifact elsewhere. `host-install` is separate installed-layout process +evidence: its deterministic adapter-simulator lane is unconditional, available +Claude and Codex binaries also prove their public install paths, and Cursor +records its unavailable non-interactive host-session surface explicitly. -### Contract matrix (`runContractMatrix` / `runPackedContractMatrix`) +### Contract matrix (`runContractMatrix` / `runPackedContractMatrix` / `runInstalledHostContractMatrix`) The contract matrix is the framework-owned generated-plugin wire-contract suite. -Two entry points share one implementation; boundary differences are explicit +Three entry points share one implementation; boundary differences are explicit capability flags, not forked check logic. The project supplies only fixtures — valid inputs, a declared `resultCompat` policy for every in-memory tool route, optional `previousResults` payloads, optional `cancellation` cases, and an @@ -372,8 +373,22 @@ without one the check is honestly `not-applicable`. Packed callers should wire that callback into the existing packed journey's restart rather than creating a second pack/build/install path. -**Neither boundary proves:** host install, browser App HTML, artifact-rebuild -replay, or state-lifetime catalog identity. +**`runInstalledHostContractMatrix` (`host-install`)** runs against an +already-open session from `openInstalledHostMcpServer`. The opener reads the +host's emitted MCP document from the installed root, verifies the manifest, +component/resource/hook paths and artifact file digests, spawns that installed +command, and observes the running version from the live MCP `initialize` +result. Its report records source, built-artifact, installed-artifact, and +running-process versions separately and fails closed when any value is missing +or differs. Metadata records the host binary version when observed, adapter +revision, manifest/schema digest, and framework version. Module-backed checks +remain honestly not-applicable because loading project modules would cross back +into the source/build tree. + +No matrix boundary proves browser App HTML, artifact-rebuild replay, +state-lifetime catalog identity, or running-process identity beyond what the +live MCP session reports; deeper runtime-instance introspection depends on +#269. When the advertised input schema declares `additionalProperties: false`, plain `z.object` tool routes may still strip unknown keys without a protocol failure. @@ -381,7 +396,12 @@ The negative-inputs check records that tolerance when other generated negatives still prove rejection paths. ```ts -import { runContractMatrix, runPackedContractMatrix } from 'agent-bundle/test'; +import { + openInstalledHostMcpServer, + runContractMatrix, + runInstalledHostContractMatrix, + runPackedContractMatrix, +} from 'agent-bundle/test'; await runContractMatrix({ fixtures: { @@ -399,6 +419,19 @@ await runPackedContractMatrix({ manifest: compiledManifest, fixtures: { /* same shape */ }, }); + +await using installedSession = await openInstalledHostMcpServer({ + artifactRoot, + host: 'claude', + installedRoot, + manifest: compiledManifest, + server: 'library', +}); +await runInstalledHostContractMatrix({ + fixtures: { /* same shape */ }, + manifest: compiledManifest, + session: installedSession, +}); ``` A failing matrix throws one aggregated `AgentTestError` with code diff --git a/packages/agent-bundle/src/test/contract.ts b/packages/agent-bundle/src/test/contract.ts index f6e52ca26..e0b6407d2 100644 --- a/packages/agent-bundle/src/test/contract.ts +++ b/packages/agent-bundle/src/test/contract.ts @@ -1,8 +1,8 @@ /** * The generated-plugin contract matrix — framework-owned wire-contract checks - * at two proof boundaries today (`mcp-in-memory` and packed stdio). + * at three proof boundaries (`mcp-in-memory`, packed stdio, and host install). * - * Both entry points share one implementation. Boundary differences are explicit + * All three entry points share one implementation. Boundary differences are explicit * capability flags, not forked check logic. The project supplies only fixtures * — valid inputs, declared result-compat policy, version-skew payloads, * optional cancellation cases, and deterministic lifecycle transitions — @@ -27,10 +27,16 @@ * every tool result through its bundled `resultSchema` before returning; a * successful sweep invocation is that evidence. * - * Stateful lifecycle fixtures replay over one open client at both boundaries. + * Stateful lifecycle fixtures replay over one open client at every boundary. * Same-store restart callbacks add boundary-local durability evidence; a run - * without one reports restart durability as not-applicable. Neither boundary - * proves host install, browser App HTML, or state-lifetime catalog identity. + * without one reports restart durability as not-applicable. + * + * **The installed-host boundary** discovers and spawns the emitted MCP command + * from a clean installed layout. It carries static layout checks and the + * source/artifact/installed/running version quadruple from `installed.ts`. + * + * No boundary here proves browser App HTML, state-lifetime catalog identity, + * or runtime-instance identity beyond the live MCP initialize result (#269). */ import type { Client } from '@modelcontextprotocol/client'; @@ -46,6 +52,13 @@ import { type InMemoryMcpSessionOptions, type McpProjectionProvenance, } from './mcp.ts'; +import type { + InstalledHostCheckOutcome, + InstalledHostEvidenceMetadata, + InstalledHostMcpProvenance, + InstalledHostMcpSession, + InstalledHostVersionQuadruple, +} from './installed.ts'; import type { PackedMcpProvenance, PackedMcpSession } from './packed.ts'; import { registeredRouteLoader, testManifest } from './registry.ts'; import type { AgentRouteModule, TestableRouteDescriptor } from './types.ts'; @@ -145,7 +158,10 @@ export interface ContractRouteReport { readonly checks: Readonly>; } -export type ContractMatrixProvenance = McpProjectionProvenance | PackedMcpProvenance; +export type ContractMatrixProvenance = + | InstalledHostMcpProvenance + | McpProjectionProvenance + | PackedMcpProvenance; export interface ContractMatrixReport { readonly provenance: ContractMatrixProvenance; @@ -162,22 +178,46 @@ export interface PackedContractMatrixOptions { readonly restart?: () => Promise; } +export interface InstalledHostContractMatrixOptions { + readonly fixtures: Readonly>; + readonly manifest: AgentBundleTestManifest; + readonly server?: string; + /** An already-open installed-host session; this entry point never opens or closes it. */ + readonly session: InstalledHostMcpSession; +} + +export interface InstalledHostContractMatrixReport { + readonly checks: Readonly>; + readonly host: InstalledHostMcpSession['provenance']['host']; + readonly matrix: ContractMatrixReport; + readonly metadata: InstalledHostEvidenceMetadata; + readonly proofLevel: string; + readonly sessionEvidence: string; + readonly status: 'passed'; + readonly versions: InstalledHostVersionQuadruple; +} + interface MatrixBoundaryCapabilities { readonly canLoadRouteModules: boolean; readonly moduleSchemaNotApplicableReason: string; readonly proofLevel: AgentTestProofLevel; readonly registersAppResources: boolean; + readonly recovery: string; readonly restart?: () => Promise; } const PACKED_MODULE_SCHEMA_NOT_APPLICABLE_REASON = 'packed sessions cannot load project route modules (source may be deleted and verified absent); loading a module would silently break deleted-source proof. The packed server validates every tool result through its bundled resultSchema before returning — a successful sweep invocation is that evidence.'; +const INSTALLED_HOST_MODULE_SCHEMA_NOT_APPLICABLE_REASON = + 'installed-host sessions cannot load project route modules without crossing back into the source/build tree; the installed server validates every tool result through its bundled resultSchema before returning — a successful sweep invocation is that evidence.'; + const IN_MEMORY_BOUNDARY: MatrixBoundaryCapabilities = Object.freeze({ canLoadRouteModules: true, moduleSchemaNotApplicableReason: '', proofLevel: MCP_IN_MEMORY_PROOF_LEVEL, registersAppResources: false, + recovery: 'Fix the failing route, fixture, or declared resultCompat policy; re-run runContractMatrix.', }); const packedBoundaryFromSession = ( @@ -189,9 +229,18 @@ const packedBoundaryFromSession = ( moduleSchemaNotApplicableReason: PACKED_MODULE_SCHEMA_NOT_APPLICABLE_REASON, proofLevel: session.provenance.proofLevel, registersAppResources: true, + recovery: 'Fix the failing route or fixture; re-run runPackedContractMatrix.', ...(restart === undefined ? {} : { restart }), }); +const INSTALLED_HOST_BOUNDARY: MatrixBoundaryCapabilities = Object.freeze({ + canLoadRouteModules: false, + moduleSchemaNotApplicableReason: INSTALLED_HOST_MODULE_SCHEMA_NOT_APPLICABLE_REASON, + proofLevel: 'host-install', + registersAppResources: true, + recovery: 'Fix the installed layout, route, or fixture; reinstall and re-run runInstalledHostContractMatrix.', +}); + const COMPAT_PROBE_KEY = '__agentBundleContractProbe'; const CHECK_SURFACE = 'surface-completeness'; @@ -1096,9 +1145,7 @@ const finalizeContractMatrixReport = ( `Contract matrix reported ${String(failures.length)} violation(s) at the ${boundary.proofLevel} proof level.`, { details, - recovery: boundary.canLoadRouteModules - ? 'Fix the failing route, fixture, or declared resultCompat policy; re-run runContractMatrix.' - : 'Fix the failing route or fixture; re-run runPackedContractMatrix.', + recovery: boundary.recovery, }, ); }; @@ -1444,3 +1491,32 @@ export const runPackedContractMatrix = async ( serverName, }); }; + +/** + * Runs the shared contract matrix over an already-open MCP process discovered + * and spawned from a host-owned installed layout. The returned report carries + * the separately observed source/artifact/installed/running version evidence. + */ +export const runInstalledHostContractMatrix = async ( + options: InstalledHostContractMatrixOptions, +): Promise => { + const serverName = resolveServerName(options.manifest, options.server); + const matrix = await executeContractMatrix({ + boundary: INSTALLED_HOST_BOUNDARY, + client: options.session.client, + fixtures: options.fixtures, + manifest: options.manifest, + provenance: options.session.provenance, + serverName, + }); + return Object.freeze({ + checks: options.session.observation.checks, + host: options.session.observation.host, + matrix, + metadata: options.session.observation.metadata, + proofLevel: options.session.observation.proofLevel, + sessionEvidence: options.session.observation.sessionEvidence, + status: 'passed', + versions: options.session.observation.versions, + }); +}; diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index e378abf03..5934f4dc5 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -1,7 +1,7 @@ /** * `agent-bundle/test` — the consumer test harness helpers. * - * Six Node proof levels ship here, and the browser-safe seventh level ships + * Seven Node proof levels ship here, and the browser-safe eighth level ships * from `agent-bundle/test/browser`. The repository's real-host install proof * uses the same level convention. Each helper names the level it supplies, * stamps it into its provenance, and prints it in every failure: @@ -14,7 +14,7 @@ * | `packed-stdio` | `openPackedMcpServer`, `runPackedContractMatrix` | a built artifact's generated entry running as a real process over stdio | * | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })`, `runPackedContractMatrix` | the packed stdio process still runs after project source and configuration are removed and verified absent | * | `browser-app` | `mountBrowserApp` (`agent-bundle/test/browser`) | production-compiled MCP App HTML mounted over the product bridge in a real browser page | - * | `host-install` | repository real-host install proof | a built bundle accepted through a real host's public install path in an isolated home, with registration observed by that host | + * | `host-install` | `openInstalledHostMcpServer`, `runInstalledHostContractMatrix` | a built bundle staged into an isolated host root, discovered in the host's emitted format, and spawned from the installed layout | * * A pass at one level is never a receipt for another. The `deletedSource` * option upgrades `openPackedMcpServer` provenance only after every path in a @@ -84,6 +84,7 @@ export { export { negativeInputsFromJsonSchema, runContractMatrix, + runInstalledHostContractMatrix, runPackedContractMatrix, } from './contract.ts'; export type { @@ -98,6 +99,8 @@ export type { ContractMatrixRestartSession, ContractRouteFixture, ContractRouteReport, + InstalledHostContractMatrixOptions, + InstalledHostContractMatrixReport, PackedContractMatrixOptions, ResultCompatPolicy, } from './contract.ts'; @@ -121,6 +124,18 @@ export type { PackedMcpSession, PackedMcpSessionOptions, } from './packed.ts'; +export { openInstalledHostMcpServer } from './installed.ts'; +export type { + InstalledHostBinaryVersion, + InstalledHostCheckName, + InstalledHostCheckOutcome, + InstalledHostEvidenceMetadata, + InstalledHostMcpProvenance, + InstalledHostMcpSession, + InstalledHostObservation, + InstalledHostVersionQuadruple, + OpenInstalledHostMcpServerOptions, +} from './installed.ts'; export type { AgentRouteModule, AgentRouteModuleLoader, diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts new file mode 100644 index 000000000..be9c4d17c --- /dev/null +++ b/packages/agent-bundle/src/test/installed.ts @@ -0,0 +1,505 @@ +import { lstat, readFile } from 'node:fs/promises'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; + +import { Client } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; + +import { artifactManifestName } from '../build/emit.ts'; +import { parseArtifactHookIndex } from '../build/hook-index.ts'; +import { parseArtifactManifest } from '../build/manifest.ts'; +import { digest, sha256Hex } from '../core/digest.ts'; +import { resolveBundleRoot } from '../install/doctor.ts'; +import type { InstallHost } from '../install/install.ts'; +import { AgentTestError } from './errors.ts'; +import { + HOST_INSTALL_PROOF_LEVEL, + proofLevelLabel, + type AgentBundleTestManifest, +} from './manifest.ts'; + +export type InstalledHostCheckName = + | 'component-paths' + | 'hook-commands' + | 'manifest-schema' + | 'mcp-command' + | 'resources' + | 'version-digests' + | 'version-quadruple'; + +export interface InstalledHostCheckOutcome { + readonly reason?: string; + readonly status: 'failed' | 'passed'; +} + +export interface InstalledHostVersionQuadruple { + readonly builtArtifact: string; + readonly installedArtifact: string; + readonly runningProcess: string; + readonly source: string; +} + +export type InstalledHostBinaryVersion = + | { readonly status: 'observed'; readonly value: string } + | { readonly reason: string; readonly status: 'unavailable' }; + +export interface InstalledHostEvidenceMetadata { + readonly adapterRevision: string; + readonly frameworkVersion: string; + readonly hostBinaryVersion: InstalledHostBinaryVersion; + readonly manifestSchemaDigest: string; +} + +export interface InstalledHostObservation { + readonly checks: Readonly>; + readonly host: InstallHost; + readonly metadata: InstalledHostEvidenceMetadata; + readonly proofLevel: string; + readonly sessionEvidence: string; + readonly versions: InstalledHostVersionQuadruple; +} + +export interface InstalledHostMcpProvenance { + /** Installed-root-relative command entry, never an absolute host path. */ + readonly entry: string; + readonly host: InstallHost; + readonly pid: number | undefined; + readonly proofLevel: typeof HOST_INSTALL_PROOF_LEVEL; +} + +export interface InstalledHostMcpSession extends AsyncDisposable { + readonly client: Client; + readonly close: () => Promise; + readonly observation: InstalledHostObservation; + readonly provenance: InstalledHostMcpProvenance; + readonly stderr: () => string; +} + +export interface OpenInstalledHostMcpServerOptions { + /** Root containing `agent-bundle.manifest.json` and target directories. */ + readonly artifactRoot: string; + readonly env?: Readonly>; + readonly host: InstallHost; + /** Version observed from the real host binary, when that lane invoked one. */ + readonly hostBinaryVersion?: string; + /** Host-owned installed plugin root, not the build target directory. */ + readonly installedRoot: string; + readonly manifest: AgentBundleTestManifest; + readonly server?: string; + readonly sessionEvidence?: string; +} + +interface RawMcpServer { + readonly args?: unknown; + readonly command?: unknown; + readonly cwd?: unknown; + readonly env?: unknown; + readonly type?: unknown; +} + +interface Failure { + readonly check: InstalledHostCheckName; + readonly reason: string; +} + +const maxStderrCharacters = 16_000; + +const hostManifestPath = (host: InstallHost): 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 installed host ${String(exhaustive)}.`); + } + } +}; + +const hostMcpPath = (host: InstallHost): string => + host === 'cursor' ? 'mcp.json' : '.mcp.json'; + +const hostHookPath = (_host: InstallHost): string => 'hooks/hooks.json'; + +const record = (value: unknown): Readonly> | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? value as Readonly> + : undefined; + +const requiredString = ( + value: unknown, + check: InstalledHostCheckName, + label: string, + failures: Failure[], +): string => { + if (typeof value === 'string' && value.length > 0) return value; + failures.push({ check, reason: `${label} was not observable` }); + return ''; +}; + +const readJsonRecord = async ( + path: string, + check: InstalledHostCheckName, + label: string, + failures: Failure[], +): Promise>> => { + try { + const value = record(JSON.parse(await readFile(path, 'utf8')) as unknown); + if (value !== undefined) return value; + } catch { + // The single finding below deliberately avoids leaking the absolute path. + } + failures.push({ check, reason: `${label} was not readable canonical JSON` }); + return Object.freeze({}); +}; + +const fileHash = async (path: string): Promise => { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) return undefined; + return sha256Hex(await readFile(path)); + } catch { + return undefined; + } +}; + +const relativePath = (root: string, path: string): string => + relative(root, path).split(sep).join('/'); + +const installedFailure = (failures: readonly Failure[]): AgentTestError => new AgentTestError( + 'contract-violation', + `Installed-host contract matrix reported ${String(failures.length)} violation(s) at the host-install proof level.`, + { + details: failures.map((failure) => + `- installed-host / ${failure.check}: ${failure.reason} (${proofLevelLabel(HOST_INSTALL_PROOF_LEVEL)})`), + recovery: 'Rebuild, reinstall into a clean host root, and rerun runInstalledHostContractMatrix.', + }, +); + +const commandStrings = (value: unknown): readonly string[] => { + if (Array.isArray(value)) return value.flatMap(commandStrings); + const object = record(value); + if (object === undefined) return []; + return Object.entries(object).flatMap(([key, nested]) => + key === 'command' && typeof nested === 'string' ? [nested] : commandStrings(nested)); +}; + +const expandHostPath = (value: string, host: InstallHost, installedRoot: string): string => { + switch (host) { + case 'claude': + return value.replaceAll('${CLAUDE_PLUGIN_ROOT}', installedRoot); + case 'codex': + return value; + case 'cursor': + return value + .replaceAll('${CURSOR_PLUGIN_ROOT}', installedRoot) + .replaceAll('${workspaceFolder}', installedRoot); + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown installed host ${String(exhaustive)}.`); + } + } +}; + +const stringEnvironment = ( + environment: Readonly, +): Record => Object.fromEntries( + Object.entries(environment).filter((entry): entry is [string, string] => entry[1] !== undefined), +); + +const resolvedCommandPath = (value: string, cwd: string): string => + isAbsolute(value) ? value : resolve(cwd, value); + +const discoveredServer = ( + document: Readonly>, + requested: string | undefined, + failures: Failure[], +): { readonly name: string; readonly server: RawMcpServer } => { + const servers = record(document.mcpServers); + const names = Object.keys(servers ?? {}).sort(); + const name = requested ?? (names.length === 1 ? names[0] : undefined); + if (name === undefined || record(servers?.[name]) === undefined) { + failures.push({ + check: 'mcp-command', + reason: requested === undefined + ? `installed MCP document exposed ${String(names.length)} servers; exactly one is required` + : `installed MCP document did not expose server ${JSON.stringify(requested)}`, + }); + return { name: requested ?? '', server: {} }; + } + return { name, server: record(servers?.[name]) as RawMcpServer }; +}; + +const outcomes = (failures: readonly Failure[]): Readonly> => { + const names: readonly InstalledHostCheckName[] = [ + 'component-paths', + 'hook-commands', + 'manifest-schema', + 'mcp-command', + 'resources', + 'version-digests', + 'version-quadruple', + ]; + return Object.freeze(Object.fromEntries(names.map((name) => { + const matching = failures.filter((failure) => failure.check === name); + return [name, matching.length === 0 + ? Object.freeze({ status: 'passed' as const }) + : Object.freeze({ reason: matching.map((failure) => failure.reason).join('; '), status: 'failed' as const })]; + })) as unknown as Readonly>); +}; + +/** + * Discovers an emitted MCP command from a host-owned installed layout, checks + * the artifact/install boundary, then opens a real stdio client session. + * + * The running-process version is read only from the live initialize result. + * Runtime-instance identity beyond initialize remains outside this helper + * until WarmRuntimeIdentity introspection lands in #269. + */ +export const openInstalledHostMcpServer = async ( + options: OpenInstalledHostMcpServerOptions, +): Promise => { + const artifactRoot = resolve(options.artifactRoot); + const installedRoot = resolve(options.installedRoot); + const failures: Failure[] = []; + let artifactBytes = ''; + let artifactManifest: ReturnType | undefined; + try { + artifactBytes = await readFile(join(artifactRoot, artifactManifestName), 'utf8'); + artifactManifest = parseArtifactManifest(artifactBytes); + } catch { + failures.push({ check: 'manifest-schema', reason: 'built artifact manifest was unavailable or invalid' }); + } + const target = artifactManifest?.targets.find((candidate) => candidate.name === options.host); + if (target === undefined) { + failures.push({ check: 'manifest-schema', reason: `artifact manifest did not declare target ${options.host}` }); + } + const builtRoot = await resolveBundleRoot(artifactRoot, options.host).catch(() => { + failures.push({ check: 'manifest-schema', reason: `Doctor could not discover the built ${options.host} bundle root` }); + return join(artifactRoot, options.host); + }); + + const prefix = `${options.host}/`; + const targetFiles = artifactManifest?.files.filter((file) => file.path.startsWith(prefix)) ?? []; + if (targetFiles.length === 0) { + failures.push({ check: 'component-paths', reason: `artifact manifest declared no ${options.host} component files` }); + } + for (const file of targetFiles) { + const targetRelative = file.path.slice(prefix.length); + const [builtHash, installedHash] = await Promise.all([ + fileHash(join(artifactRoot, file.path)), + fileHash(join(installedRoot, targetRelative)), + ]); + if (builtHash !== file.sha256) { + failures.push({ check: 'version-digests', reason: `built file ${targetRelative} disagreed with its artifact digest` }); + } + if (installedHash !== file.sha256) { + failures.push({ check: 'version-digests', reason: `installed file ${targetRelative} disagreed with its artifact digest` }); + } + if (installedHash === undefined) { + failures.push({ check: 'component-paths', reason: `installed component ${targetRelative} was missing` }); + } + } + + const resourceFiles = targetFiles.filter((file) => { + const path = file.path.slice(prefix.length); + return path.startsWith('assets/') || path.startsWith('skills/') || path.startsWith('commands/'); + }); + if (resourceFiles.length === 0) { + failures.push({ check: 'resources', reason: 'artifact manifest declared no installed resources' }); + } + for (const resource of resourceFiles) { + const path = resource.path.slice(prefix.length); + if (await fileHash(join(installedRoot, path)) === undefined) { + failures.push({ check: 'resources', reason: `installed resource ${path} was missing` }); + } + } + + const installedManifest = await readJsonRecord( + join(installedRoot, hostManifestPath(options.host)), + 'manifest-schema', + 'installed host manifest', + failures, + ); + const builtManifest = await readJsonRecord( + join(builtRoot, hostManifestPath(options.host)), + 'manifest-schema', + 'built host manifest', + failures, + ); + const installedVersion = requiredString( + installedManifest.version, + 'version-quadruple', + 'installed artifact version', + failures, + ); + const sourceVersion = requiredString( + options.manifest.plugin.packageVersion ?? options.manifest.plugin.version, + 'version-quadruple', + 'source version', + failures, + ); + const builtVersion = requiredString( + builtManifest.version, + 'version-quadruple', + 'built artifact version', + failures, + ); + + const hookDocument = await readJsonRecord( + join(installedRoot, hostHookPath(options.host)), + 'hook-commands', + 'installed hook document', + failures, + ); + const hooks = commandStrings(hookDocument); + if (hooks.length === 0) { + failures.push({ check: 'hook-commands', reason: 'installed hook document exposed no commands' }); + } + try { + const hookIndex = parseArtifactHookIndex( + await readFile(join(artifactRoot, 'agent-bundle.hooks.json'), 'utf8'), + ); + const installedHooks = hookIndex?.hooks.filter((hook) => hook.target === options.host) ?? []; + if (installedHooks.length === 0) { + failures.push({ check: 'hook-commands', reason: 'artifact hook index exposed no target hook commands' }); + } + for (const hook of installedHooks) { + const path = hook.path.startsWith(prefix) ? hook.path.slice(prefix.length) : hook.path; + if (await fileHash(join(installedRoot, path)) === undefined) { + failures.push({ check: 'hook-commands', reason: `installed hook command target ${path} was missing` }); + } + } + } catch { + failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); + } + + const mcpDocument = await readJsonRecord( + join(installedRoot, hostMcpPath(options.host)), + 'mcp-command', + 'installed MCP document', + failures, + ); + const discovered = discoveredServer(mcpDocument, options.server, failures); + const command = requiredString(discovered.server.command, 'mcp-command', 'installed MCP command', failures); + const rawArgs = Array.isArray(discovered.server.args) + && discovered.server.args.every((argument) => typeof argument === 'string') + ? discovered.server.args as readonly string[] + : []; + if (!Array.isArray(discovered.server.args)) { + failures.push({ check: 'mcp-command', reason: 'installed MCP command exposed no argument vector' }); + } + const rawCwd = typeof discovered.server.cwd === 'string' ? discovered.server.cwd : installedRoot; + const expandedCwd = expandHostPath(rawCwd, options.host, installedRoot); + const cwd = resolvedCommandPath(expandedCwd, installedRoot); + const expandedCommand = expandHostPath(command, options.host, installedRoot); + const args = rawArgs.map((argument) => expandHostPath(argument, options.host, installedRoot)); + const entryArgument = args.find((argument) => /\.mjs$/u.test(argument)); + if (entryArgument === undefined || await fileHash(resolvedCommandPath(entryArgument, cwd)) === undefined) { + failures.push({ check: 'mcp-command', reason: 'installed MCP entry argument did not resolve to an installed file' }); + } + const declaredEnvironment = record(discovered.server.env); + const expandedDeclaredEnvironment = Object.fromEntries( + Object.entries(declaredEnvironment ?? {}) + .filter((entry): entry is [string, string] => typeof entry[1] === 'string') + .map(([key, value]) => [key, expandHostPath(value, options.host, installedRoot)] as const), + ); + const env = { + ...stringEnvironment(process.env), + ...(options.env ?? {}), + ...expandedDeclaredEnvironment, + }; + + if (expandedCommand.length === 0 || discovered.name.length === 0) throw installedFailure(failures); + const client = new Client({ name: 'agent-bundle-installed-host-proof', version: '1.0.0' }); + const transport = new StdioClientTransport({ + args: [...args], + command: expandedCommand, + cwd, + env, + stderr: 'pipe', + }); + let captured = ''; + transport.stderr?.on('data', (chunk: unknown) => { + if (captured.length >= maxStderrCharacters) return; + captured = `${captured}${String(chunk)}`.slice(0, maxStderrCharacters); + }); + try { + await client.connect(transport); + } catch { + failures.push({ + check: 'mcp-command', + reason: `installed MCP command could not initialize${captured === '' ? '' : `: ${captured}`}`, + }); + throw installedFailure(failures); + } + + const runningVersion = requiredString( + client.getServerVersion()?.version, + 'version-quadruple', + 'running process version from initialize serverInfo', + failures, + ); + const versions: InstalledHostVersionQuadruple = Object.freeze({ + builtArtifact: builtVersion, + installedArtifact: installedVersion, + runningProcess: runningVersion, + source: sourceVersion, + }); + if (new Set(Object.values(versions)).size !== 1 || Object.values(versions).some((value) => value.length === 0)) { + failures.push({ + check: 'version-quadruple', + reason: `source=${sourceVersion || 'missing'}, builtArtifact=${builtVersion || 'missing'}, installedArtifact=${installedVersion || 'missing'}, runningProcess=${runningVersion || 'missing'}`, + }); + } + const metadata: InstalledHostEvidenceMetadata = Object.freeze({ + adapterRevision: target?.adapterRevision ?? 'unavailable', + frameworkVersion: artifactManifest?.producer.version ?? 'unavailable', + hostBinaryVersion: options.hostBinaryVersion === undefined + ? Object.freeze({ + reason: 'adapter simulator does not invoke a host binary', + status: 'unavailable' as const, + }) + : Object.freeze({ status: 'observed' as const, value: options.hostBinaryVersion }), + manifestSchemaDigest: digest({ + manifest: sha256Hex(artifactBytes), + schemas: target?.schemas ?? [], + }), + }); + const observation: InstalledHostObservation = Object.freeze({ + checks: outcomes(failures), + host: options.host, + metadata, + proofLevel: proofLevelLabel(HOST_INSTALL_PROOF_LEVEL), + sessionEvidence: options.sessionEvidence + ?? 'adapter-simulated discovery and stdio spawn from an isolated installed root', + versions, + }); + if (failures.length > 0) { + await client.close(); + throw installedFailure(failures); + } + + const provenance: InstalledHostMcpProvenance = Object.freeze({ + entry: entryArgument === undefined ? discovered.name : relativePath(installedRoot, resolvedCommandPath(entryArgument, cwd)), + host: options.host, + pid: transport.pid ?? undefined, + proofLevel: HOST_INSTALL_PROOF_LEVEL, + }); + let closed = false; + const close = async (): Promise => { + if (closed) return; + closed = true; + await client.close(); + }; + return Object.freeze({ + client, + close, + observation, + provenance, + stderr: () => captured, + [Symbol.asyncDispose]: close, + }); +}; diff --git a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts index 1b3530b4c..9f40daa88 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/agent-bundle.config.ts @@ -14,6 +14,9 @@ export default { name: 'host-install-proof', version: '1.0.0', }, + routes: { + mcpCommands: true, + }, skills: ['skills/probe'], targets: ['claude', 'codex', 'cursor'], }; diff --git a/packages/agent-bundle/tests/fixtures/host-install/src/mcp/probe.ts b/packages/agent-bundle/tests/fixtures/host-install/src/mcp/probe.ts index 62853ae21..6a1e6b278 100644 --- a/packages/agent-bundle/tests/fixtures/host-install/src/mcp/probe.ts +++ b/packages/agent-bundle/tests/fixtures/host-install/src/mcp/probe.ts @@ -1,6 +1,17 @@ import { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; -export default () => new McpServer({ - name: 'host-install-proof', - version: '1.0.0', -}); +export default () => { + const server = new McpServer({ + name: 'host-install-proof', + version: '1.0.0', + }); + server.registerTool('echo', { + description: 'Echoes one message from the installed host process.', + inputSchema: { message: z.string() }, + }, async ({ message }) => ({ + content: [{ text: message, type: 'text' }], + structuredContent: { message, operationId: 'tool:probe/echo' }, + })); + return server; +}; diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 8386c3b4f..6e1beff8a 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -1,4 +1,6 @@ import { spawnSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; @@ -6,6 +8,7 @@ import { buildHostInstallFixture, buildPortableHostInstallFixture, disposeHostInstallFixture, + runInstalledHostContractMatrixProof, runClaudeHostInstallProof, runCodexHostInstallProof, runCursorHostInstallProof, @@ -13,6 +16,7 @@ import { type BuiltHostInstallFixture, type BuiltPortableHostInstallFixture, } from './support/host-install.ts'; +import { AgentTestError } from '../src/test/errors.ts'; import { HOST_INSTALL_PROOF_LEVEL, proofLevelLabel, @@ -69,6 +73,96 @@ const expectHygienicReport = (report: unknown): void => { ); }; +it('stages a clean adapter-simulated host and runs the shared matrix from its installed layout', async () => { + const report = await runInstalledHostContractMatrixProof(builtFixture(), { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'installed host' }, resultCompat: 'additive' }, + }, + host: 'claude', + mode: 'adapter-simulator', + }); + + expect(report, proofLabel).toMatchObject({ + checks: { + 'component-paths': { status: 'passed' }, + 'hook-commands': { status: 'passed' }, + 'manifest-schema': { status: 'passed' }, + 'mcp-command': { status: 'passed' }, + resources: { status: 'passed' }, + 'version-digests': { status: 'passed' }, + 'version-quadruple': { status: 'passed' }, + }, + host: 'claude', + matrix: { + provenance: { host: 'claude', proofLevel: 'host-install' }, + routes: { + 'tool:probe/echo': { + checks: { + 'compat-probe': { + reason: expect.stringContaining('installed-host sessions cannot load project route modules'), + status: 'not-applicable', + }, + 'serialized-round-trip': { + reason: expect.stringContaining('installed-host sessions cannot load project route modules'), + status: 'not-applicable', + }, + sweep: { status: 'passed' }, + 'version-skew': { + reason: expect.stringContaining('installed-host sessions cannot load project route modules'), + status: 'not-applicable', + }, + }, + }, + }, + }, + metadata: { + adapterRevision: expect.any(String), + frameworkVersion: expect.stringMatching(/^\d+\.\d+\.\d+$/u), + hostBinaryVersion: { + reason: 'adapter simulator does not invoke a host binary', + status: 'unavailable', + }, + manifestSchemaDigest: expect.stringMatching(/^[a-f0-9]{64}$/u), + }, + proofLevel: proofLabel, + sessionEvidence: 'adapter-simulated discovery and stdio spawn from an isolated installed root', + status: 'passed', + versions: { + builtArtifact: '1.0.0', + installedArtifact: '1.0.0', + runningProcess: '1.0.0', + source: '1.0.0', + }, + }); + expect(report.matrix.provenance, proofLabel).toMatchObject({ + entry: expect.not.stringMatching(/^\//u), + }); + expectHygienicReport(report); +}, 180_000); + +it('fails closed when an installed manifest drifts from source, artifact, and running process', async () => { + const error = await runInstalledHostContractMatrixProof(builtFixture(), { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'installed host' }, resultCompat: 'additive' }, + }, + host: 'claude', + mode: 'adapter-simulator', + mutateInstalled: async (installedRoot) => { + const manifestPath = join(installedRoot, '.claude-plugin', 'plugin.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, version: '9.0.0' })}\n`); + }, + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).code).toBe('contract-violation'); + expect((error as AgentTestError).message).toContain('version-digests'); + expect((error as AgentTestError).message).toContain('version-quadruple'); + expect((error as AgentTestError).message).toContain(proofLabel); +}, 180_000); + claudePluginIt( claudeAvailable ? 'installs through Claude and observes the host-owned component inventory' @@ -93,6 +187,26 @@ claudePluginIt( status: 'passed', }); expectHygienicReport(report); + + const matrix = await runInstalledHostContractMatrixProof(builtFixture(), { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'claude installed host' }, resultCompat: 'additive' }, + }, + host: 'claude', + mode: 'native-host', + }); + expect(matrix.versions, proofLabel).toEqual({ + builtArtifact: '1.0.0', + installedArtifact: '1.0.0', + runningProcess: '1.0.0', + source: '1.0.0', + }); + expect(matrix.metadata.hostBinaryVersion, proofLabel).toEqual({ + status: 'observed', + value: expect.stringMatching(/^\d+\.\d+\.\d+$/u), + }); + expectHygienicReport(matrix); }, 180_000, ); @@ -136,6 +250,26 @@ codexPluginIt( status: 'passed', }); expectHygienicReport(report); + + const matrix = await runInstalledHostContractMatrixProof(builtFixture(), { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'codex installed host' }, resultCompat: 'additive' }, + }, + host: 'codex', + mode: 'native-host', + }); + expect(matrix.versions, proofLabel).toEqual({ + builtArtifact: '1.0.0', + installedArtifact: '1.0.0', + runningProcess: '1.0.0', + source: '1.0.0', + }); + expect(matrix.metadata.hostBinaryVersion, proofLabel).toEqual({ + status: 'observed', + value: expect.stringMatching(/^\d+\.\d+\.\d+$/u), + }); + expectHygienicReport(matrix); }, 180_000, ); @@ -171,6 +305,25 @@ it('installs into an isolated Cursor home, validates schemas, and is idempotent' status: 'passed', }); expectHygienicReport(report); + + const matrix = await runInstalledHostContractMatrixProof(builtFixture(), { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'cursor installed host' }, resultCompat: 'additive' }, + }, + host: 'cursor', + mode: 'native-host', + }); + expect(matrix.versions, proofLabel).toEqual({ + builtArtifact: '1.0.0', + installedArtifact: '1.0.0', + runningProcess: '1.0.0', + source: '1.0.0', + }); + expect(matrix.sessionEvidence, proofLabel).toBe( + 'unavailable: Cursor exposes no non-interactive plugin-loading session surface; adapter-simulated stdio spawn from isolated installed root', + ); + expectHygienicReport(matrix); }, 180_000); it( diff --git a/packages/agent-bundle/tests/support/host-install.ts b/packages/agent-bundle/tests/support/host-install.ts index cf34b8289..b82108a6e 100644 --- a/packages/agent-bundle/tests/support/host-install.ts +++ b/packages/agent-bundle/tests/support/host-install.ts @@ -1,7 +1,7 @@ import { execFile as executeFile } from 'node:child_process'; import { access, cp, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; import { parse as parseYaml } from 'yaml'; @@ -17,9 +17,16 @@ import { createAdapterValidator } from '../../src/adapters/types.ts'; import { isInsideOrEqual } from '../../src/core/paths.ts'; import { validateCodexOpenaiYaml } from '../../src/schemas/skill-hosts/contract.ts'; import { + compileTestManifest, HOST_INSTALL_PROOF_LEVEL, proofLevelLabel, } from '../../src/test/manifest.ts'; +import { + runInstalledHostContractMatrix, + type ContractRouteFixture, + type InstalledHostContractMatrixReport, +} from '../../src/test/contract.ts'; +import { openInstalledHostMcpServer } from '../../src/test/installed.ts'; import { normalClaudeSettingsAndPluginsUnchanged, packedNativeEnvironment, @@ -119,6 +126,14 @@ export interface HostInstallCommand { readonly prefixArguments?: readonly string[]; } +export interface InstalledHostContractMatrixProofOptions { + readonly environment: Readonly; + readonly fixtures: Readonly>; + readonly host: 'claude' | 'codex' | 'cursor'; + readonly mode: 'adapter-simulator' | 'native-host'; + readonly mutateInstalled?: (installedRoot: string) => Promise; +} + interface HostInstallProofOptions { readonly environment: Readonly; readonly installCommand?: HostInstallCommand; @@ -369,6 +384,12 @@ const isolatedEnvironment = ( ...values, }); +const stringEnvironment = ( + environment: Readonly, +): Readonly> => Object.fromEntries( + Object.entries(environment).filter((entry): entry is [string, string] => entry[1] !== undefined), +); + const assertInstallResult = ( document: InstallResult, host: 'claude' | 'codex' | 'cursor', @@ -400,7 +421,7 @@ const buildFixtureProject = async (options: { const artifactRoot = join(project, 'artifact'); try { await cp(join(fixturesRoot, options.fixture), project, { recursive: true }); - await symlink(join(workspaceRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); + await symlink(join(packageRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); await options.prepareProject?.(project); const result = await run(process.execPath, [ cli, @@ -485,6 +506,110 @@ export const disposeHostInstallFixture = async (fixture: BuiltFixtureProject): P await rm(fixture.root, { force: true, recursive: true }); }; +/** + * Stages one already-built target, opens its emitted MCP command from the + * installed location, and runs the shared matrix in that same live session. + * The adapter-simulator lane is deterministic; native-host mode uses the + * existing real CLI install machinery before the same installed-layout spawn. + */ +export const runInstalledHostContractMatrixProof = async ( + fixture: BuiltHostInstallFixture, + options: InstalledHostContractMatrixProofOptions, +): Promise => { + const root = await mkdtemp(join(tmpdir(), `agent-bundle-installed-matrix-${options.host}-`)); + const home = join(root, 'home'); + const config = join(root, 'config'); + const codexHome = join(root, 'codex'); + const simulatedRoot = join(root, 'installed'); + try { + await Promise.all([ + mkdir(home, { recursive: true }), + mkdir(config, { recursive: true }), + mkdir(codexHome, { recursive: true }), + ]); + const environment = isolatedEnvironment(options.environment, { + CLAUDE_CONFIG_DIR: config, + CODEX_HOME: codexHome, + HOME: home, + }); + let installedRoot: string; + let hostBinaryVersion: string | undefined; + let sessionEvidence: string | undefined; + if (options.mode === 'adapter-simulator') { + await cp(fixture.bundles[options.host], simulatedRoot, { recursive: true }); + installedRoot = simulatedRoot; + } else if (options.host === 'cursor') { + await mkdir(join(home, '.cursor'), { recursive: true }); + const installed = await runInstallCommand(fixture, 'cursor', fixture.bundles.cursor, { + environment, + }); + assertProof(installed.exitCode === 0, `Cursor public install path failed: ${commandDetail(installed)}`); + const document = parseJson(installed.stdout, 'Cursor install'); + assertInstallResult(document, 'cursor', 'installed'); + assertProof(typeof document.destination === 'string', 'Cursor install returned no destination.'); + installedRoot = document.destination; + sessionEvidence = 'unavailable: Cursor exposes no non-interactive plugin-loading session surface; adapter-simulated stdio spawn from isolated installed root'; + } else { + const versioned = await run(options.host, ['--version'], { + cwd: fixture.bundles[options.host], + environment, + }); + assertProof(versioned.exitCode === 0, `${options.host} --version failed: ${commandDetail(versioned)}`); + hostBinaryVersion = /(?:^|\s)(\d+\.\d+\.\d+)(?:\s|$)/u.exec(versioned.stdout)?.[1]; + assertProof(hostBinaryVersion !== undefined, `${options.host} --version did not report a semantic version.`); + const installed = await runInstallCommand( + fixture, + options.host, + fixture.bundles[options.host], + { environment }, + ); + assertProof(installed.exitCode === 0, `${options.host} public install path failed: ${commandDetail(installed)}`); + const document = parseJson(installed.stdout, `${options.host} install`); + assertInstallResult(document, options.host, 'installed'); + installedRoot = options.host === 'claude' + ? join(config, 'plugins', 'cache', marketplace, plugin, version) + : join(codexHome, 'plugins', 'cache', marketplace, plugin, version); + sessionEvidence = 'host-owned installation and adapter-format stdio spawn from isolated installed root'; + } + + await options.mutateInstalled?.(installedRoot); + const projectRoot = dirname(fixture.artifactRoot); + const compiledManifest = await compileTestManifest({ root: projectRoot }); + const manifest = Object.freeze({ + ...compiledManifest, + routes: Object.freeze({ + ...compiledManifest.routes, + 'tool:probe/echo': Object.freeze({ + config: Object.freeze({}), + id: 'tool:probe/echo', + kind: 'tool' as const, + relativePath: 'src/mcp/probe.ts', + serverId: 'mcp:probe', + source: join(projectRoot, 'src', 'mcp', 'probe.ts'), + }), + }), + }); + await using session = await openInstalledHostMcpServer({ + artifactRoot: fixture.artifactRoot, + env: stringEnvironment(environment), + host: options.host, + ...(hostBinaryVersion === undefined ? {} : { hostBinaryVersion }), + installedRoot, + manifest, + server: 'probe', + ...(sessionEvidence === undefined ? {} : { sessionEvidence }), + }); + return await runInstalledHostContractMatrix({ + fixtures: options.fixtures, + manifest, + server: 'probe', + session, + }); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + export const runClaudeHostInstallProof = async ( fixture: BuiltHostInstallFixture, options: HostInstallProofOptions,