From 4046b94d172f18634cb98fcc000e551efe3791c5 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 22:26:03 +0000 Subject: [PATCH 1/2] feat(test): packed deleted-source proof level for the consumer harness (#103 stage 4) The artifact-harness stage: removeProjectSource returns a verified removal receipt, openPackedMcpServer({ deletedSource }) re-verifies absence before spawn and only then upgrades provenance to packed-deleted-source, and the repository's single packed journey now deletes the consumer's source after its one build and proves the generated server still serves every route plus its embedded MCP App resource. Pack scripts accept npm 12's package-keyed `npm pack --json` output alongside npm 11's array form. --- .changeset/packed-deleted-source-proof.md | 16 ++ packages/agent-bundle/README.md | 12 +- .../src/mcp/harness/apps/panel.tsx | 9 +- packages/agent-bundle/src/test/errors.ts | 1 + packages/agent-bundle/src/test/index.ts | 18 +- packages/agent-bundle/src/test/manifest.ts | 18 +- packages/agent-bundle/src/test/packed.ts | 175 +++++++++++++++++- .../tests/packed-deleted-source.test.ts | 72 +++++++ .../tests/packed-stdio-projection.test.ts | 55 ++++-- .../agent-bundle/tests/support/shared-pack.ts | 22 ++- .../tests/test-harness-manifest.test.ts | 6 + scripts/audit-packed-release.mjs | 20 +- scripts/run-packed-tests.mjs | 22 ++- 13 files changed, 411 insertions(+), 35 deletions(-) create mode 100644 .changeset/packed-deleted-source-proof.md create mode 100644 packages/agent-bundle/tests/packed-deleted-source.test.ts 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..a03d58906 100644 --- a/packages/agent-bundle/src/test/packed.ts +++ b/packages/agent-bundle/src/test/packed.ts @@ -13,17 +13,34 @@ * 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. */ +export interface DeletedSourceReceipt { + /** Absolute project root against which every removed path was verified. */ + 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 +48,8 @@ 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 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 +89,147 @@ 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 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 +241,10 @@ const loadSdk = async (): Promise => { export const openPackedMcpServer = async ( options: PackedMcpSessionOptions, ): Promise => { + if (options.deletedSource !== undefined) 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 +266,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 +277,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..4cef21caf --- /dev/null +++ b/packages/agent-bundle/tests/packed-deleted-source.test.ts @@ -0,0 +1,72 @@ +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 }); + } + }); +}); 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`, From ee6e6e9d8151f139001a0cba45ab4ce175f4ff20 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 22:41:42 +0000 Subject: [PATCH 2/2] fix(test): bind deleted-source receipts to the launched session A receipt from project A could upgrade a session spawning project B's entry (B's source intact) to packed-deleted-source. The entry must now be a descendant of the receipt's project root and any explicit cwd must stay inside it, verified fail-closed before the SDK loads or anything spawns. --- packages/agent-bundle/src/test/packed.ts | 53 ++++++++++++++++-- .../tests/packed-deleted-source.test.ts | 56 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/test/packed.ts b/packages/agent-bundle/src/test/packed.ts index a03d58906..a510c4bd3 100644 --- a/packages/agent-bundle/src/test/packed.ts +++ b/packages/agent-bundle/src/test/packed.ts @@ -25,9 +25,13 @@ import { proofLevelLabel, } from './manifest.ts'; -/** Verified project-relative paths removed before a packed entry is spawned. */ +/** + * 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 project root against which every removed path was verified. */ + /** 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[]; @@ -48,7 +52,11 @@ 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 before this session may claim deleted-source proof. */ + /** + * 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; @@ -138,6 +146,40 @@ const projectPath = ( }; }; +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.', { @@ -241,7 +283,10 @@ export const removeProjectSource = async (options: { export const openPackedMcpServer = async ( options: PackedMcpSessionOptions, ): Promise => { - if (options.deletedSource !== undefined) await verifyDeletedSourceReceipt(options.deletedSource); + 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; diff --git a/packages/agent-bundle/tests/packed-deleted-source.test.ts b/packages/agent-bundle/tests/packed-deleted-source.test.ts index 4cef21caf..75a47af03 100644 --- a/packages/agent-bundle/tests/packed-deleted-source.test.ts +++ b/packages/agent-bundle/tests/packed-deleted-source.test.ts @@ -69,4 +69,60 @@ describe('deleted-source artifact evidence', () => { 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 }); + } + }); });