Skip to content

Commit 75c5f46

Browse files
fix(manifest): host documents project the launch record — kinds, order, transport, pointer (AB6017); install re-measures mode; one portable path rule (#604 follow-up) (#650)
* fix(manifest): launch entries are the rows their server kind compiles to; host documents agree with the launch record; install re-measures bytes and mode; one portable path rule for files[] and receipts * mcp run: host document must project every launchable manifest server; drop manifest-only fallback * chore: deslop the Pass 8 fix delta * fix(validate-artifact): host documents project the launch record in order, over stdio, from the manifest's own pointer (AB6017); schema device-name parity cases * changeset: manifest launch agreement follow-up
1 parent 412bfce commit 75c5f46

21 files changed

Lines changed: 440 additions & 161 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agent-bundle": patch
3+
---
4+
5+
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)

‎docs/diagnostics.md‎

Lines changed: 3 additions & 3 deletions
Large diffs are not rendered by default.

‎packages/agent-bundle/schemas/agent-bundle.manifest.schema.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@
118118
},
119119
"relativePath": {
120120
"type": "string",
121-
"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.",
122-
"pattern": "^(?![A-Za-z]:)(?:(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+/)*(?!\\.{1,2}(?:/|$))[^/\\\\\\u0000]+$"
121+
"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.",
122+
"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<>:\"|?*. ]$"
123123
},
124124
"nonNegativeSafeInteger": {
125125
"type": "integer",

‎packages/agent-bundle/src/build/manifest.ts‎

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
RouteInputSchemaLiteral,
2121
} from '../routes/types.ts';
2222
import {
23+
artifactManifestFileKinds,
2324
artifactManifestName,
2425
artifactManifestVersion,
2526
mcpServerKinds,
@@ -30,7 +31,9 @@ import {
3031
requireLaunchFiles,
3132
requireLaunchReferences,
3233
requireManifestVersion,
34+
type ArtifactManifestFileKind,
3335
type ArtifactManifestLaunch,
36+
type ArtifactManifestServerLaunch,
3437
type ArtifactManifestLaunchArgument,
3538
type WebManifest,
3639
} from '../web-host/manifest.ts';
@@ -63,7 +66,7 @@ export type { ArtifactManifestLaunch, ArtifactManifestLaunchArgument };
6366
export { artifactManifestName, artifactManifestVersion };
6467
export const artifactCompilerRecordVersion = 1;
6568

66-
export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt';
69+
export type { ArtifactManifestFileKind };
6770
export type ArtifactManifestValidationStatus = 'passed';
6871

6972
export interface ArtifactManifestSourceInput {
@@ -622,16 +625,14 @@ const parseFiles = (value: unknown): readonly ArtifactManifestFile[] => {
622625
if (!Number.isSafeInteger(file.bytes) || (file.bytes as number) < 0) {
623626
fail(`files[${index}].bytes must be a non-negative safe integer.`);
624627
}
625-
if (file.kind !== 'bundle' && file.kind !== 'copy' && file.kind !== 'generated' && file.kind !== 'prebuilt') {
626-
fail(`files[${index}].kind is unknown.`);
627-
}
628+
const kind = requireOneOf(file.kind, `files[${index}].kind`, artifactManifestFileKinds);
628629
if (file.mode !== undefined && (!Number.isSafeInteger(file.mode) || (file.mode as number) < 0 || (file.mode as number) > 0o777)) {
629630
fail(`files[${index}].mode must be an integer from 0 through 0777.`);
630631
}
631632
const path = parseArtifactFilePath(file.path, `files[${index}].path`);
632633
return {
633634
bytes: file.bytes as number,
634-
kind: file.kind as ArtifactManifestFileKind,
635+
kind,
635636
...(file.mode === undefined ? {} : { mode: file.mode as number }),
636637
path,
637638
sha256: requireHash(file.sha256, `files[${index}].sha256`),
@@ -1239,14 +1240,14 @@ const parseMcpApps = (value: unknown, location: string): readonly ArtifactManife
12391240
const parseMcpServers = (
12401241
value: unknown,
12411242
hosts: ReadonlySet<string>,
1242-
launches: ReadonlyMap<string, ArtifactManifestLaunch>,
1243+
launches: ReadonlyMap<string, ArtifactManifestServerLaunch>,
12431244
): readonly ArtifactManifestMcpServer[] => {
12441245
const servers = requireArray(value, 'executables.mcpServers').map((candidate, index) => {
12451246
const location = `executables.mcpServers[${index}]`;
12461247
const server = requireRecord(candidate, location);
12471248
requireExactKeys(server, location, ['apps', 'hosts', 'id', 'kind', 'name', 'transport'], ['launch']);
12481249
const name = requireString(server.name, `${location}.name`);
1249-
const launch = launches.get(name);
1250+
const launch = launches.get(name)?.launch;
12501251
return {
12511252
apps: parseMcpApps(server.apps, `${location}.apps`),
12521253
hosts: parseHosts(server.hosts, `${location}.hosts`, hosts),
@@ -1418,8 +1419,11 @@ const referencedPaths = (manifest: {
14181419
return references;
14191420
};
14201421

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

14241428
const parseWeb = (value: unknown, servers: readonly ArtifactManifestMcpServer[]): WebManifest | undefined => {
14251429
if (value === undefined) return undefined;
@@ -1588,17 +1592,17 @@ const validateManifest = (value: unknown): ArtifactManifest => {
15881592
fail('distribution.channels lists "npm" exactly when compiler.project.packageName is present.');
15891593
}
15901594
const web = parseWeb(manifest.web, executables.mcpServers);
1591-
const filePaths = new Set(files.map((file) => file.path));
1595+
const fileKinds = new Map(files.map((file) => [file.path, file.kind]));
15921596
for (const [index, payload] of distribution.payloads.entries()) {
15931597
const prefix = `${payload.name}/`;
15941598
if (!files.some((file) => file.kind === 'prebuilt' && file.path.startsWith(prefix))) {
15951599
fail(`distribution.payloads[${index}].name names a directory with no prebuilt manifest file.`);
15961600
}
15971601
}
15981602
for (const [location, path] of referencedPaths({ distribution, executables, projections })) {
1599-
if (!filePaths.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`);
1603+
if (!fileKinds.has(path)) fail(`${location} names ${JSON.stringify(path)}, which is not a manifest file.`);
16001604
}
1601-
requireLaunchFiles(launchesOf(executables.mcpServers), filePaths);
1605+
requireLaunchFiles(launchesOf(executables.mcpServers), fileKinds);
16021606

16031607
return {
16041608
application,

‎packages/agent-bundle/src/build/validate-artifact-mcp.ts‎

Lines changed: 109 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import { DiagnosticError, type Diagnostic } from '../core/diagnostics.ts';
55
import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';
66
import { classifyMcpArtifactArgument } from '../services/mcp-artifact-reference.ts';
77
import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts';
8-
import { readTargetMcpServers } from '../services/mcp-runtime.ts';
8+
import { readTargetMcpServers, type ModernMcpServer } from '../services/mcp-runtime.ts';
99
import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries } from './artifact-diagnostics.ts';
1010
import { readFileString, runWithPlatform } from '../effect/platform.ts';
1111
import { isDirectOutputLayoutPath, matchesManifestFile } from './artifact-layout.ts';
1212
import type { ValidatedArtifactMcpServerEvidence } from './artifact-validation-types.ts';
1313
import type { ArtifactFile, ManifestFile } from './emit.ts';
14-
import type { ArtifactManifest } from './manifest.ts';
14+
import type { ArtifactManifest, ArtifactManifestMcpServer } from './manifest.ts';
1515

1616
const mcpArtifactPathApi = process.platform === 'win32'
1717
? Object.freeze({
@@ -113,6 +113,74 @@ const validateMcpArtifactReference = (options: {
113113
return Object.freeze(diagnostics);
114114
};
115115

116+
/**
117+
* A host document's server starts the bytes the manifest's launch record of
118+
* the same name names, in the record's order: the first artifact-local path
119+
* the document's command and arguments name is the record's entry (Node's
120+
* script operand), and the record's `artifact` arguments follow it in order.
121+
* Otherwise `mcp run` (host document) and `<plugin> web` (manifest record)
122+
* would launch different files under one server name. The document may
123+
* reference more — an adapter's flags, an author's bare relative argument that
124+
* is a `literal` in the record — and each such reference is validated on its
125+
* own above. A document server the record starts but the document reaches
126+
* over another transport is the same disagreement.
127+
*/
128+
const validateLaunchAgreement = (options: {
129+
readonly declared: ArtifactManifestMcpServer | undefined;
130+
readonly kind: ModernMcpServer['kind'];
131+
readonly launchPaths: readonly string[];
132+
readonly manifestPath: string;
133+
readonly server: string;
134+
readonly target: string;
135+
}): readonly Diagnostic[] => {
136+
const launch = options.declared?.launch;
137+
if (launch === undefined) return Object.freeze([]);
138+
const disagreement = (detail: string): readonly Diagnostic[] => Object.freeze([diagnostic(
139+
'AB6017',
140+
`MCP server ${JSON.stringify(options.server)} in target ${JSON.stringify(options.target)} ${detail}`,
141+
options.manifestPath,
142+
options.target,
143+
)]);
144+
if (options.kind !== 'stdio') {
145+
return disagreement(`is a ${options.kind} server in the target document, but its manifest launch record starts it over stdio.`);
146+
}
147+
const [first, ...following] = options.launchPaths;
148+
if (first !== launch.entry) {
149+
return disagreement(
150+
`starts ${first === undefined ? 'no artifact file' : JSON.stringify(first)} in the target document, ` +
151+
`but its manifest launch record starts ${JSON.stringify(launch.entry)}.`,
152+
);
153+
}
154+
let cursor = 0;
155+
for (const argument of launch.args) {
156+
if (argument.kind !== 'artifact') continue;
157+
const index = following.indexOf(argument.path, cursor);
158+
if (index === -1) {
159+
return disagreement(
160+
`does not pass ${JSON.stringify(argument.path)} after ${JSON.stringify(launch.entry)} in the order of its manifest ` +
161+
`launch record; the target document names ${JSON.stringify(options.launchPaths)}.`,
162+
);
163+
}
164+
cursor = index + 1;
165+
}
166+
return Object.freeze([]);
167+
};
168+
169+
const validateDeclaredServersPresent = (options: {
170+
readonly documentServers: ReadonlySet<string>;
171+
readonly manifestPath: string;
172+
readonly servers: readonly ArtifactManifestMcpServer[];
173+
readonly target: string;
174+
}): readonly Diagnostic[] => Object.freeze(options.servers
175+
.filter((server) => server.launch !== undefined && server.hosts.includes(options.target) && !options.documentServers.has(server.name))
176+
.map((server) => diagnostic(
177+
'AB6017',
178+
`MCP server ${JSON.stringify(server.name)} is declared for target ${JSON.stringify(options.target)} with a launch record, ` +
179+
'but the target document names no such server.',
180+
options.manifestPath,
181+
options.target,
182+
)));
183+
116184
/**
117185
* Every selected host's MCP document lives in the one composite root and
118186
* names the shared compiled entries (`mcp/<server>.mjs`) the host's servers
@@ -136,12 +204,22 @@ export const validateMcpCoherence = async (options: {
136204
const compiledEntries = new Set<string>();
137205
const referencedAnywhere = new Set<string>();
138206

139-
for (const { host: targetName } of options.manifest.projections) {
140-
const target = { name: targetName };
207+
for (const projection of options.manifest.projections) {
208+
const target = { name: projection.host };
141209
if (!options.registry.has(target.name) || !options.registry.supports(target.name, 'mcp')) continue;
142210
const runtime = options.registry.mcpRuntime(target.name);
143211
if (runtime === undefined) continue;
144212
const manifestPath = runtime.manifestPath;
213+
const pointer = projection.documents.mcp;
214+
if ((pointer !== undefined || files.has(manifestPath)) && pointer !== manifestPath) {
215+
diagnostics.push(diagnostic(
216+
'AB6017',
217+
`projections[${JSON.stringify(target.name)}].documents.mcp ${pointer === undefined ? 'is absent' : `is ${JSON.stringify(pointer)}`}, ` +
218+
`but the target's MCP manifest is ${JSON.stringify(manifestPath)}.`,
219+
manifestPath,
220+
target.name,
221+
));
222+
}
145223
const mcpLayout = options.registry.artifactLayout(target.name).mcpEntries;
146224
const referenceCounts = new Map<string, McpReferenceOccurrence[]>();
147225
const mcpEntries = options.files.filter((file) => isDirectOutputLayoutPath(file.path, mcpLayout));
@@ -174,6 +252,12 @@ export const validateMcpCoherence = async (options: {
174252
target.name,
175253
));
176254
} else {
255+
diagnostics.push(...validateDeclaredServersPresent({
256+
documentServers: new Set(servers.servers.map((entry) => entry.name)),
257+
manifestPath,
258+
servers: options.manifest.executables.mcpServers,
259+
target: target.name,
260+
}));
177261
for (const entry of servers.servers) {
178262
let server = entry.server;
179263
try {
@@ -206,8 +290,17 @@ export const validateMcpCoherence = async (options: {
206290
}
207291
continue;
208292
}
209-
const entryPaths = new Set<string>();
293+
const declared = options.manifest.executables.mcpServers.find((row) => row.name === entry.name);
294+
const launchPaths: string[] = [];
210295
if (server.kind !== 'stdio') {
296+
diagnostics.push(...validateLaunchAgreement({
297+
declared,
298+
kind: server.kind,
299+
launchPaths,
300+
manifestPath,
301+
server: entry.name,
302+
target: target.name,
303+
}));
211304
options.mcpServers.push(Object.freeze({
212305
entryPaths: Object.freeze([]),
213306
kind: server.kind,
@@ -249,7 +342,7 @@ export const validateMcpCoherence = async (options: {
249342
if (commandReference.status === 'artifact-local') {
250343
recordMcpReference(referenceCounts, commandReference.path, { field: 'command', server: entry.name });
251344
referencedAnywhere.add(commandReference.path);
252-
entryPaths.add(commandReference.path);
345+
launchPaths.push(commandReference.path);
253346
}
254347
}
255348

@@ -273,11 +366,19 @@ export const validateMcpCoherence = async (options: {
273366
if (argumentReference.status === 'artifact-local') {
274367
recordMcpReference(referenceCounts, argumentReference.path, { field: 'argument', server: entry.name });
275368
referencedAnywhere.add(argumentReference.path);
276-
entryPaths.add(argumentReference.path);
369+
launchPaths.push(argumentReference.path);
277370
}
278371
}
372+
diagnostics.push(...validateLaunchAgreement({
373+
declared,
374+
kind: server.kind,
375+
launchPaths,
376+
manifestPath,
377+
server: entry.name,
378+
target: target.name,
379+
}));
279380
options.mcpServers.push(Object.freeze({
280-
entryPaths: Object.freeze([...entryPaths].sort((left, right) => left.localeCompare(right))),
381+
entryPaths: Object.freeze([...new Set(launchPaths)].sort((left, right) => left.localeCompare(right))),
281382
kind: server.kind,
282383
manifestPath,
283384
name: entry.name,

‎packages/agent-bundle/src/core/paths.ts‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,37 @@ export const isContainedRelativePath = (value: string): boolean =>
7878
!/^[a-z]:/iu.test(value) &&
7979
!value.split(/[/\\]/u).includes('..');
8080

81+
const windowsDeviceName = /^(?:con|prn|aux|nul|com[0-9¹²³]|lpt[0-9¹²³])(?:\.|$)/iu;
82+
83+
/**
84+
* One path segment every supported filesystem can hold and hand back unchanged:
85+
* non-empty, never `.` or `..`, no control character or Windows-reserved
86+
* character, not a Windows device name, and no trailing dot or space (which
87+
* Windows strips). The manifest's `files[]` rows and the installer's receipt
88+
* share this rule, so a manifest the parser accepts is one the installer can
89+
* inventory, copy, and own.
90+
*/
91+
export const isPortablePathSegment = (segment: string): boolean =>
92+
segment.length > 0 &&
93+
segment !== '.' &&
94+
segment !== '..' &&
95+
!/[<>:"|?*]/u.test(segment) &&
96+
[...segment].every((character) => character.charCodeAt(0) >= 0x20) &&
97+
!windowsDeviceName.test(segment) &&
98+
!segment.endsWith('.') &&
99+
!segment.endsWith(' ');
100+
81101
/**
82102
* The manifest's path rule: a non-empty POSIX path that is relative on every platform
83-
* (no leading `/`, no drive letter, no backslash, no NUL) and whose segments are
84-
* non-empty and never `.` or `..`, so the path means the same file wherever the root lands.
103+
* (no leading `/`, no drive letter, no backslash) whose every segment is portable,
104+
* so the path means the same file wherever the root lands.
85105
*/
86106
export const isRelocatablePosixPath = (path: string): boolean =>
87107
path.length > 0 &&
88108
!path.includes('\\') &&
89-
!path.includes('\0') &&
90109
!path.startsWith('/') &&
91110
!/^[a-z]:/iu.test(path) &&
92-
path.split('/').every((segment) => segment.length > 0 && segment !== '.' && segment !== '..');
111+
path.split('/').every(isPortablePathSegment);
93112

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

0 commit comments

Comments
 (0)