diff --git a/.changeset/secure-installed-test-harness.md b/.changeset/secure-installed-test-harness.md new file mode 100644 index 000000000..4c25d5efc --- /dev/null +++ b/.changeset/secure-installed-test-harness.md @@ -0,0 +1,8 @@ +--- +'agent-bundle': patch +--- + +Fix installed-host verification to reject integrity failures before spawning +an MCP command, distinguish simulated staging from real host-install proof, +and accept artifacts that declare no resources or hooks. Preserve caller-owned +progress handlers while the contract matrix observes lifecycle notifications. diff --git a/packages/agent-bundle/src/test/contract.ts b/packages/agent-bundle/src/test/contract.ts index e0b6407d2..165ec750d 100644 --- a/packages/agent-bundle/src/test/contract.ts +++ b/packages/agent-bundle/src/test/contract.ts @@ -233,13 +233,16 @@ const packedBoundaryFromSession = ( ...(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 installedHostBoundaryFromSession = ( + session: InstalledHostMcpSession, +): MatrixBoundaryCapabilities => + Object.freeze({ + canLoadRouteModules: false, + moduleSchemaNotApplicableReason: INSTALLED_HOST_MODULE_SCHEMA_NOT_APPLICABLE_REASON, + proofLevel: session.provenance.proofLevel, + registersAppResources: true, + recovery: 'Fix the installed layout, route, or fixture; reinstall and re-run runInstalledHostContractMatrix.', + }); const COMPAT_PROBE_KEY = '__agentBundleContractProbe'; @@ -923,6 +926,10 @@ interface LifecycleEvidence { readonly orderFailure?: string; } +type ClientNotificationHandler = ( + ...arguments_: readonly unknown[] +) => void | Promise; + const executeLifecycleTransitions = async ( client: Client, descriptor: TestableRouteDescriptor, @@ -949,21 +956,48 @@ const executeLifecycleTransitions = async ( } const byPhase = new Map(); - for (const [index, transition] of transitions.entries()) { - const progressToken = `agent-bundle-contract-lifecycle:${descriptor.id}:${String(index)}`; - let settled = false; - let liveProgress = 0; - client.setNotificationHandler('notifications/progress', (notification) => { - if (notification.params.progressToken === progressToken && !settled) liveProgress += 1; - }); - const result = await callToolResult( - client, - routeProtocolName(descriptor), - transition.input, - { progressToken, timeout: 10_000 }, - ); - settled = true; - byPhase.set(transition.phase, { liveProgress, result, transition }); + const progressMethod = 'notifications/progress'; + const notificationHandlers = ( + client as unknown as { + readonly _notificationHandlers: Map; + } + )._notificationHandlers; + const callerHandler = notificationHandlers.get(progressMethod); + let activeProgressToken: string | undefined; + let settled = true; + let liveProgress = 0; + notificationHandlers.set(progressMethod, async (...arguments_) => { + const notification = arguments_[0] as { + readonly params?: { readonly progressToken?: string | number }; + }; + if ( + notification.params?.progressToken === activeProgressToken + && !settled + ) { + liveProgress += 1; + } + await callerHandler?.(...arguments_); + }); + try { + for (const [index, transition] of transitions.entries()) { + activeProgressToken = `agent-bundle-contract-lifecycle:${descriptor.id}:${String(index)}`; + settled = false; + liveProgress = 0; + const result = await callToolResult( + client, + routeProtocolName(descriptor), + transition.input, + { progressToken: activeProgressToken, timeout: 10_000 }, + ); + settled = true; + byPhase.set(transition.phase, { liveProgress, result, transition }); + } + } finally { + if (callerHandler === undefined) { + notificationHandlers.delete(progressMethod); + } else { + notificationHandlers.set(progressMethod, callerHandler); + } } return { byPhase }; }; @@ -1502,7 +1536,7 @@ export const runInstalledHostContractMatrix = async ( ): Promise => { const serverName = resolveServerName(options.manifest, options.server); const matrix = await executeContractMatrix({ - boundary: INSTALLED_HOST_BOUNDARY, + boundary: installedHostBoundaryFromSession(options.session), client: options.session.client, fixtures: options.fixtures, manifest: options.manifest, diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 5934f4dc5..a8a13f19f 100644 --- a/packages/agent-bundle/src/test/index.ts +++ b/packages/agent-bundle/src/test/index.ts @@ -14,6 +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 | + * | `simulated` | `openInstalledHostMcpServer` without `sessionEvidence` | an emitted bundle staged directly into an isolated host-shaped root and spawned without a host-owned install | * | `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` @@ -28,6 +29,7 @@ export { PACKED_DELETED_SOURCE_PROOF_LEVEL, PACKED_STDIO_PROOF_LEVEL, ROUTE_UNIT_PROOF_LEVEL, + SIMULATED_PROOF_LEVEL, compileTestManifest, proofLevelLabel, testManifestFromRouteGraph, diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index be9c4d17c..e0d8083eb 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -5,7 +5,7 @@ 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 { parseArtifactHookIndex, type ArtifactHook } from '../build/hook-index.ts'; import { parseArtifactManifest } from '../build/manifest.ts'; import { digest, sha256Hex } from '../core/digest.ts'; import { resolveBundleRoot } from '../install/doctor.ts'; @@ -13,8 +13,10 @@ import type { InstallHost } from '../install/install.ts'; import { AgentTestError } from './errors.ts'; import { HOST_INSTALL_PROOF_LEVEL, + SIMULATED_PROOF_LEVEL, proofLevelLabel, type AgentBundleTestManifest, + type AgentTestProofLevel, } from './manifest.ts'; export type InstalledHostCheckName = @@ -63,7 +65,7 @@ export interface InstalledHostMcpProvenance { readonly entry: string; readonly host: InstallHost; readonly pid: number | undefined; - readonly proofLevel: typeof HOST_INSTALL_PROOF_LEVEL; + readonly proofLevel: typeof HOST_INSTALL_PROOF_LEVEL | typeof SIMULATED_PROOF_LEVEL; } export interface InstalledHostMcpSession extends AsyncDisposable { @@ -168,12 +170,15 @@ const fileHash = async (path: string): Promise => { const relativePath = (root: string, path: string): string => relative(root, path).split(sep).join('/'); -const installedFailure = (failures: readonly Failure[]): AgentTestError => new AgentTestError( +const installedFailure = ( + failures: readonly Failure[], + proofLevel: AgentTestProofLevel, +): AgentTestError => new AgentTestError( 'contract-violation', - `Installed-host contract matrix reported ${String(failures.length)} violation(s) at the host-install proof level.`, + `Installed-host contract matrix reported ${String(failures.length)} violation(s) at the ${proofLevel} proof level.`, { details: failures.map((failure) => - `- installed-host / ${failure.check}: ${failure.reason} (${proofLevelLabel(HOST_INSTALL_PROOF_LEVEL)})`), + `- installed-host / ${failure.check}: ${failure.reason} (${proofLevelLabel(proofLevel)})`), recovery: 'Rebuild, reinstall into a clean host root, and rerun runInstalledHostContractMatrix.', }, ); @@ -263,6 +268,9 @@ export const openInstalledHostMcpServer = async ( ): Promise => { const artifactRoot = resolve(options.artifactRoot); const installedRoot = resolve(options.installedRoot); + const proofLevel = options.sessionEvidence === undefined + ? SIMULATED_PROOF_LEVEL + : HOST_INSTALL_PROOF_LEVEL; const failures: Failure[] = []; let artifactBytes = ''; let artifactManifest: ReturnType | undefined; @@ -307,9 +315,6 @@ export const openInstalledHostMcpServer = async ( 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) { @@ -348,23 +353,29 @@ export const openInstalledHostMcpServer = async ( 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' }); - } + let installedHooks: readonly ArtifactHook[] | undefined; 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' }); + if (hookIndex === undefined) { + failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); + } else { + installedHooks = hookIndex.hooks.filter((hook) => hook.target === options.host); + } + } catch { + failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' }); + } + if (installedHooks !== undefined && installedHooks.length > 0) { + 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' }); } for (const hook of installedHooks) { const path = hook.path.startsWith(prefix) ? hook.path.slice(prefix.length) : hook.path; @@ -372,8 +383,6 @@ export const openInstalledHostMcpServer = async ( 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( @@ -412,7 +421,7 @@ export const openInstalledHostMcpServer = async ( ...expandedDeclaredEnvironment, }; - if (expandedCommand.length === 0 || discovered.name.length === 0) throw installedFailure(failures); + if (failures.length > 0) throw installedFailure(failures, proofLevel); const client = new Client({ name: 'agent-bundle-installed-host-proof', version: '1.0.0' }); const transport = new StdioClientTransport({ args: [...args], @@ -433,7 +442,7 @@ export const openInstalledHostMcpServer = async ( check: 'mcp-command', reason: `installed MCP command could not initialize${captured === '' ? '' : `: ${captured}`}`, }); - throw installedFailure(failures); + throw installedFailure(failures, proofLevel); } const runningVersion = requiredString( @@ -472,21 +481,21 @@ export const openInstalledHostMcpServer = async ( checks: outcomes(failures), host: options.host, metadata, - proofLevel: proofLevelLabel(HOST_INSTALL_PROOF_LEVEL), + proofLevel: proofLevelLabel(proofLevel), 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); + throw installedFailure(failures, proofLevel); } 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, + proofLevel, }); let closed = false; const close = async (): Promise => { diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index f737a2417..2bab8cca2 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -36,6 +36,8 @@ import type { * - `browser-app` compiles MCP App HTML through the production Rsbuild * profile and mounts it over the product bridge in a real browser page. It * does not prove host embedding, a packed artifact, or Workbench behavior. + * - `simulated` stages an emitted bundle directly into an isolated host-shaped + * root and spawns its MCP command. It does not prove a host-owned install. * - `host-install` installs a built bundle into an isolated real host home * through the public install path and observes registration through the * host's own CLI. It does not prove session behavior or packed provenance. @@ -47,6 +49,7 @@ export type AgentTestProofLevel = | 'packed-stdio' | 'packed-deleted-source' | 'browser-app' + | 'simulated' | 'host-install'; export const ROUTE_UNIT_PROOF_LEVEL = 'route-unit' as const; @@ -55,6 +58,7 @@ export const CLI_DISPATCH_PROOF_LEVEL = 'cli-dispatch' as const; export const PACKED_STDIO_PROOF_LEVEL = 'packed-stdio' as const; export const PACKED_DELETED_SOURCE_PROOF_LEVEL = 'packed-deleted-source' as const; export const BROWSER_APP_PROOF_LEVEL = 'browser-app' as const; +export const SIMULATED_PROOF_LEVEL = 'simulated' as const; export const HOST_INSTALL_PROOF_LEVEL = 'host-install' as const; /** @@ -76,6 +80,8 @@ export const proofLevelLabel = (level: AgentTestProofLevel): string => { return 'packed-deleted-source (packed tarball installed into a clean consumer, artifact built, project source removed and verified absent, generated stdio entry spawned as a real process; self-contained-artifact evidence)'; case 'browser-app': return 'browser-app (MCP App HTML compiled through the production Rsbuild profile, mounted in a real browser page over the product bridge; NOT host embedding, packed-artifact, or Workbench evidence)'; + case 'simulated': + return 'simulated (adapter-simulated discovery and stdio spawn from an isolated installed root; NOT host-install evidence)'; case 'host-install': return 'host-install (built bundle installed into an isolated real host home through the public install path, registration observed via the host\'s own CLI; NOT session-behavior or packed-artifact evidence)'; default: { diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 6e1beff8a..cded0fec6 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -1,5 +1,6 @@ import { spawnSync } from 'node:child_process'; -import { readFile, writeFile } from 'node:fs/promises'; +import { cp, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, expect, it } from '@rstest/core'; @@ -17,12 +18,15 @@ import { type BuiltPortableHostInstallFixture, } from './support/host-install.ts'; import { AgentTestError } from '../src/test/errors.ts'; +import { stableJson } from '../src/core/digest.ts'; import { HOST_INSTALL_PROOF_LEVEL, proofLevelLabel, } from '../src/test/manifest.ts'; const proofLabel = proofLevelLabel(HOST_INSTALL_PROOF_LEVEL); +const simulatedProofLabel = + 'simulated (adapter-simulated discovery and stdio spawn from an isolated installed root; NOT host-install evidence)'; const claudeMissingEvidence = 'missing evidence: claude binary unavailable on PATH'; const codexMissingEvidence = 'missing evidence: codex binary unavailable on PATH'; const claudeAvailable = spawnSync('claude', ['--version'], { @@ -39,11 +43,31 @@ const claudePluginIt = claudeAvailable ? it : it.skip; const codexPluginIt = codexAvailable ? it : it.skip; let fixture: BuiltHostInstallFixture | undefined; +let mcpOnlyFixture: BuiltHostInstallFixture | undefined; let portableFixture: BuiltPortableHostInstallFixture | undefined; beforeAll(async () => { - [fixture, portableFixture] = await Promise.all([ + [fixture, mcpOnlyFixture, portableFixture] = await Promise.all([ buildHostInstallFixture({ environment: process.env }), + buildHostInstallFixture({ + environment: process.env, + prepareProject: async (projectRoot) => { + await writeFile(join(projectRoot, 'agent-bundle.config.ts'), [ + 'export default {', + ' marketplace: true,', + ' mcp: { servers: { probe: {} } },', + ' plugin: {', + " description: 'Proves an MCP-only installed host artifact.',", + " name: 'host-install-mcp-only-proof',", + " version: '1.0.0',", + ' },', + ' routes: { mcpCommands: true },', + " targets: ['claude', 'codex', 'cursor'],", + '};', + '', + ].join('\n')); + }, + }), buildPortableHostInstallFixture({ environment: process.env }), ]); }, 180_000); @@ -51,6 +75,7 @@ beforeAll(async () => { afterAll(async () => { await Promise.all([ fixture === undefined ? Promise.resolve() : disposeHostInstallFixture(fixture), + mcpOnlyFixture === undefined ? Promise.resolve() : disposeHostInstallFixture(mcpOnlyFixture), portableFixture === undefined ? Promise.resolve() : disposeHostInstallFixture(portableFixture), ]); }); @@ -67,6 +92,13 @@ const builtPortableFixture = (): BuiltPortableHostInstallFixture => { return portableFixture; }; +const builtMcpOnlyFixture = (): BuiltHostInstallFixture => { + if (mcpOnlyFixture === undefined) { + throw new Error(`[${simulatedProofLabel}] MCP-only fixture build did not complete.`); + } + return mcpOnlyFixture; +}; + const expectHygienicReport = (report: unknown): void => { expect(JSON.stringify(report), proofLabel).not.toMatch( /(?:API_KEY|AUTH_TOKEN|ACCESS_TOKEN|authorization|credential|password|secret|sk-[A-Za-z0-9_-]{16,}|\/home\/|\/Users\/|\/tmp\/|stdout|stderr)/iu, @@ -95,7 +127,7 @@ it('stages a clean adapter-simulated host and runs the shared matrix from its in }, host: 'claude', matrix: { - provenance: { host: 'claude', proofLevel: 'host-install' }, + provenance: { host: 'claude', proofLevel: 'simulated' }, routes: { 'tool:probe/echo': { checks: { @@ -125,7 +157,7 @@ it('stages a clean adapter-simulated host and runs the shared matrix from its in }, manifestSchemaDigest: expect.stringMatching(/^[a-f0-9]{64}$/u), }, - proofLevel: proofLabel, + proofLevel: simulatedProofLabel, sessionEvidence: 'adapter-simulated discovery and stdio spawn from an isolated installed root', status: 'passed', versions: { @@ -141,7 +173,102 @@ it('stages a clean adapter-simulated host and runs the shared matrix from its in expectHygienicReport(report); }, 180_000); -it('fails closed when an installed manifest drifts from source, artifact, and running process', async () => { +it('does not execute a tampered installed MCP command after static integrity checks fail', async () => { + const markerRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-tampered-command-')); + const marker = join(markerRoot, 'executed'); + try { + 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 commandPath = join(markerRoot, 'tampered-command.sh'); + await writeFile(commandPath, [ + '#!/bin/sh', + `printf executed > ${JSON.stringify(marker)}`, + `exec ${JSON.stringify(process.execPath)} "$@"`, + '', + ].join('\n'), { mode: 0o755 }); + const mcpPath = join(installedRoot, '.mcp.json'); + const mcp = JSON.parse(await readFile(mcpPath, 'utf8')) as { + mcpServers: Record>; + }; + await writeFile(mcpPath, `${JSON.stringify({ + ...mcp, + mcpServers: { + ...mcp.mcpServers, + probe: { ...mcp.mcpServers.probe, command: commandPath }, + }, + })}\n`); + }, + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).message).toContain('version-digests'); + await expect(readFile(marker, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(markerRoot, { force: true, recursive: true }); + } +}, 180_000); + +it('accepts an installed artifact whose manifest declares no resource components', async () => { + const cloneParent = await mkdtemp(join(tmpdir(), 'agent-bundle-resource-free-')); + const cloneRoot = join(cloneParent, 'fixture'); + try { + await cp(builtFixture().root, cloneRoot, { recursive: true }); + const artifactRoot = join(cloneRoot, 'project', 'artifact'); + const artifactManifestPath = join(artifactRoot, 'agent-bundle.manifest.json'); + const artifactManifest = JSON.parse(await readFile(artifactManifestPath, 'utf8')) as { + readonly files: readonly { readonly path: string }[]; + }; + await writeFile(artifactManifestPath, `${stableJson({ + ...artifactManifest, + files: artifactManifest.files.filter((file) => + !/^[^/]+\/(?:assets|commands|skills)\//u.test(file.path)), + })}\n`); + const clonedFixture: BuiltHostInstallFixture = Object.freeze({ + artifactRoot, + bundles: Object.freeze({ + claude: join(artifactRoot, 'claude'), + codex: join(artifactRoot, 'codex'), + cursor: join(artifactRoot, 'cursor'), + }), + cli: builtFixture().cli, + root: cloneRoot, + }); + const report = await runInstalledHostContractMatrixProof(clonedFixture, { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'resource-free installed host' }, resultCompat: 'additive' }, + }, + host: 'claude', + mode: 'adapter-simulator', + }); + + expect(report.checks.resources).toEqual({ status: 'passed' }); + } finally { + await rm(cloneParent, { force: true, recursive: true }); + } +}, 180_000); + +it('accepts an installed MCP-only artifact with no declared hooks', async () => { + const report = await runInstalledHostContractMatrixProof(builtMcpOnlyFixture(), { + environment: process.env, + fixtures: { + 'tool:probe/echo': { input: { message: 'MCP-only installed host' }, resultCompat: 'additive' }, + }, + host: 'claude', + mode: 'adapter-simulator', + }); + + expect(report.checks['hook-commands']).toEqual({ status: 'passed' }); + expect(report.proofLevel).toBe(simulatedProofLabel); +}, 180_000); + +it('fails closed when an installed manifest drifts from the built artifact', async () => { const error = await runInstalledHostContractMatrixProof(builtFixture(), { environment: process.env, fixtures: { @@ -159,8 +286,7 @@ it('fails closed when an installed manifest drifts from source, artifact, and ru 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); + expect((error as AgentTestError).message).toContain(simulatedProofLabel); }, 180_000); claudePluginIt( diff --git a/packages/agent-bundle/tests/projection/contract-matrix.test.ts b/packages/agent-bundle/tests/projection/contract-matrix.test.ts index ae10f5e5c..ea9456205 100644 --- a/packages/agent-bundle/tests/projection/contract-matrix.test.ts +++ b/packages/agent-bundle/tests/projection/contract-matrix.test.ts @@ -1,21 +1,31 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { describe, expect, it } from '@rstest/core'; import { createSqliteStateDriver } from '@agent-bundle/runtime/state/sqlite'; import stateDefinition from '../../fixtures/route-harness/src/state.ts'; import { AgentTestError } from '../../src/test/errors.ts'; -import { MCP_IN_MEMORY_PROOF_LEVEL, proofLevelLabel } from '../../src/test/manifest.ts'; -import { runContractMatrix, type ContractMatrixOptions } from '../../src/test/contract.ts'; +import { + compileTestManifest, + MCP_IN_MEMORY_PROOF_LEVEL, + proofLevelLabel, +} from '../../src/test/manifest.ts'; +import { + runContractMatrix, + runPackedContractMatrix, + type ContractMatrixOptions, +} from '../../src/test/contract.ts'; import { openInMemoryMcpServer, type InMemoryMcpSession } from '../../src/test/mcp.ts'; +import type { PackedMcpSession } from '../../src/test/packed.ts'; import { routeHarnessContractFixtures, routeHarnessLifecycleWithoutLiveProgress, } from '../support/contract-matrix-fixtures.ts'; const proofLabel = proofLevelLabel(MCP_IN_MEMORY_PROOF_LEVEL); +const fixtureRoot = resolve(import.meta.dirname, '../../fixtures/route-harness'); const withStatefulMatrix = async ( fixtures: ContractMatrixOptions['fixtures'], @@ -83,6 +93,64 @@ describe('the generated-plugin contract matrix', () => { expect((error as AgentTestError).message).toContain(proofLabel); }, 30_000); + it('composes and restores a caller-owned progress handler after lifecycle replay', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-contract-handler-')); + const compiledManifest = await compileTestManifest({ root: fixtureRoot }); + const manifest = Object.freeze({ + ...compiledManifest, + apps: Object.freeze({}), + routes: Object.freeze(Object.fromEntries( + Object.entries(compiledManifest.routes).filter(([, route]) => route.kind !== 'app'), + )), + }); + const session = await openInMemoryMcpServer({ + manifest, + state: { + definition: stateDefinition, + driver: createSqliteStateDriver({ root }), + }, + }); + const packedSession: PackedMcpSession = Object.freeze({ + client: session.client, + close: session.close, + provenance: Object.freeze({ + entry: 'in-memory progress-handler regression fixture', + pid: undefined, + proofLevel: 'packed-stdio' as const, + }), + stderr: () => '', + [Symbol.asyncDispose]: session[Symbol.asyncDispose], + }); + type NotificationHandler = (...arguments_: readonly unknown[]) => void | Promise; + const notificationHandlers = ( + session.client as unknown as { + readonly _notificationHandlers: Map; + } + )._notificationHandlers; + let callerProgress = 0; + session.client.setNotificationHandler('notifications/progress', () => { + callerProgress += 1; + }); + const callerHandler = notificationHandlers.get('notifications/progress'); + try { + const report = await runPackedContractMatrix({ + fixtures: routeHarnessContractFixtures(), + manifest, + server: 'harness', + session: packedSession, + }); + + expect(report.routes['tool:harness/lifecycle']?.checks['lifecycle-replay']).toEqual({ + status: 'passed', + }); + expect(callerProgress).toBeGreaterThan(0); + expect(notificationHandlers.get('notifications/progress')).toBe(callerHandler); + } finally { + await session.close(); + await rm(root, { force: true, recursive: true }); + } + }, 30_000); + it('aggregates missing route coverage with the proof-level label', async () => { const fixtures = { ...routeHarnessContractFixtures() }; delete fixtures['tool:harness/echo'];