diff --git a/.changeset/packed-deleted-source-proof.md b/.changeset/packed-deleted-source-proof.md new file mode 100644 index 000000000..ff999afaa --- /dev/null +++ b/.changeset/packed-deleted-source-proof.md @@ -0,0 +1,16 @@ +--- +'agent-bundle': minor +--- + +Add the `packed-deleted-source` consumer proof level to `agent-bundle/test`. + +`removeProjectSource` removes conventional project inputs and returns a frozen +receipt, while `openPackedMcpServer({ deletedSource })` verifies every receipt +path is still absent immediately before spawn and upgrades its provenance only +then. + +The repository's single packed harness journey now builds once, removes the +fixture source and configuration, spawns once, asserts every route, and proves +the generated server serves its self-contained embedded MCP App resource. +Packed test and release scripts also accept both npm 11's array and npm 12's +package-keyed object forms of `npm pack --json`. diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 2a5cdbe6c..feca22ea2 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -276,6 +276,7 @@ is never a receipt for another. | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface` | the real generated MCP server's protocol contract, over the SDK's in-memory transport | | `cli-dispatch` | `invokeCli`, `cliJson` | an argv vector resolved and run through the routed CLI's own shell, in-process | | `packed-stdio` | `openPackedMcpServer` | a built artifact's generated entry running as a real process over stdio | +| `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer({ deletedSource })` | the packed stdio process still runs after project source and configuration are removed and verified absent | ```ts import { cliJson, expectEvents, invokeCli, invokeMcpTool } from 'agent-bundle/test'; @@ -296,10 +297,13 @@ turn a passing render red — while a missing frame, a reordering, or a regresse ordinal still fails; `toHaveMonotonicSequence`, `toCompleteOnce`, `toHaveProgress`, and `toHaveNoErrors` cover the rest of the contract. -Only `packed-stdio` is process evidence, and it is deliberately expensive: pack -once, install once, spawn once, and iterate every per-route assertion inside -that one session. Browser-App surfaces and deleted-source artifact proofs are -later stages; nothing here stands in for them. +Only `packed-stdio` and its strictly stronger `packed-deleted-source` upgrade +are process evidence, and they are deliberately expensive: pack once, install +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. ## Evaluation diff --git a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/apps/panel.tsx b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/apps/panel.tsx index dad0859e7..6b36fba5a 100644 --- a/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/apps/panel.tsx +++ b/packages/agent-bundle/fixtures/route-harness/src/mcp/harness/apps/panel.tsx @@ -1,7 +1,14 @@ // A browser App surface. The route-unit level refuses to render it by name; -// nothing in the Node test bundle ever imports this module. +// nothing in the Node test bundle ever imports this module, and the guard +// keeps its observable marker inert anywhere without a browser document. export const config = { resourceUri: 'ui://harness/panel' }; +if (typeof document !== 'undefined') { + const marker = document.createElement('span'); + marker.textContent = 'route-harness panel'; + document.body.append(marker); +} + export default function Panel() { return null; } diff --git a/packages/agent-bundle/src/test/errors.ts b/packages/agent-bundle/src/test/errors.ts index 151d45ff7..7f964ed1f 100644 --- a/packages/agent-bundle/src/test/errors.ts +++ b/packages/agent-bundle/src/test/errors.ts @@ -4,6 +4,7 @@ import type { RenderedRouteProvenance } from './types.ts'; export type AgentTestErrorCode = | 'assertion-failed' | 'command-not-found' + | 'deleted-source-unverified' | 'invalid-input' | 'invalid-route-module' | 'manifest-unavailable' diff --git a/packages/agent-bundle/src/test/index.ts b/packages/agent-bundle/src/test/index.ts index 31747b212..bd44b1d81 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. * - * Four Node proof levels ship here, and the browser-safe fifth level ships + * Five Node proof levels ship here, and the browser-safe sixth level ships * from `agent-bundle/test/browser`. Each helper names the level it supplies, * stamps it into its provenance, and prints it in every failure: * @@ -11,15 +11,18 @@ * | `mcp-in-memory` | `openInMemoryMcpServer`, `invokeMcpTool`, `readMcpResource`, `getMcpPrompt`, `listMcpSurface` | the real generated MCP server's protocol contract, over the SDK's in-memory transport | * | `cli-dispatch` | `invokeCli`, `cliJson` | a compiled CLI command dispatched through the routed CLI's own shell, in this process | * | `packed-stdio` | `openPackedMcpServer` | a built artifact's generated entry running as a real process over stdio | + * | `packed-deleted-source` | `removeProjectSource`, `openPackedMcpServer` | 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 | * - * A pass at one level is never a receipt for another. Deleted-source artifact - * proof is a later stage; nothing here stands in for it. + * A pass at one level is never a receipt for another. The `deletedSource` + * option upgrades `openPackedMcpServer` provenance only after every path in a + * non-empty removal receipt is verified absent immediately before spawn. */ export { BROWSER_APP_PROOF_LEVEL, CLI_DISPATCH_PROOF_LEVEL, MCP_IN_MEMORY_PROOF_LEVEL, + PACKED_DELETED_SOURCE_PROOF_LEVEL, PACKED_STDIO_PROOF_LEVEL, ROUTE_UNIT_PROOF_LEVEL, compileTestManifest, @@ -75,8 +78,13 @@ export type { } from './mcp.ts'; export { cliJson, invokeCli } from './cli.ts'; export type { CliDispatchProvenance, CliInvocation, InvokeCliOptions } from './cli.ts'; -export { openPackedMcpServer } from './packed.ts'; -export type { PackedMcpProvenance, PackedMcpSession, PackedMcpSessionOptions } from './packed.ts'; +export { openPackedMcpServer, removeProjectSource } from './packed.ts'; +export type { + DeletedSourceReceipt, + PackedMcpProvenance, + PackedMcpSession, + PackedMcpSessionOptions, +} from './packed.ts'; export type { AgentRouteModule, AgentRouteModuleLoader, diff --git a/packages/agent-bundle/src/test/manifest.ts b/packages/agent-bundle/src/test/manifest.ts index caa4529fb..d27464779 100644 --- a/packages/agent-bundle/src/test/manifest.ts +++ b/packages/agent-bundle/src/test/manifest.ts @@ -29,20 +29,28 @@ import type { * - `packed-stdio` installs the packed release tarball into a clean consumer, * spawns the generated stdio entry as a real process, and drives it with a * real MCP client. This is the only level here that is process evidence. + * - `packed-deleted-source` carries the `packed-stdio` proof after project + * source and configuration have been removed and verified absent. It proves + * that the generated entry is self-contained; it does not prove native-host + * install or dispatch, or an install mode that copies the artifact elsewhere. * * - `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. - * - * Deleted-source artifact evidence is a later stage; nothing here stands in - * for it. */ -export type AgentTestProofLevel = 'route-unit' | 'mcp-in-memory' | 'cli-dispatch' | 'packed-stdio' | 'browser-app'; +export type AgentTestProofLevel = + | 'route-unit' + | 'mcp-in-memory' + | 'cli-dispatch' + | 'packed-stdio' + | 'packed-deleted-source' + | 'browser-app'; export const ROUTE_UNIT_PROOF_LEVEL = 'route-unit' as const; export const MCP_IN_MEMORY_PROOF_LEVEL = 'mcp-in-memory' as const; 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; /** @@ -60,6 +68,8 @@ export const proofLevelLabel = (level: AgentTestProofLevel): string => { return 'cli-dispatch (argv dispatched through the routed CLI shell in-process; NOT a spawned binary)'; case 'packed-stdio': return 'packed-stdio (packed tarball installed into a clean consumer, generated stdio entry spawned as a real process)'; + case 'packed-deleted-source': + 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)'; default: { diff --git a/packages/agent-bundle/src/test/packed.ts b/packages/agent-bundle/src/test/packed.ts index 563be55ed..a510c4bd3 100644 --- a/packages/agent-bundle/src/test/packed.ts +++ b/packages/agent-bundle/src/test/packed.ts @@ -13,17 +13,38 @@ * instead: one artifact, one spawned server, every route asserted inside that * single session (#103's cost rule). */ +import { lstat, readdir, rm } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + import type { Client } from '@modelcontextprotocol/client'; import { AgentTestError } from './errors.ts'; -import { PACKED_STDIO_PROOF_LEVEL } from './manifest.ts'; +import { + PACKED_DELETED_SOURCE_PROOF_LEVEL, + PACKED_STDIO_PROOF_LEVEL, + proofLevelLabel, +} from './manifest.ts'; + +/** + * Verified project-relative paths removed before a packed entry is spawned. + * The receipt upgrades only a session whose entry and any explicit cwd belong + * to this same project root. + */ +export interface DeletedSourceReceipt { + /** Absolute root that must contain the launched entry and any explicit cwd. */ + readonly projectRoot: string; + /** Sorted project-relative POSIX paths that existed, were removed, and were verified absent. */ + readonly removed: readonly string[]; +} /** Where a packed session's evidence came from. */ export interface PackedMcpProvenance { /** Absolute path of the generated stdio entry that was spawned. */ readonly entry: string; readonly pid: number | undefined; - readonly proofLevel: typeof PACKED_STDIO_PROOF_LEVEL; + readonly proofLevel: typeof PACKED_DELETED_SOURCE_PROOF_LEVEL | typeof PACKED_STDIO_PROOF_LEVEL; + /** Project-relative paths verified absent immediately before the process spawn. */ + readonly sourceRemoved?: readonly string[]; } export interface PackedMcpSessionOptions { @@ -31,6 +52,12 @@ export interface PackedMcpSessionOptions { readonly args?: readonly string[]; /** Working directory for the spawned process; defaults to the entry's directory. */ readonly cwd?: string; + /** + * Receipt whose paths must still be absent and whose project root must + * contain the entry and any explicit cwd before this session may claim + * deleted-source proof. + */ + readonly deletedSource?: DeletedSourceReceipt; /** Absolute path of the generated stdio entry (`//mcp/.mjs`). */ readonly entry: string; /** Environment for the spawned process; defaults to the current one. */ @@ -70,6 +97,181 @@ const loadSdk = async (): Promise => { return sdkPromise; }; +const pathExists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +}; + +const deletedSourceError = ( + message: string, + options: { + readonly cause?: unknown; + readonly details?: readonly string[]; + readonly recovery: string; + }, +): AgentTestError => new AgentTestError('deleted-source-unverified', message, { + ...(options.cause === undefined ? {} : { cause: options.cause }), + details: [ + `proof level: ${proofLevelLabel(PACKED_DELETED_SOURCE_PROOF_LEVEL)}`, + ...(options.details ?? []), + ], + recovery: options.recovery, +}); + +const projectPath = ( + projectRoot: string, + candidate: string, +): { readonly absolute: string; readonly relative: string } => { + const absolute = resolve(projectRoot, candidate); + const relativePath = relative(projectRoot, absolute); + if ( + relativePath === '' + || relativePath === '..' + || relativePath.startsWith(`..${sep}`) + || isAbsolute(relativePath) + ) { + throw deletedSourceError('A deleted-source path did not stay inside the project root.', { + details: [`project root: ${projectRoot}`, `path: ${candidate}`], + recovery: 'Pass only non-empty project-relative paths beneath projectRoot.', + }); + } + return { + absolute, + relative: relativePath.split(sep).join('/'), + }; +}; + +const belongsToProject = ( + projectRoot: string, + candidate: string, + allowRoot: boolean, +): boolean => { + const relativePath = relative(resolve(projectRoot), resolve(candidate)); + return (allowRoot || relativePath !== '') + && relativePath !== '..' + && !relativePath.startsWith(`..${sep}`) + && !isAbsolute(relativePath); +}; + +const verifyDeletedSourceSessionBinding = ( + receipt: DeletedSourceReceipt, + entry: string, + cwd: string | undefined, +): void => { + const resolvedEntry = resolve(entry); + if (!belongsToProject(receipt.projectRoot, resolvedEntry, false)) { + throw deletedSourceError('The launched entry does not belong to the deleted-source receipt project.', { + details: [`project root: ${receipt.projectRoot}`, `entry: ${resolvedEntry}`], + recovery: 'Pass the receipt produced for the project that owns the launched entry.', + }); + } + if (cwd === undefined) return; + const resolvedCwd = resolve(cwd); + if (!belongsToProject(receipt.projectRoot, resolvedCwd, true)) { + throw deletedSourceError('The launched cwd does not belong to the deleted-source receipt project.', { + details: [`project root: ${receipt.projectRoot}`, `cwd: ${resolvedCwd}`], + recovery: 'Pass the receipt produced for the project that owns the launched entry, and keep its cwd inside that project.', + }); + } +}; + +const verifyDeletedSourceReceipt = async (receipt: DeletedSourceReceipt): Promise => { + if (receipt.removed.length === 0) { + throw deletedSourceError('The deleted-source receipt names no removed project paths.', { + details: [`project root: ${receipt.projectRoot}`], + recovery: 'Call removeProjectSource on a project that still contains source before opening the packed session.', + }); + } + const paths = receipt.removed.map((candidate) => projectPath(receipt.projectRoot, candidate)); + let survived: readonly string[]; + try { + survived = ( + await Promise.all(paths.map(async (path) => ({ ...path, exists: await pathExists(path.absolute) }))) + ).filter((path) => path.exists).map((path) => path.relative); + } catch (error) { + throw deletedSourceError('The deleted-source receipt could not be verified before process spawn.', { + cause: error, + details: [`project root: ${receipt.projectRoot}`], + recovery: 'Make the receipt paths readable, remove them, and open the packed session again.', + }); + } + if (survived.length > 0) { + throw deletedSourceError('Project source named by the deleted-source receipt still exists.', { + details: [`project root: ${receipt.projectRoot}`, `survived: ${survived.join(', ')}`], + recovery: 'Remove every receipt path before opening the packed session.', + }); + } +}; + +/** + * Removes a consumer project's conventional source inputs and verifies their + * absence before minting deleted-source evidence. + * + * The default set is `src` plus every root `agent-bundle.config.*` entry; + * callers may add project-relative paths for non-conventional inputs. A + * project with nothing to remove is refused, because absence observed without + * a deletion is not evidence that the artifact survived source removal. + */ +export const removeProjectSource = async (options: { + readonly extraPaths?: readonly string[]; + readonly projectRoot: string; +}): Promise => { + const projectRoot = resolve(options.projectRoot); + let entries: readonly string[]; + try { + entries = await readdir(projectRoot); + } catch (error) { + throw deletedSourceError('The project root does not exist or cannot be read.', { + cause: error, + details: [`project root: ${projectRoot}`], + recovery: 'Pass an existing readable consumer project root.', + }); + } + + const candidates = new Map(); + for (const candidate of [ + ...(entries.includes('src') ? ['src'] : []), + ...entries.filter((entry) => entry.startsWith('agent-bundle.config.')), + ...(options.extraPaths ?? []), + ]) { + const path = projectPath(projectRoot, candidate); + candidates.set(path.relative, path.absolute); + } + + const existing: readonly [string, string][] = ( + await Promise.all( + [...candidates].map(async ([relativePath, absolute]) => ( + await pathExists(absolute) ? [relativePath, absolute] as const : undefined + )), + ) + ).filter((entry): entry is [string, string] => entry !== undefined); + if (existing.length === 0) { + throw deletedSourceError('No project source existed to remove.', { + details: [`project root: ${projectRoot}`], + recovery: 'Build the artifact while the project source still exists, then remove that source exactly once.', + }); + } + + try { + await Promise.all(existing.map(([, absolute]) => rm(absolute, { force: true, recursive: true }))); + } catch (error) { + throw deletedSourceError('Project source could not be removed.', { + cause: error, + details: [`project root: ${projectRoot}`], + recovery: 'Make every source path writable and retry the deletion.', + }); + } + + const removed = Object.freeze(existing.map(([relativePath]) => relativePath).sort()); + await verifyDeletedSourceReceipt({ projectRoot, removed }); + return Object.freeze({ projectRoot, removed }); +}; + /** * Spawns a built stdio MCP entry and connects a real MCP client to it. * @@ -81,6 +283,13 @@ const loadSdk = async (): Promise => { export const openPackedMcpServer = async ( options: PackedMcpSessionOptions, ): Promise => { + if (options.deletedSource !== undefined) { + verifyDeletedSourceSessionBinding(options.deletedSource, options.entry, options.cwd); + await verifyDeletedSourceReceipt(options.deletedSource); + } + const proofLevel = options.deletedSource === undefined + ? PACKED_STDIO_PROOF_LEVEL + : PACKED_DELETED_SOURCE_PROOF_LEVEL; const sdk = await loadSdk(); const client = new sdk.Client({ name: options.name ?? 'agent-bundle-packed-proof', version: '1.0.0' }); const transport = new sdk.StdioClientTransport({ @@ -102,7 +311,7 @@ export const openPackedMcpServer = async ( throw new AgentTestError('packed-unavailable', 'The packed stdio MCP server did not start.', { cause: error, details: [ - `proof level: ${PACKED_STDIO_PROOF_LEVEL}`, + `proof level: ${proofLevelLabel(proofLevel)}`, `entry: ${options.entry}`, `cause: ${error instanceof Error ? error.message : String(error)}`, `server stderr:${captured === '' ? ' (empty)' : `\n${captured}`}`, @@ -113,7 +322,10 @@ export const openPackedMcpServer = async ( const provenance: PackedMcpProvenance = Object.freeze({ entry: options.entry, pid: transport.pid ?? undefined, - proofLevel: PACKED_STDIO_PROOF_LEVEL, + proofLevel, + ...(options.deletedSource === undefined + ? {} + : { sourceRemoved: Object.freeze([...options.deletedSource.removed]) }), }); let closed = false; const close = async (): Promise => { diff --git a/packages/agent-bundle/tests/packed-deleted-source.test.ts b/packages/agent-bundle/tests/packed-deleted-source.test.ts new file mode 100644 index 000000000..75a47af03 --- /dev/null +++ b/packages/agent-bundle/tests/packed-deleted-source.test.ts @@ -0,0 +1,128 @@ +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { describe, expect, it } from '@rstest/core'; + +import { + AgentTestError, + openPackedMcpServer, + proofLevelLabel, + removeProjectSource, + type DeletedSourceReceipt, +} from '../src/test/index.ts'; + +describe('deleted-source artifact evidence', () => { + it('removes conventional project source and returns a frozen relative receipt', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-deleted-source-')); + try { + await mkdir(join(projectRoot, 'src')); + await Promise.all([ + writeFile(join(projectRoot, 'agent-bundle.config.ts'), 'export default {};\n'), + writeFile(join(projectRoot, 'src', 'x.ts'), 'export const x = true;\n'), + ]); + + const receipt = await removeProjectSource({ projectRoot }); + + expect(receipt).toEqual({ + projectRoot, + removed: ['agent-bundle.config.ts', 'src'], + }); + expect(Object.isFrozen(receipt)).toBe(true); + expect(Object.isFrozen(receipt.removed)).toBe(true); + await expect(access(join(projectRoot, 'agent-bundle.config.ts'))).rejects.toThrow(); + await expect(access(join(projectRoot, 'src'))).rejects.toThrow(); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + it('refuses to mint evidence when no project source existed', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-deleted-source-empty-')); + try { + const error = await removeProjectSource({ projectRoot }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).code).toBe('deleted-source-unverified'); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + it('rejects a stale receipt before spawning the entry', async () => { + const projectRoot = await mkdtemp(join(tmpdir(), 'agent-bundle-deleted-source-stale-')); + try { + await mkdir(join(projectRoot, 'src')); + const deletedSource: DeletedSourceReceipt = Object.freeze({ + projectRoot, + removed: Object.freeze(['src']), + }); + const error = await openPackedMcpServer({ + deletedSource, + entry: join(projectRoot, 'does-not-exist.mjs'), + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).code).toBe('deleted-source-unverified'); + expect((error as AgentTestError).message).toContain(proofLevelLabel('packed-deleted-source')); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + it('rejects a receipt from another project before spawning the entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-deleted-source-mismatch-')); + const projectA = join(root, 'project-a'); + const projectB = join(root, 'project-b'); + try { + await Promise.all([ + mkdir(join(projectA, 'src'), { recursive: true }), + mkdir(join(projectB, 'artifact'), { recursive: true }), + mkdir(join(projectB, 'src'), { recursive: true }), + ]); + const deletedSource = await removeProjectSource({ projectRoot: projectA }); + const entry = join(projectB, 'artifact', 'entry.mjs'); + + const error = await openPackedMcpServer({ + deletedSource, + entry, + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).code).toBe('deleted-source-unverified'); + expect((error as AgentTestError).message).toContain('does not belong to the deleted-source receipt project'); + expect((error as AgentTestError).message).toContain(projectA); + expect((error as AgentTestError).message).toContain(entry); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it('rejects an explicit cwd outside the receipt project before spawning the entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-deleted-source-cwd-mismatch-')); + const projectA = join(root, 'project-a'); + const projectB = join(root, 'project-b'); + try { + await Promise.all([ + mkdir(join(projectA, 'src'), { recursive: true }), + mkdir(projectB, { recursive: true }), + ]); + const deletedSource = await removeProjectSource({ projectRoot: projectA }); + const entry = join(projectA, 'artifact', 'entry.mjs'); + + const error = await openPackedMcpServer({ + cwd: projectB, + deletedSource, + entry, + }).catch((thrown: unknown) => thrown); + + expect(error).toBeInstanceOf(AgentTestError); + expect((error as AgentTestError).code).toBe('deleted-source-unverified'); + expect((error as AgentTestError).message).toContain('does not belong to the deleted-source receipt project'); + expect((error as AgentTestError).message).toContain(projectA); + expect((error as AgentTestError).message).toContain(projectB); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/agent-bundle/tests/packed-stdio-projection.test.ts b/packages/agent-bundle/tests/packed-stdio-projection.test.ts index 80bc89795..7f7ef345a 100644 --- a/packages/agent-bundle/tests/packed-stdio-projection.test.ts +++ b/packages/agent-bundle/tests/packed-stdio-projection.test.ts @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; import { expect, it } from '@rstest/core'; -import { openPackedMcpServer } from '../src/test/packed.ts'; +import { openPackedMcpServer, removeProjectSource } from '../src/test/packed.ts'; import { cachedNpmInstallArguments, installedEnvironment, sharedPackedTarball } from './support/shared-pack.ts'; const execFile = promisify(executeFile); @@ -17,21 +17,22 @@ interface McpJson { } /** - * The `packed-stdio` proof level, and the repository's single packed proof - * journey for the consumer test harness (#103 stage 2 cost rule). + * The `packed-deleted-source` proof level, and the repository's single packed + * proof journey for the consumer test harness (#103 cost rule). * * One tarball (the run-level shared pack), one install, one artifact build, - * one spawned server. Every per-route assertion runs inside that one client - * session, because a second spawn would double the only cost this level has - * and prove the same thing twice. + * one verified source removal, one spawned server. Every per-route assertion + * runs inside that one client session, because a second spawn would double + * the only cost this level has and prove the same thing twice. * - * This is the only harness level that is process evidence: the generated - * entry runs as a separate operating-system process, out of a built artifact, - * over real stdio framing. The `mcp-in-memory` level (tests/projection/) - * covers the same protocol surface at a fraction of the cost and explicitly - * does not claim any of this. + * The generated entry runs as a separate operating-system process, out of a + * built artifact, over real stdio framing after the project source and config + * are verified absent. Its MCP App resource therefore comes from the inline + * artifact registry, not the deleted source tree. The `mcp-in-memory` level + * (tests/projection/) covers the same route protocol surface at a fraction of + * the cost and explicitly does not claim any of this. */ -it('serves every compiled route from a packed install over real stdio', async () => { +it('serves every compiled route and embedded App after packed consumer source deletion', async () => { const [agentBundle, runtime] = await Promise.all([ sharedPackedTarball('agent-bundle'), sharedPackedTarball('runtime'), @@ -63,14 +64,26 @@ it('serves every compiled route from a packed install over real stdio', async () // Claude Code expands ${CLAUDE_PLUGIN_ROOT} to the installed plugin root // before it spawns the server; the test stands in for the host there. const entry = manifest.mcpServers['harness']!.args[0].replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot); + const deletedSource = await removeProjectSource({ projectRoot: project }); - await using session = await openPackedMcpServer({ cwd: project, entry, env: installedEnvironment() as Record }); + await using session = await openPackedMcpServer({ + cwd: project, + deletedSource, + entry, + env: installedEnvironment() as Record, + }); - expect(session.provenance.proofLevel).toBe('packed-stdio'); + expect(session.provenance.proofLevel).toBe('packed-deleted-source'); expect(session.provenance.pid).toBeGreaterThan(0); + expect(session.provenance.sourceRemoved).toEqual(['agent-bundle.config.ts', 'src']); const tools = await session.client.listTools(); expect(tools.tools.map((tool) => tool.name).sort()).toEqual(['catalog', 'echo', 'unavailable']); + const resources = await session.client.listResources(); + expect(resources.resources).toEqual(expect.arrayContaining([ + expect.objectContaining({ mimeType: 'text/markdown', uri: 'harness://notes' }), + expect.objectContaining({ mimeType: 'text/html;profile=mcp-app', uri: 'ui://harness/panel' }), + ])); // Per-route assertions iterate inside this one session: the packed cost is // the spawn, not the calls. @@ -92,6 +105,20 @@ it('serves every compiled route from a packed install over real stdio', async () await expect(session.client.readResource({ uri: 'harness://notes' })).resolves.toEqual({ contents: [{ mimeType: 'text/markdown', text: '# Notes for harness://notes', uri: 'harness://notes' }], }); + const panel = await session.client.readResource({ uri: 'ui://harness/panel' }); + expect(panel.contents).toHaveLength(1); + const panelContent = panel.contents[0]; + expect(panelContent).toMatchObject({ + mimeType: 'text/html;profile=mcp-app', + uri: 'ui://harness/panel', + }); + if (panelContent === undefined || !('text' in panelContent)) { + throw new TypeError('The embedded panel resource did not return inline text.'); + } + const panelHtml = panelContent.text; + expect(panelHtml).toContain('route-harness panel'); + expect(panelHtml).toMatch(/]+(?:src|href)=/iu); await expect(session.client.getPrompt({ arguments: { note: 'chapter one' }, name: 'summarize' })).resolves.toEqual({ messages: [{ content: { text: 'Summarize chapter one', type: 'text' }, role: 'user' }], }); diff --git a/packages/agent-bundle/tests/support/shared-pack.ts b/packages/agent-bundle/tests/support/shared-pack.ts index c4a4e7d54..1d3347681 100644 --- a/packages/agent-bundle/tests/support/shared-pack.ts +++ b/packages/agent-bundle/tests/support/shared-pack.ts @@ -23,6 +23,26 @@ export interface SharedPack { export type SharedPackPackage = 'agent-bundle' | 'create-agent-bundle' | 'runtime'; +const packOutputFromJson = (stdout: string): SharedPackOutput => { + const parsed: unknown = JSON.parse(stdout); + const entries = Array.isArray(parsed) + ? parsed + : parsed !== null && typeof parsed === 'object' + ? Object.values(parsed) + : undefined; + if (entries === undefined) { + throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.'); + } + if (entries.length !== 1) { + throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`); + } + const [entry] = entries; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.'); + } + return entry as SharedPackOutput; +}; + /** * NODE_PATH-free environment with per-command npm cache and tmp roots under * the worker's RSTEST_WORKER_ID directory (see rstest.worker-isolation.ts), @@ -81,7 +101,7 @@ const packOnce = async (packageName: SharedPackPackage): Promise => cwd: join(workspaceRoot, 'packages', packageName === 'runtime' ? 'rsc-runtime' : packageName), env: { ...installedEnvironment(), NODE_ENV: 'production' }, }); - const [packOutput] = JSON.parse(stdout) as [SharedPackOutput]; + const packOutput = packOutputFromJson(stdout); return { packOutput, tarball: join(destination, packOutput.filename) }; }; diff --git a/packages/agent-bundle/tests/test-harness-manifest.test.ts b/packages/agent-bundle/tests/test-harness-manifest.test.ts index 2eaaed95a..38a9dc359 100644 --- a/packages/agent-bundle/tests/test-harness-manifest.test.ts +++ b/packages/agent-bundle/tests/test-harness-manifest.test.ts @@ -40,6 +40,12 @@ const withRealmRegistry = async (registry: unknown, body: () => T | Promise { + it('names packed-deleted-source as verified self-contained artifact evidence', () => { + expect(proofLevelLabel('packed-deleted-source')).toBe( + '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)', + ); + }); + it('names browser-app as compiled browser evidence without overstating host or artifact proof', () => { expect(proofLevelLabel('browser-app')).toBe( '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)', diff --git a/scripts/audit-packed-release.mjs b/scripts/audit-packed-release.mjs index 4ae1ea5a9..f70731255 100644 --- a/scripts/audit-packed-release.mjs +++ b/scripts/audit-packed-release.mjs @@ -28,6 +28,24 @@ const asString = (value, message) => { return value; }; +const packOutputFromJson = (stdout) => { + const parsed = JSON.parse(stdout); + const entries = Array.isArray(parsed) + ? parsed + : parsed !== null && typeof parsed === 'object' + ? Object.values(parsed) + : undefined; + if (entries === undefined) fail('npm pack --json returned neither an array nor a package-keyed object'); + if (entries.length !== 1) { + fail(`npm pack --json returned ${String(entries.length)} entries; expected exactly one`); + } + const [entry] = entries; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + fail('npm pack --json returned an invalid pack entry; expected one object'); + } + return entry; +}; + /** Every name -> Set(version) reachable under the consumer's own node_modules tree. */ const collectInstalledPackages = async (nodeModulesRoot) => { const installed = new Map(); @@ -151,7 +169,7 @@ const auditPackedRelease = async () => { version: '1.0.0', }) + '\n'); - const [{ filename }] = JSON.parse((await execNpm([ + const { filename } = packOutputFromJson((await execNpm([ 'pack', '--json', '--pack-destination', tarballs, diff --git a/scripts/run-packed-tests.mjs b/scripts/run-packed-tests.mjs index 8648ac8ed..daf04c5ba 100644 --- a/scripts/run-packed-tests.mjs +++ b/scripts/run-packed-tests.mjs @@ -21,6 +21,26 @@ const { NODE_PATH: _nodePath, ...environment } = process.env; const releasePool = process.argv.includes('--release'); const rstestArguments = process.argv.slice(2).filter((argument) => argument !== '--release'); +const packOutputFromJson = (stdout) => { + const parsed = JSON.parse(stdout); + const entries = Array.isArray(parsed) + ? parsed + : parsed !== null && typeof parsed === 'object' + ? Object.values(parsed) + : undefined; + if (entries === undefined) { + throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.'); + } + if (entries.length !== 1) { + throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`); + } + const [entry] = entries; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.'); + } + return entry; +}; + const run = (command, args, extraEnvironment = {}) => new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, args, { cwd: repositoryRoot, @@ -47,7 +67,7 @@ try { cwd: join(repositoryRoot, 'packages', directory), env: { ...environment, NODE_ENV: 'production' }, }); - const [packOutput] = JSON.parse(stdout); + const packOutput = packOutputFromJson(stdout); await writeFile( join(packDirectory, `${packageName}.json`), `${JSON.stringify({ packOutput, tarball: join(packDirectory, packOutput.filename) })}\n`,