Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/secure-installed-test-harness.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 57 additions & 23 deletions packages/agent-bundle/src/test/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -923,6 +926,10 @@ interface LifecycleEvidence {
readonly orderFailure?: string;
}

type ClientNotificationHandler = (
...arguments_: readonly unknown[]
) => void | Promise<void>;

const executeLifecycleTransitions = async (
client: Client,
descriptor: TestableRouteDescriptor,
Expand All @@ -949,21 +956,48 @@ const executeLifecycleTransitions = async (
}

const byPhase = new Map<ContractLifecyclePhase, LifecyclePhaseEvidence>();
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<string, ClientNotificationHandler>;
}
)._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 };
};
Expand Down Expand Up @@ -1502,7 +1536,7 @@ export const runInstalledHostContractMatrix = async (
): Promise<InstalledHostContractMatrixReport> => {
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,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/test/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -28,6 +29,7 @@ export {
PACKED_DELETED_SOURCE_PROOF_LEVEL,
PACKED_STDIO_PROOF_LEVEL,
ROUTE_UNIT_PROOF_LEVEL,
SIMULATED_PROOF_LEVEL,
compileTestManifest,
proofLevelLabel,
testManifestFromRouteGraph,
Expand Down
65 changes: 37 additions & 28 deletions packages/agent-bundle/src/test/installed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ 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';
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 =
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -168,12 +170,15 @@ const fileHash = async (path: string): Promise<string | undefined> => {
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.',
},
);
Expand Down Expand Up @@ -263,6 +268,9 @@ export const openInstalledHostMcpServer = async (
): Promise<InstalledHostMcpSession> => {
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<typeof parseArtifactManifest> | undefined;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -348,32 +353,36 @@ 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;
if (await fileHash(join(installedRoot, path)) === undefined) {
failures.push({ check: 'hook-commands', reason: `installed hook command target ${path} was missing` });
}
}
} catch {
failures.push({ check: 'hook-commands', reason: 'artifact hook index was unavailable or invalid' });
}

const mcpDocument = await readJsonRecord(
Expand Down Expand Up @@ -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],
Expand All @@ -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(
Expand Down Expand Up @@ -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<void> => {
Expand Down
6 changes: 6 additions & 0 deletions packages/agent-bundle/src/test/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand All @@ -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;

/**
Expand All @@ -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: {
Expand Down
Loading
Loading