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
5 changes: 5 additions & 0 deletions .changeset/manifest-launch-agreement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Hold every host MCP document to the manifest's launch records: `agent-bundle build` and `validate-artifact` fail `AB6017` when a target document omits or renames a launchable `executables.mcpServers[]` server, reaches it over a non-stdio transport, starts an artifact file other than its `launch.entry` first, passes the record's `artifact` arguments out of order, or when `projections[host].documents.mcp` does not point at the target's MCP document. Both manifest readers now require a compiled server's `launch.entry` and `worker` to be `bundle` rows and a prebuilt server's entry a `prebuilt` row. `agent-bundle mcp run` launches the host document's line for the record of the same name and no longer falls back to the manifest record alone. `install` and `doctor` report `AB7001` when an indexed file's size or executable bit differs from its `files[]` row, not only its digest. One portable path-segment rule (`isPortablePathSegment`) governs `files[]` rows, the JSON Schema, and the install receipt. (#650)
6 changes: 3 additions & 3 deletions docs/diagnostics.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@
},
"relativePath": {
"type": "string",
"description": "Safe relative POSIX path from the artifact root: non-empty, no leading slash or drive prefix, no backslash or NUL, and no empty, `.`, or `..` segment.",
"pattern": "^(?![A-Za-z]:)(?:(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+/)*(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+$"
"description": "Safe relative POSIX path from the artifact root: non-empty, no leading slash or drive prefix, no backslash, and every segment portable — never empty, `.`, or `..`, no control or Windows-reserved character (`<>:\"|?*`), not a Windows device name (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`COM9`, `LPT1`–`LPT9`), and no trailing dot or space.",
"pattern": "^(?![A-Za-z]:)(?:(?!(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Mm][0-9¹²³]|[Ll][Pp][Tt][0-9¹²³])(?:\\.|/|$))[^/\\\\\\u0000-\\u001f<>:\"|?*]*[^/\\\\\\u0000-\\u001f<>:\"|?*. ]/)*(?!(?:[Cc][Oo][Nn]|[Pp][Rr][Nn]|[Aa][Uu][Xx]|[Nn][Uu][Ll]|[Cc][Oo][Mm][0-9¹²³]|[Ll][Pp][Tt][0-9¹²³])(?:\\.|/|$))[^/\\\\\\u0000-\\u001f<>:\"|?*]*[^/\\\\\\u0000-\\u001f<>:\"|?*. ]$"
},
"nonNegativeSafeInteger": {
"type": "integer",
Expand Down
28 changes: 16 additions & 12 deletions packages/agent-bundle/src/build/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
RouteInputSchemaLiteral,
} from '../routes/types.ts';
import {
artifactManifestFileKinds,
artifactManifestName,
artifactManifestVersion,
mcpServerKinds,
Expand All @@ -30,7 +31,9 @@ import {
requireLaunchFiles,
requireLaunchReferences,
requireManifestVersion,
type ArtifactManifestFileKind,
type ArtifactManifestLaunch,
type ArtifactManifestServerLaunch,
type ArtifactManifestLaunchArgument,
type WebManifest,
} from '../web-host/manifest.ts';
Expand Down Expand Up @@ -63,7 +66,7 @@ export type { ArtifactManifestLaunch, ArtifactManifestLaunchArgument };
export { artifactManifestName, artifactManifestVersion };
export const artifactCompilerRecordVersion = 1;

export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt';
export type { ArtifactManifestFileKind };
export type ArtifactManifestValidationStatus = 'passed';

export interface ArtifactManifestSourceInput {
Expand Down Expand Up @@ -622,16 +625,14 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => {
if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0) {
fail(`files[${index}].bytes must be a non-negative safe integer.`);
}
if (file.kind !== 'bundle' && file.kind !== 'copy' && file.kind !== 'generated' && file.kind !== 'prebuilt') {
fail(`files[${index}].kind is unknown.`);
}
const kind = requireOneOf(file.kind, `files[${index}].kind`, artifactManifestFileKinds);
if (file.mode !== undefined && (!Number.isSafeInteger(file.mode) || (file.mode as number) < 0 || (file.mode as number) > 0o777)) {
fail(`files[${index}].mode must be an integer from 0 through 0777.`);
}
const path = parseArtifactFilePath(file.path, `files[${index}].path`);
return {
bytes: file.bytes as number,
kind: file.kind as ArtifactManifestFileKind,
kind,
...(file.mode === undefined ? {} : { mode: file.mode as number }),
path,
sha256: requireHash(file.sha256, `files[${index}].sha256`),
Expand Down Expand Up @@ -1239,14 +1240,14 @@ const parseMcpApps = (value: unknown, location: string): readonly ArtifactManife
const parseMcpServers = (
value: unknown,
hosts: ReadonlySet<string>,
launches: ReadonlyMap<string, ArtifactManifestLaunch>,
launches: ReadonlyMap<string, ArtifactManifestServerLaunch>,
): readonly ArtifactManifestMcpServer[] => {
const servers = requireArray(value, 'executables.mcpServers').map((candidate, index) => {
const location = `executables.mcpServers[${index}]`;
const server = requireRecord(candidate, location);
requireExactKeys(server, location, ['apps', 'hosts', 'id', 'kind', 'name', 'transport'], ['launch']);
const name = requireString(server.name, `${location}.name`);
const launch = launches.get(name);
const launch = launches.get(name)?.launch;
return {
apps: parseMcpApps(server.apps, `${location}.apps`),
hosts: parseHosts(server.hosts, `${location}.hosts`, hosts),
Expand Down Expand Up @@ -1418,8 +1419,11 @@ const referencedPaths = (manifest: {
return references;
};

const launchesOf = (servers: readonly ArtifactManifestMcpServer[]): ReadonlyMap<string, ArtifactManifestLaunch> =>
new Map(servers.flatMap((server) => server.launch === undefined ? [] : [[server.name, server.launch] as const]));
const launchesOf = (servers: readonly ArtifactManifestMcpServer[]): ReadonlyMap<string, ArtifactManifestServerLaunch> =>
new Map(servers.flatMap((server) =>
server.launch === undefined || (server.kind !== 'compiled' && server.kind !== 'prebuilt')
? []
: [[server.name, { kind: server.kind, launch: server.launch }] as const]));

const parseWeb = (value: unknown, servers: readonly ArtifactManifestMcpServer[]): WebManifest | undefined => {
if (value === undefined) return undefined;
Expand Down Expand Up @@ -1588,17 +1592,17 @@ const validateManifest = (value: unknown): ArtifactManifest => {
fail('distribution.channels lists "npm" exactly when compiler.project.packageName is present.');
}
const web = parseWeb(manifest.web, executables.mcpServers);
const filePaths = new Set(files.map((file) => file.path));
const fileKinds = new Map(files.map((file) => [file.path, file.kind]));
for (const [index, payload] of distribution.payloads.entries()) {
const prefix = `${payload.name}/`;
if (!files.some((file) => file.kind === 'prebuilt' && file.path.startsWith(prefix))) {
fail(`distribution.payloads[${index}].name names a directory with no prebuilt manifest file.`);
}
}
for (const [location, path] of referencedPaths({ distribution, executables, projections })) {
if (!filePaths.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`);
if (!fileKinds.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`);
}
requireLaunchFiles(launchesOf(executables.mcpServers), filePaths);
requireLaunchFiles(launchesOf(executables.mcpServers), fileKinds);

return {
application,
Expand Down
117 changes: 109 additions & 8 deletions packages/agent-bundle/src/build/validate-artifact-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts';
import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';
import { classifyMcpArtifactArgument } from '../services/mcp-artifact-reference.ts';
import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts';
import { readTargetMcpServers } from '../services/mcp-runtime.ts';
import { readTargetMcpServers, type ModernMcpServer } from '../services/mcp-runtime.ts';
import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts';
import { readFileString, runWithPlatform } from '../effect/platform.ts';
import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts';
import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts';
import type { ArtifactFile, ManifestFile } from './emit.ts';
import type { ArtifactManifest } from './manifest.ts';
import type { ArtifactManifest, ArtifactManifestMcpServer } from './manifest.ts';

const mcpArtifactPathApi = process.platform === 'win32'
? Object.freeze({
Expand Down Expand Up @@ -113,6 +113,74 @@ const validateMcpArtifactReference = (options: {
return Object.freeze(diagnostics);
};

/**
* A host document's server starts the bytes the manifest's launch record of
* the same name names, in the record's order: the first artifact-local path
* the document's command and arguments name is the record's entry (Node's
* script operand), and the record's `artifact` arguments follow it in order.
* Otherwise `mcp run` (host document) and `<plugin> web` (manifest record)
* would launch different files under one server name. The document may
* reference more — an adapter's flags, an author's bare relative argument that
* is a `literal` in the record — and each such reference is validated on its
* own above. A document server the record starts but the document reaches
* over another transport is the same disagreement.
*/
const validateLaunchAgreement = (options: {
readonly declared: ArtifactManifestMcpServer | undefined;
readonly kind: ModernMcpServer['kind'];
readonly launchPaths: readonly string[];
readonly manifestPath: string;
readonly server: string;
readonly target: string;
}): readonly Diagnostic[] => {
const launch = options.declared?.launch;
if (launch === undefined) return Object.freeze([]);
const disagreement = (detail: string): readonly Diagnostic[] => Object.freeze([diagnostic(
'AB6017',
`MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} ${detail}`,
options.manifestPath,
options.target,
)]);
if (options.kind !== 'stdio') {
return disagreement(`is a ${options.kind} server in the target document, but its manifest launch record starts it over stdio.`);
}
const [first, ...following] = options.launchPaths;
if (first !== launch.entry) {
return disagreement(
`starts ${first === undefined ? 'no artifact file' : JSON.stringify(first)} in the target document, ` +
`but its manifest launch record starts ${JSON.stringify(launch.entry)}.`,
);
}
let cursor = 0;
for (const argument of launch.args) {
if (argument.kind !== 'artifact') continue;
const index = following.indexOf(argument.path, cursor);
if (index === -1) {
return disagreement(
`does not pass ${JSON.stringify(argument.path)} after ${JSON.stringify(launch.entry)} in the order of its manifest ` +
`launch record; the target document names ${JSON.stringify(options.launchPaths)}.`,
);
}
cursor = index + 1;
}
return Object.freeze([]);
};

const validateDeclaredServersPresent = (options: {
readonly documentServers: ReadonlySet<string>;
readonly manifestPath: string;
readonly servers: readonly ArtifactManifestMcpServer[];
readonly target: string;
}): readonly Diagnostic[] => Object.freeze(options.servers
.filter((server) => server.launch !== undefined && server.hosts.includes(options.target) && !options.documentServers.has(server.name))
.map((server) => diagnostic(
'AB6017',
`MCP server ${JSON.stringify(server.name)} is declared for target ${JSON.stringify(options.target)} with a launch record, ` +
'but the target document names no such server.',
options.manifestPath,
options.target,
)));

/**
* Every selected host's MCP document lives in the one composite root and
* names the shared compiled entries (`mcp/<server>.mjs`) the host's servers
Expand All @@ -136,12 +204,22 @@ export const validateMcpCoherence = async (options: {
const compiledEntries = new Set<string>();
const referencedAnywhere = new Set<string>();

for (const { host: targetName } of options.manifest.projections) {
const target = { name: targetName };
for (const projection of options.manifest.projections) {
const target = { name: projection.host };
if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue;
const runtime = options.registry.mcpRuntime(target.name);
if (runtime === undefined) continue;
const manifestPath = runtime.manifestPath;
const pointer = projection.documents.mcp;
if ((pointer !== undefined || files.has(manifestPath)) && pointer !== manifestPath) {
diagnostics.push(diagnostic(
'AB6017',
`projections[${JSON.stringify(target.name)}].documents.mcp ${pointer === undefined ? 'is absent' : `is ${JSON.stringify(pointer)}`}, ` +
`but the target's MCP manifest is ${JSON.stringify(manifestPath)}.`,
manifestPath,
target.name,
));
}
const mcpLayout = options.registry.artifactLayout(target.name).mcpEntries;
const referenceCounts = new Map<string, McpReferenceOccurrence[]>();
const mcpEntries = options.files.filter((file) => isDirectOutputLayoutPath(file.path, mcpLayout));
Expand Down Expand Up @@ -174,6 +252,12 @@ export const validateMcpCoherence = async (options: {
target.name,
));
} else {
diagnostics.push(...validateDeclaredServersPresent({
documentServers: new Set(servers.servers.map((entry) => entry.name)),
manifestPath,
servers: options.manifest.executables.mcpServers,
target: target.name,
}));
for (const entry of servers.servers) {
let server = entry.server;
try {
Expand Down Expand Up @@ -206,8 +290,17 @@ export const validateMcpCoherence = async (options: {
}
continue;
}
const entryPaths = new Set<string>();
const declared = options.manifest.executables.mcpServers.find((row) => row.name === entry.name);
const launchPaths: string[] = [];
if (server.kind !== 'stdio') {
diagnostics.push(...validateLaunchAgreement({
declared,
kind: server.kind,
launchPaths,
manifestPath,
server: entry.name,
target: target.name,
}));
options.mcpServers.push(Object.freeze({
entryPaths: Object.freeze([]),
kind: server.kind,
Expand Down Expand Up @@ -249,7 +342,7 @@ export const validateMcpCoherence = async (options: {
if (commandReference.status === 'artifact-local') {
recordMcpReference(referenceCounts, commandReference.path, { field: 'command', server: entry.name });
referencedAnywhere.add(commandReference.path);
entryPaths.add(commandReference.path);
launchPaths.push(commandReference.path);
}
}

Expand All @@ -273,11 +366,19 @@ export const validateMcpCoherence = async (options: {
if (argumentReference.status === 'artifact-local') {
recordMcpReference(referenceCounts, argumentReference.path, { field: 'argument', server: entry.name });
referencedAnywhere.add(argumentReference.path);
entryPaths.add(argumentReference.path);
launchPaths.push(argumentReference.path);
}
}
diagnostics.push(...validateLaunchAgreement({
declared,
kind: server.kind,
launchPaths,
manifestPath,
server: entry.name,
target: target.name,
}));
options.mcpServers.push(Object.freeze({
entryPaths: Object.freeze([...entryPaths].sort((left, right) => left.localeCompare(right))),
entryPaths: Object.freeze([...new Set(launchPaths)].sort((left, right) => left.localeCompare(right))),
kind: server.kind,
manifestPath,
name: entry.name,
Expand Down
27 changes: 23 additions & 4 deletions packages/agent-bundle/src/core/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,18 +78,37 @@ export const isContainedRelativePath = (value: string): boolean =>
!/^[a-z]:/iu.test(value) &&
!value.split(/[/\\]/u).includes('..');

const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\.|$)/iu;

/**
* One path segment every supported filesystem can hold and hand back unchanged:
* non-empty, never `.` or `..`, no control character or Windows-reserved
* character, not a Windows device name, and no trailing dot or space (which
* Windows strips). The manifest's `files[]` rows and the installer's receipt
* share this rule, so a manifest the parser accepts is one the installer can
* inventory, copy, and own.
*/
export const isPortablePathSegment = (segment: string): boolean =>
segment.length > 0 &&
segment !== '.' &&
segment !== '..' &&
!/[<>:"|?*]/u.test(segment) &&
[...segment].every((character) => character.charCodeAt(0) >= 0x20) &&
!windowsDeviceName.test(segment) &&
!segment.endsWith('.') &&
!segment.endsWith(' ');

/**
* The manifest's path rule: a non-empty POSIX path that is relative on every platform
* (no leading `/`, no drive letter, no backslash, no NUL) and whose segments are
* non-empty and never `.` or `..`, so the path means the same file wherever the root lands.
* (no leading `/`, no drive letter, no backslash) whose every segment is portable,
* so the path means the same file wherever the root lands.
*/
export const isRelocatablePosixPath = (path: string): boolean =>
path.length > 0 &&
!path.includes('\\') &&
!path.includes('\0') &&
!path.startsWith('/') &&
!/^[a-z]:/iu.test(path) &&
path.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..');
path.split('/').every(isPortablePathSegment);

/** A normalized relative path that cannot traverse out of an artifact root. */
export const safeArtifactPath = (path: string): boolean =>
Expand Down
Loading
Loading