From c4b23938ed3a4582e095cbc451b777516eec93f1 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:37:01 +0000 Subject: [PATCH 1/2] refactor(agent-bundle): dedupe route, JSON, and IP-range helpers onto shared owners --- .changeset/ponytail-dedupe-helpers.md | 5 + packages/agent-bundle/src/app/index.ts | 16 +- .../agent-bundle/src/build/entry-exports.ts | 164 ++++-------------- packages/agent-bundle/src/config/command.ts | 3 +- .../agent-bundle/src/config/dev-contracts.ts | 5 +- .../src/config/notice-retention.ts | 7 +- .../src/config/render-markdown.ts | 3 +- packages/agent-bundle/src/config/rule.ts | 3 +- packages/agent-bundle/src/config/skill.ts | 6 +- packages/agent-bundle/src/core/special-ip.ts | 43 +++++ .../src/dev/artifacts/artifact-routes.ts | 25 +-- packages/agent-bundle/src/dev/epoch-store.ts | 14 +- .../agent-bundle/src/dev/eval/eval-routes.ts | 76 +++----- packages/agent-bundle/src/dev/http.ts | 14 ++ .../agent-bundle/src/dev/inspector-routes.ts | 25 +-- .../src/dev/logs/dev-log-routes.ts | 7 +- .../agent-bundle/src/dev/mcp-app-metadata.ts | 16 +- .../dev/mcp-apps/mcp-app-binding-service.ts | 38 +--- .../src/dev/mcp-apps/mcp-app-bridge.ts | 79 ++++----- .../src/dev/mcp-apps/mcp-app-host-profiles.ts | 100 +---------- .../src/dev/mcp-apps/mcp-app-sandbox.ts | 72 ++------ .../dev/playground/hook-playground-routes.ts | 16 +- .../dev/playground/host-discovery-routes.ts | 17 +- .../dev/playground/lifecycle-replay-routes.ts | 21 +-- .../src/dev/playground/mcp-probe-routes.ts | 21 +-- .../src/dev/playground/playground-routes.ts | 33 ++-- .../src/dev/runtime-mcp-registry.ts | 52 +----- .../claude-plugin-validation.ts | 4 +- .../cursor-plugin-validation.ts | 20 +-- .../portable-plugin-validation.ts | 4 +- packages/agent-bundle/src/install-entry.ts | 3 +- .../agent-bundle/src/mcp-server-runtime.ts | 9 +- packages/agent-bundle/src/mcp-tasks.ts | 11 +- packages/agent-bundle/src/test/packed.ts | 18 +- .../agent-bundle/tests/entry-shell.test.ts | 4 +- 35 files changed, 288 insertions(+), 666 deletions(-) create mode 100644 .changeset/ponytail-dedupe-helpers.md create mode 100644 packages/agent-bundle/src/core/special-ip.ts diff --git a/.changeset/ponytail-dedupe-helpers.md b/.changeset/ponytail-dedupe-helpers.md new file mode 100644 index 000000000..404341e5d --- /dev/null +++ b/.changeset/ponytail-dedupe-helpers.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Consolidate duplicated request, JSON, and IP-range helpers onto `dev/http.ts` (`responseJsonOrDestroy`, `badRequest`, `noQuery`), `core/strict-json.ts`, `core/errors.ts`, `core/paths.ts`, and a `net.BlockList`-backed `core/special-ip.ts`; detect entry `main`/default exports with the TypeScript parser instead of a hand-rolled tokenizer. Route diagnostics and responses are unchanged; the MCP App sandbox CSP host check now uses the IANA special-purpose registry, so the `192.31.196/24`, `192.52.193/24`, and `192.175.48/24` blocks are rejected and public space in `192.0/16`, `192.2/16`, `192.88/16`, and `198.51/16` outside the registry is accepted (#660) diff --git a/packages/agent-bundle/src/app/index.ts b/packages/agent-bundle/src/app/index.ts index cd707c2ad..435cc725b 100644 --- a/packages/agent-bundle/src/app/index.ts +++ b/packages/agent-bundle/src/app/index.ts @@ -203,8 +203,6 @@ const allowedErrorKeys = Object.freeze(['code', 'data', 'message']); const defaultTimeoutMs = 15_000; const maximumTimeoutMs = 2_147_483_647; -const hasOwn = (value: object, key: string): boolean => Object.hasOwn(value, key); - const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; @@ -255,17 +253,17 @@ const rpcMessage = (value: unknown): RpcMessage | undefined => { if (!isPlainDataRecord(snapshot) || snapshot.jsonrpc !== '2.0') return undefined; const keys = Object.keys(snapshot); if (keys.some((key) => !allowedMessageKeys.includes(key))) return undefined; - const hasMethod = hasOwn(snapshot, 'method'); - const hasResult = hasOwn(snapshot, 'result'); - const hasError = hasOwn(snapshot, 'error'); + const hasMethod = Object.hasOwn(snapshot, 'method'); + const hasResult = Object.hasOwn(snapshot, 'result'); + const hasError = Object.hasOwn(snapshot, 'error'); if (Number(hasMethod) + Number(hasResult) + Number(hasError) !== 1) return undefined; - if (hasOwn(snapshot, 'id') && !requestId(snapshot.id)) return undefined; + if (Object.hasOwn(snapshot, 'id') && !requestId(snapshot.id)) return undefined; if (hasMethod) { if (!nonempty(snapshot.method) || hasResult || hasError) return undefined; - if (hasOwn(snapshot, 'params') && !isPlainDataRecord(snapshot.params)) return undefined; + if (Object.hasOwn(snapshot, 'params') && !isPlainDataRecord(snapshot.params)) return undefined; return snapshot as unknown as RpcMessage; } - if (!hasOwn(snapshot, 'id') || hasOwn(snapshot, 'params')) return undefined; + if (!Object.hasOwn(snapshot, 'id') || Object.hasOwn(snapshot, 'params')) return undefined; if (hasError) { if (!isPlainDataRecord(snapshot.error)) return undefined; if ( @@ -702,7 +700,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient }, async rebind(rebindOptions: AppConnectOptions = {}): Promise { if (isDisposed) throw new AppClientError('disposed', 'The App client was disposed.'); - const nextOrigin = hasOwn(rebindOptions, 'targetOrigin') + const nextOrigin = Object.hasOwn(rebindOptions, 'targetOrigin') ? trustedOrigin(rebindOptions.targetOrigin) : configuredOrigin; connectionGeneration += 1; diff --git a/packages/agent-bundle/src/build/entry-exports.ts b/packages/agent-bundle/src/build/entry-exports.ts index a7172d5aa..71df0dd2f 100644 --- a/packages/agent-bundle/src/build/entry-exports.ts +++ b/packages/agent-bundle/src/build/entry-exports.ts @@ -1,154 +1,54 @@ import { readFile } from 'node:fs/promises'; +import ts from 'typescript-5'; + /** * Static entry-export detection for TypeScript/JavaScript entry modules. The * generated entry conventions only need two facts — "does this module export - * `main`" and "does this module have a default export" — and the sources are - * TypeScript, which the JS-only lexers in this package cannot parse. A - * comment- and string-stripped scan decides both facts deterministically at - * build time; the generated wrappers re-verify the export shape at runtime - * with a clear error. + * `main`" and "does this module have a default export" — read from the + * top-level statements of a TypeScript parse at build time; the generated + * wrappers re-verify the export shape at runtime with a clear error. */ export interface EntryExportScan { readonly hasDefaultExport: boolean; readonly hasMainExport: boolean; } -/** - * Removes comments, string literals, and template literals so export - * detection never matches inside them. Template `${}` holes are scanned - * recursively enough for detection purposes (nested braces tracked by depth). - */ -export const stripCommentsAndStrings = (source: string): string => { - let output = ''; - let index = 0; - const length = source.length; - // Template-literal nesting: each entry is the brace depth inside a `${}` hole. - const templateHoleDepth: number[] = []; - let inTemplate = false; - // Last significant character, for the division-versus-regex-literal heuristic. - let lastCode = ''; - - const regexCanFollow = (): boolean => - lastCode === '' || '(,=:[!&|?{};+-*%<>~^'.includes(lastCode) || /\breturn$|\btypeof$|\bcase$/u.test(output.trimEnd()); +const hasModifier = (statement: ts.Statement, kind: ts.SyntaxKind): boolean => + ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === kind); - while (index < length) { - const char = source[index]!; - const next = source[index + 1]; - - if (inTemplate) { - if (char === '\\') { - index += 2; - continue; - } - if (char === '`') { - inTemplate = false; - index += 1; - continue; - } - if (char === '$' && next === '{') { - templateHoleDepth.push(0); - inTemplate = false; - output += ' '; - index += 2; - continue; - } - index += 1; - continue; - } +const declaresMain = (statement: ts.Statement): boolean => { + if (ts.isFunctionDeclaration(statement)) return statement.name?.text === 'main'; + if (ts.isVariableStatement(statement)) { + return statement.declarationList.declarations.some((declaration) => + ts.isIdentifier(declaration.name) && declaration.name.text === 'main'); + } + return false; +}; - if (char === '/' && next === '/') { - while (index < length && source[index] !== '\n') index += 1; - continue; - } - if (char === '/' && next === '*') { - index += 2; - while (index < length && !(source[index] === '*' && source[index + 1] === '/')) index += 1; - index += 2; - output += ' '; - continue; - } - if (char === '/' && next !== '/' && next !== '*' && regexCanFollow()) { - // Regex literal: skip to its unescaped closing slash (character classes - // may contain unescaped slashes). - index += 1; - let inClass = false; - while (index < length && (inClass || source[index] !== '/')) { - if (source[index] === '\\') index += 1; - else if (source[index] === '[') inClass = true; - else if (source[index] === ']') inClass = false; - index += 1; - } - index += 1; - while (index < length && /[a-z]/iu.test(source[index]!)) index += 1; - output += ' '; - lastCode = ' '; +export const scanEntryExportsSource = (source: string): EntryExportScan => { + const file = ts.createSourceFile('entry.ts', source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); + let hasDefaultExport = false; + let hasMainExport = false; + for (const statement of file.statements) { + if (ts.isExportAssignment(statement)) { + hasDefaultExport ||= !statement.isExportEquals; continue; } - if (char === "'" || char === '"') { - index += 1; - while (index < length && source[index] !== char) { - index += source[index] === '\\' ? 2 : 1; + if (ts.isExportDeclaration(statement)) { + // Type-only clauses (`export type { … }`) never produce runtime exports. + if (statement.isTypeOnly || statement.exportClause === undefined || !ts.isNamedExports(statement.exportClause)) continue; + for (const element of statement.exportClause.elements) { + if (element.isTypeOnly) continue; + hasDefaultExport ||= element.name.text === 'default'; + hasMainExport ||= element.name.text === 'main'; } - index += 1; - output += ' '; - continue; - } - if (char === '`') { - inTemplate = true; - index += 1; continue; } - if (templateHoleDepth.length > 0) { - if (char === '{') { - templateHoleDepth[templateHoleDepth.length - 1] = templateHoleDepth[templateHoleDepth.length - 1]! + 1; - } else if (char === '}') { - const depth = templateHoleDepth[templateHoleDepth.length - 1]!; - if (depth === 0) { - templateHoleDepth.pop(); - inTemplate = true; - index += 1; - continue; - } - templateHoleDepth[templateHoleDepth.length - 1] = depth - 1; - } - } - output += char; - if (!/\s/u.test(char)) lastCode = char; - index += 1; + if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue; + if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) hasDefaultExport = true; + else if (declaresMain(statement)) hasMainExport = true; } - - return output; -}; - -const exportBraceClausePattern = /export\s+(?:type\s+)?\{([^}]*)\}/gu; - -/** Names exported by one `export { … }` clause, honoring `as` renames. */ -const braceExportNames = (clause: string): readonly string[] => clause - .split(',') - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0) - .map((entry) => { - const rename = /^(?:\S+)\s+as\s+(\S+)$/u.exec(entry); - return rename === null ? entry : rename[1]!; - }); - -export const scanEntryExportsSource = (source: string): EntryExportScan => { - const stripped = stripCommentsAndStrings(source); - const braceExports = new Set(); - for (const match of stripped.matchAll(exportBraceClausePattern)) { - // Type-only clauses (`export type { … }`) never produce runtime exports. - if (/export\s+type\s*\{/u.test(match[0])) continue; - for (const name of braceExportNames(match[1]!)) { - braceExports.add(name); - } - } - - const hasDefaultExport = - /(?:^|[\s;}])export\s+default\b/u.test(stripped) || braceExports.has('default'); - const hasMainExport = - /(?:^|[\s;}])export\s+(?:async\s+)?(?:const|let|var|function)\s+main\b/u.test(stripped) || - braceExports.has('main'); return Object.freeze({ hasDefaultExport, hasMainExport }); }; diff --git a/packages/agent-bundle/src/config/command.ts b/packages/agent-bundle/src/config/command.ts index c6b576c35..f14a86375 100644 --- a/packages/agent-bundle/src/config/command.ts +++ b/packages/agent-bundle/src/config/command.ts @@ -1,6 +1,7 @@ import { readFile } from 'node:fs/promises'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { errorMessage } from '../core/errors.ts'; import { parseMarkdownFrontmatter } from './skill-references.ts'; export interface CommandDocument { @@ -116,7 +117,7 @@ export const parseCommand = async (source: string): Promise => body: '', diagnostics: [diagnostic( 'AB4920', - `Unable to read command file: ${error instanceof Error ? error.message : String(error)}`, + `Unable to read command file: ${errorMessage(error)}`, source, )], frontmatter: {}, diff --git a/packages/agent-bundle/src/config/dev-contracts.ts b/packages/agent-bundle/src/config/dev-contracts.ts index fc53c49e1..008da03b7 100644 --- a/packages/agent-bundle/src/config/dev-contracts.ts +++ b/packages/agent-bundle/src/config/dev-contracts.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from 'node:path'; import { createJiti } from 'jiti'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { errorMessage } from '../core/errors.ts'; import { isInsideOrEqual } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import type { @@ -194,7 +195,7 @@ export const loadDevContractMatrix = async ( configured = declaration(config); } catch (error) { return Object.freeze({ - diagnostics: Object.freeze([diagnostic(configPath, error instanceof Error ? error.message : String(error))]), + diagnostics: Object.freeze([diagnostic(configPath, errorMessage(error))]), modulePath: configPath, }); } @@ -225,7 +226,7 @@ export const loadDevContractMatrix = async ( } catch (error) { return Object.freeze({ ...base, - diagnostics: Object.freeze([diagnostic(requestedPath, error instanceof Error ? error.message : String(error))]), + diagnostics: Object.freeze([diagnostic(requestedPath, errorMessage(error))]), }); } }; diff --git a/packages/agent-bundle/src/config/notice-retention.ts b/packages/agent-bundle/src/config/notice-retention.ts index ccb1633be..84faa8d65 100644 --- a/packages/agent-bundle/src/config/notice-retention.ts +++ b/packages/agent-bundle/src/config/notice-retention.ts @@ -1,5 +1,6 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { isPlainRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig, NormalizedNoticeRetention, @@ -48,12 +49,6 @@ export const parseNoticeRetentionDuration = (value: unknown): number | undefined return Number.isSafeInteger(milliseconds) ? milliseconds : undefined; }; -const isPlainRecord = (value: unknown): value is Readonly> => - typeof value === 'object' - && value !== null - && !Array.isArray(value) - && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); - const retentionKeys = new Set(['maxJournalBytes', 'maxTerminal', 'terminalTtl']); const diagnostic = (message: string, sourcePath: string, hasState: boolean): Diagnostic => ({ diff --git a/packages/agent-bundle/src/config/render-markdown.ts b/packages/agent-bundle/src/config/render-markdown.ts index 2fdb6dbae..d4dbfa3ff 100644 --- a/packages/agent-bundle/src/config/render-markdown.ts +++ b/packages/agent-bundle/src/config/render-markdown.ts @@ -10,6 +10,7 @@ * `em`/`i`, `code`, `pre` (fenced, `language-*` class), `blockquote`, `a`, * `hr`, `br`, fragments, arrays, strings, and numbers. */ +import { errorMessage } from '../core/errors.ts'; const reactFragment = Symbol.for('react.fragment'); @@ -55,7 +56,7 @@ const resolveNode = async (node: unknown, depth = 0): Promise => { rendered = (node.type as (props: ElementProps) => unknown)(node.props); } catch (error) { throw new MarkdownRenderError( - `Rendered skill component ${componentName(node.type)} threw: ${error instanceof Error ? error.message : String(error)}`, + `Rendered skill component ${componentName(node.type)} threw: ${errorMessage(error)}`, ); } return resolveNode(isThenable(rendered) ? await rendered : rendered, depth + 1); diff --git a/packages/agent-bundle/src/config/rule.ts b/packages/agent-bundle/src/config/rule.ts index 1e69864ec..871ee73de 100644 --- a/packages/agent-bundle/src/config/rule.ts +++ b/packages/agent-bundle/src/config/rule.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises'; import { stringify as stringifyYaml } from 'yaml'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { errorMessage } from '../core/errors.ts'; import { parseMarkdownFrontmatter } from './skill-references.ts'; export interface RuleDocument { @@ -116,7 +117,7 @@ export const parseRule = async (source: string): Promise => { body: '', diagnostics: [diagnostic( 'AB4900', - `Unable to read rule file: ${error instanceof Error ? error.message : String(error)}`, + `Unable to read rule file: ${errorMessage(error)}`, source, )], emittedMarkdown: '', diff --git a/packages/agent-bundle/src/config/skill.ts b/packages/agent-bundle/src/config/skill.ts index 9a336acf2..2f3aea3b7 100644 --- a/packages/agent-bundle/src/config/skill.ts +++ b/packages/agent-bundle/src/config/skill.ts @@ -5,7 +5,7 @@ import fastGlob from 'fast-glob'; import type { Ignore } from 'ignore'; import type { Diagnostic } from '../core/diagnostics.ts'; -import { isErrno } from '../core/errors.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; import { isProjectPathIgnored, readProjectIgnoreRules, @@ -112,7 +112,7 @@ const missingFrontmatter = (source: string): Diagnostic => ({ const malformedFrontmatter = (source: string, error: unknown): Diagnostic => ({ code: 'AB3002', severity: 'error', - message: `Skill YAML frontmatter is invalid: ${error instanceof Error ? error.message : String(error)}`, + message: `Skill YAML frontmatter is invalid: ${errorMessage(error)}`, sourcePath: source, }); @@ -185,7 +185,7 @@ export const parseSkill = async ( { code: 'AB3000', severity: 'error', - message: `Unable to read Skill Markdown: ${error instanceof Error ? error.message : String(error)}`, + message: `Unable to read Skill Markdown: ${errorMessage(error)}`, sourcePath: source, }, ], diff --git a/packages/agent-bundle/src/core/special-ip.ts b/packages/agent-bundle/src/core/special-ip.ts new file mode 100644 index 000000000..51234f233 --- /dev/null +++ b/packages/agent-bundle/src/core/special-ip.ts @@ -0,0 +1,43 @@ +import { BlockList, isIP } from 'node:net'; + +type Family = 'ipv4' | 'ipv6'; + +const blockList = (family: Family, subnets: readonly string[]): BlockList => { + const list = new BlockList(); + for (const subnet of subnets) { + const [address, prefix] = subnet.split('/') as [string, string]; + list.addSubnet(address, Number(prefix), family); + } + return list; +}; + +/** IANA IPv4 Special-Purpose Address Registry: never globally reachable. */ +const specialIpv4 = blockList('ipv4', [ + '0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', + '192.0.0.0/24', '192.0.2.0/24', '192.31.196.0/24', '192.52.193.0/24', '192.88.99.0/24', '192.168.0.0/16', + '192.175.48.0/24', '198.18.0.0/15', '198.51.100.0/24', '203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', +]); + +/** IANA IPv6 Special-Purpose Address Registry: never globally reachable. */ +const specialIpv6 = blockList('ipv6', [ + '::/96', '::ffff:0:0/96', '64:ff9b::/96', '64:ff9b:1::/48', '100::/64', '100:0:0:1::/64', + '2001::/23', '2001:db8::/32', '2002::/16', '3fff::/20', '5f00::/16', 'fc00::/7', 'fe80::/10', 'ff00::/8', +]); + +const globalUnicastIpv6 = blockList('ipv6', ['2000::/3']); + +const bareAddress = (host: string): string => host.replace(/^\[|\]$/gu, ''); + +/** An IP literal (brackets allowed) inside an IANA special-purpose range; hostnames are never special. */ +export const isSpecialPurposeIp = (host: string): boolean => { + const address = bareAddress(host); + const version = isIP(address); + if (version === 4) return specialIpv4.check(address, 'ipv4'); + return version === 6 && specialIpv6.check(address, 'ipv6'); +}; + +/** An IPv6 literal (brackets allowed) outside global unicast `2000::/3`. */ +export const isNonGlobalUnicastIpv6 = (host: string): boolean => { + const address = bareAddress(host); + return isIP(address) === 6 && !globalUnicastIpv6.check(address, 'ipv6'); +}; diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-routes.ts b/packages/agent-bundle/src/dev/artifacts/artifact-routes.ts index 4e9fc29a1..651470d4c 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-routes.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-routes.ts @@ -7,14 +7,16 @@ import { } from './artifact-inspection-service.ts'; import { EpochStoreError, type EpochStoreErrorCode } from '../epoch-store.ts'; import { + badRequest, decodedOpaqueSegment, diagnostic, isRequestDiagnostic, + noQuery, nonemptyString, rawPathname, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, type RequestDiagnostic, } from '../http.ts'; import type { ArtifactEpochDiff, ArtifactInspection } from '../types.ts'; @@ -53,13 +55,9 @@ const epochDiagnostics: Readonly { - throw artifactRequestError(diagnostic('AB8060', 'Artifact route path is not valid.', 400)); -}; +const pathError = badRequest('AB8060', 'Artifact route path is not valid.'); -const invalidShape = (): never => { - throw artifactRequestError(diagnostic('AB8062', 'Artifact request has an invalid shape.', 400)); -}; +const invalidShape = badRequest('AB8062', 'Artifact request has an invalid shape.'); const decodedSegment = (segment: string): string => decodedOpaqueSegment(segment, { code: 'AB8060', message: 'Artifact route path is not valid.' }); @@ -75,9 +73,6 @@ const route = (requestTarget: string | undefined): Route | undefined => { return Object.freeze({ epochId: segments[1]!, kind: 'epoch' }); }; -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - const diffQuery = (requestTarget: string | undefined): Readonly<{ readonly base: string; readonly candidate: string }> => { const query = new URL(requestTarget ?? '/', 'http://localhost').searchParams; if ([...query.keys()].some((key) => key !== 'base' && key !== 'candidate')) invalidShape(); @@ -88,10 +83,6 @@ const diffQuery = (requestTarget: string | undefined): Readonly<{ readonly base: return Object.freeze({ base, candidate }); }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); -}; - /** * Read-only HTTP boundary over published artifact epochs. The browser names an * epoch id; the service resolves every path and holds the epoch reference. @@ -144,10 +135,10 @@ export class ArtifactRoutes { } if (parsed.kind === 'diff') { const query = diffQuery(request.url); - return responseJson(response, { diff: await service.diff(query.base, query.candidate) }); + return responseJsonOrDestroy(response, { diff: await service.diff(query.base, query.candidate) }); } - noQuery(request.url); - return responseJson(response, { inspection: await service.inspect(parsed.epochId) }); + noQuery(request.url, invalidShape); + return responseJsonOrDestroy(response, { inspection: await service.inspect(parsed.epochId) }); } #unavailable(status: number): Error { diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index 7f36e3960..0e6e8ce02 100644 --- a/packages/agent-bundle/src/dev/epoch-store.ts +++ b/packages/agent-bundle/src/dev/epoch-store.ts @@ -6,7 +6,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; -import { isInside } from '../core/paths.ts'; +import { exists, isInside } from '../core/paths.ts'; import { hasExactOwnKeys, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { runPromise, runSync } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; @@ -175,16 +175,6 @@ const leaseMutexFor = (agentBundlePath: string): Semaphore.Semaphore => { return created; }; -const pathExists = async (path: string): Promise => { - try { - await lstat(path); - return true; - } catch (error) { - if (isErrno(error, 'ENOENT')) return false; - throw error; - } -}; - const isSafePathSegment = (value: string): boolean => /^[a-z0-9][a-z0-9._-]*$/iu.test(value) && value !== '.' && value !== '..'; @@ -639,7 +629,7 @@ export class EpochStore { let publication: EpochPublicationReceipt | undefined; let moved = false; const attempt = Effect.gen({ self: this }, function* (this: EpochStore) { - if (yield* liftPromise(() => pathExists(epochRoot))) { + if (yield* liftPromise(() => exists(epochRoot))) { return yield* Effect.fail( new EpochStoreError('EPOCH_ALREADY_EXISTS', `Epoch ${JSON.stringify(record.epoch.id)} already exists.`), ); diff --git a/packages/agent-bundle/src/dev/eval/eval-routes.ts b/packages/agent-bundle/src/dev/eval/eval-routes.ts index f77a9aca0..97dad077c 100644 --- a/packages/agent-bundle/src/dev/eval/eval-routes.ts +++ b/packages/agent-bundle/src/dev/eval/eval-routes.ts @@ -2,17 +2,19 @@ import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; -import { hasOnlyOwnKeys, isRecord as coreIsRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { + badRequest, + decodedOpaqueSegment, diagnostic, - isJsonRequest, + hasOnly, isRequestDiagnostic, + noQuery, nonemptyString, rawPathname, - readBody, + readJsonBody, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, type RequestDiagnostic, } from '../http.ts'; import { @@ -108,29 +110,12 @@ const authoringDiagnostic = (error: unknown): RequestDiagnostic | undefined => { const terminalEvent = (event: EvalRunEventsReplay['events'][number]): boolean => event.kind === 'run.cancelled' || event.kind === 'run.completed' || event.kind === 'run.failed'; -const pathError = (): never => { - throw requestError(diagnostic('AB8070', 'Eval route path is not valid.', 400)); -}; +const pathError = badRequest('AB8070', 'Eval route path is not valid.'); -const invalidShape = (): never => { - throw requestError(diagnostic('AB8072', 'Eval request has an invalid shape.', 400)); -}; +const invalidShape = badRequest('AB8072', 'Eval request has an invalid shape.'); -const decodedSegment = (segment: string): string => { - let decoded: string; - try { - decoded = decodeURIComponent(segment); - } catch { - return pathError(); - } - if ( - decoded.length === 0 || decoded === '.' || decoded === '..' || - decoded.includes('/') || decoded.includes('\\') || decoded.includes('\0') - ) { - return pathError(); - } - return decoded; -}; +const decodedSegment = (segment: string): string => + decodedOpaqueSegment(segment, { code: 'AB8070', message: 'Eval route path is not valid.' }); const opaqueArtifactRef = (value: string): string => { if (!/^[A-Za-z0-9_-]{1,8192}$/u.test(value)) return pathError(); @@ -168,11 +153,6 @@ const route = (requestTarget: string | undefined): Route | undefined => { return Object.freeze({ kind: 'run', runId: segments[1] ?? pathError() }); }; -// Inputs are parsed JSON, so the canonical guard's unknown-record narrowing is retyped to JsonObject. -const isRecord = coreIsRecord as (value: unknown) => value is JsonObject; - -const hasOnly: (value: JsonObject, fields: readonly string[]) => boolean = hasOnlyOwnKeys; - const nameList = (value: unknown): readonly string[] => { if (!Array.isArray(value) || value.length === 0 || !value.every(nonemptyString)) return invalidShape(); return Object.freeze([...value]); @@ -185,19 +165,7 @@ const trials = (value: unknown): number => { return value; }; -const jsonBody = async (request: IncomingMessage): Promise => { - if (!isJsonRequest(request)) { - throw requestError(diagnostic('AB8009', 'Request body must use application/json.', 415)); - } - let parsed: unknown; - try { - parsed = parseJsonWithoutDuplicateKeys(await readBody(request)); - } catch (error) { - if (isRequestDiagnostic(error)) throw error; - throw requestError(diagnostic('AB8001', 'Request body must be valid JSON.', 400)); - } - return isRecord(parsed) ? parsed : invalidShape(); -}; +const jsonBody = (request: IncomingMessage): Promise => readJsonBody(request, { invalidShape }); /** * A browser selects authored suites, authored cases, and a trial count. Artifact @@ -220,10 +188,6 @@ const cancelRequest = async (request: IncomingMessage): Promise => { if (!hasOnly(await jsonBody(request), [])) invalidShape(); }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); -}; - const eventCursor = (requestTarget: string | undefined): number => { const query = new URL(requestTarget ?? '/', 'http://localhost').searchParams; if ([...query.keys()].some((key) => key !== 'after') || query.getAll('after').length > 1) invalidShape(); @@ -361,7 +325,7 @@ export class EvalRoutes { const selection = runRequest(await jsonBody(request)); if (this.#closePromise !== undefined) throw this.#unavailable(503); const admission = await service.start(selection); - return writeJsonResponse(response, { run: admission.run }, { destroyIfEnded: true, status: 202 }); + return responseJsonOrDestroy(response, { run: admission.run }, 202); } finally { finishAdmission(); } @@ -369,11 +333,11 @@ export class EvalRoutes { if (parsed.kind === 'cancel' && method === 'POST') { const finishAdmission = this.#beginAdmission(); try { - noQuery(request.url); + noQuery(request.url, invalidShape); await cancelRequest(request); if (this.#closePromise !== undefined) throw this.#unavailable(503); const cancelled = await service.cancel(parsed.runId); - return writeJsonResponse(response, { cancelled, runId: parsed.runId }, { destroyIfEnded: true, status: 202 }); + return responseJsonOrDestroy(response, { cancelled, runId: parsed.runId }, 202); } finally { finishAdmission(); } @@ -383,10 +347,10 @@ export class EvalRoutes { } if (parsed.kind === 'comparisons') { const query = comparisonQuery(request.url); - return writeJsonResponse(response, { comparison: await service.compare(query.base, query.candidate) }, { destroyIfEnded: true }); + return responseJsonOrDestroy(response, { comparison: await service.compare(query.base, query.candidate) }); } if (parsed.kind === 'events') { - return writeJsonResponse(response, { replay: await service.events(parsed.runId, eventCursor(request.url)) }, { destroyIfEnded: true }); + return responseJsonOrDestroy(response, { replay: await service.events(parsed.runId, eventCursor(request.url)) }); } if (parsed.kind === 'stream') { return this.#stream(response, service, parsed.runId, eventCursor(request.url)); @@ -394,10 +358,10 @@ export class EvalRoutes { if (parsed.kind === 'artifact') { return this.#artifact(request, response, service, parsed); } - noQuery(request.url); - if (parsed.kind === 'suites') return writeJsonResponse(response, await service.suites(), { destroyIfEnded: true }); - if (parsed.kind === 'runs') return writeJsonResponse(response, { runs: await service.list() }, { destroyIfEnded: true }); - return writeJsonResponse(response, { run: await service.read(parsed.runId) }, { destroyIfEnded: true }); + noQuery(request.url, invalidShape); + if (parsed.kind === 'suites') return responseJsonOrDestroy(response, await service.suites()); + if (parsed.kind === 'runs') return responseJsonOrDestroy(response, { runs: await service.list() }); + return responseJsonOrDestroy(response, { run: await service.read(parsed.runId) }); } #unavailable(status: number): Error { diff --git a/packages/agent-bundle/src/dev/http.ts b/packages/agent-bundle/src/dev/http.ts index deeb05c15..651d05fb6 100644 --- a/packages/agent-bundle/src/dev/http.ts +++ b/packages/agent-bundle/src/dev/http.ts @@ -79,6 +79,20 @@ export const responseJson = ( response.end(JSON.stringify(body)); }; +/** `responseJson` for handlers that may already have answered (streams, cancellation): destroys instead of writing twice. */ +export const responseJsonOrDestroy = (response: ServerResponse, body: unknown, status = 200): void => + responseJson(response, body, { destroyIfEnded: true, status }); + +/** Builds a thrower for one fixed 400 diagnostic; routes derive `invalidShape`/`pathError` from it. */ +export const badRequest = (code: string, message: string): () => never => () => { + throw requestError(diagnostic(code, message, 400)); +}; + +/** Rejects a request target that carries a query string. */ +export const noQuery = (requestTarget: string | undefined, invalid: () => never): void => { + if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalid(); +}; + export const singleHeader = (value: string | readonly string[] | undefined): string | undefined => typeof value === 'string' ? value : undefined; diff --git a/packages/agent-bundle/src/dev/inspector-routes.ts b/packages/agent-bundle/src/dev/inspector-routes.ts index 2cbd38f1f..2052ce1ee 100644 --- a/packages/agent-bundle/src/dev/inspector-routes.ts +++ b/packages/agent-bundle/src/dev/inspector-routes.ts @@ -1,14 +1,16 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { + badRequest, diagnostic, hasOnly, isRequestDiagnostic, + noQuery, rawPathname, readJsonBody, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, } from './http.ts'; import type { InspectorLauncherStatus } from './inspector-launcher.ts'; @@ -26,16 +28,9 @@ export interface InspectorRoutesOptions { readonly service?: InspectorRouteService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); +const pathError = badRequest('AB8110', 'Inspector route path is not valid.'); -const pathError = (): never => { - throw requestError(diagnostic('AB8110', 'Inspector route path is not valid.', 400)); -}; - -const invalidShape = (): never => { - throw requestError(diagnostic('AB8111', 'Inspector request has an invalid shape.', 400)); -}; +const invalidShape = badRequest('AB8111', 'Inspector request has an invalid shape.'); const route = (requestTarget: string | undefined): Route | undefined => { const pathname = rawPathname(requestTarget); @@ -48,10 +43,6 @@ const route = (requestTarget: string | undefined): Route | undefined => { return pathError(); }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); -}; - /** * HTTP boundary for the opt-in standalone MCP Inspector. The browser never * selects the child command, environment, or working directory. @@ -93,15 +84,15 @@ export class InspectorRoutes { service: InspectorRouteService, ): Promise { const method = request.method ?? 'GET'; - noQuery(request.url); + noQuery(request.url, invalidShape); switch (parsed) { case 'status': if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - return responseJson(response, { status: service.status() }); + return responseJsonOrDestroy(response, { status: service.status() }); case 'launch': if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); if (!hasOnly(await readJsonBody(request, { invalidShape }), [])) invalidShape(); - return responseJson(response, { url: (await service.launch()).url }); + return responseJsonOrDestroy(response, { url: (await service.launch()).url }); default: { const exhaustive: never = parsed; throw new Error(`Unexpected inspector route: ${String(exhaustive)}`); diff --git a/packages/agent-bundle/src/dev/logs/dev-log-routes.ts b/packages/agent-bundle/src/dev/logs/dev-log-routes.ts index ee6d7e1b5..cc613691c 100644 --- a/packages/agent-bundle/src/dev/logs/dev-log-routes.ts +++ b/packages/agent-bundle/src/dev/logs/dev-log-routes.ts @@ -12,7 +12,7 @@ import { rawPathname, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, type RequestDiagnostic, } from '../http.ts'; import { createBackpressuredWriter, encodedNdjsonFrame, writeKeepAliveStreamHead } from '../route-streams.ts'; @@ -27,9 +27,6 @@ export interface DevLogRoutesOptions { readonly service?: DevLogService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - const route = (requestTarget: string | undefined): Route | undefined => { const pathname = rawPathname(requestTarget); if (pathname !== '/api/logs' && !pathname.startsWith('/api/logs/')) return undefined; @@ -99,7 +96,7 @@ export class DevLogRoutes { try { const afterSequence = cursor(request.url); if (parsed === 'replay') { - responseJson(response, { replay: service.replay({ afterSequence }) }); + responseJsonOrDestroy(response, { replay: service.replay({ afterSequence }) }); } else { this.#stream(service, afterSequence, response); } diff --git a/packages/agent-bundle/src/dev/mcp-app-metadata.ts b/packages/agent-bundle/src/dev/mcp-app-metadata.ts index 9aaa2ab05..3f0cd8580 100644 --- a/packages/agent-bundle/src/dev/mcp-app-metadata.ts +++ b/packages/agent-bundle/src/dev/mcp-app-metadata.ts @@ -1,5 +1,6 @@ import { isCallToolResult } from '@modelcontextprotocol/client'; +import { isPlainRecord } from '../core/strict-json.ts'; import type { McpAppJsonValue, McpAppToolDefinition } from './mcp-apps/mcp-app-binding-service.ts'; export interface McpAppResultInspection { @@ -41,9 +42,6 @@ const setOwnData = (target: Record, key: string, value: Va }); }; -const isPlainRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; - const ownData = (value: object, key: string): unknown => { const descriptor = Object.getOwnPropertyDescriptor(value, key); if (descriptor === undefined || descriptor.get !== undefined || descriptor.set !== undefined) { @@ -93,8 +91,6 @@ const jsonRecord = (value: unknown, label: string): JsonRecord => { return cloned; }; -const hasOwn = (value: object, key: string): boolean => Object.hasOwn(value, key); - const uiUri = (value: unknown): string | undefined => { if (typeof value !== 'string') return undefined; try { @@ -110,7 +106,7 @@ const deepEqual = (left: McpAppJsonValue, right: McpAppJsonValue): boolean => JS const metadataRecord = (value: unknown): JsonRecord => { if (value === undefined) return Object.freeze({}); if (!isPlainRecord(value)) return jsonRecord(value, 'MCP App metadata'); - if (hasOwn(value, '_meta')) { + if (Object.hasOwn(value, '_meta')) { const metadata = ownData(value, '_meta'); return metadata === undefined ? Object.freeze({}) : jsonRecord(metadata, 'MCP App metadata'); } @@ -239,14 +235,14 @@ export const projectMcpAppResult = (value: unknown): McpAppResultInspection => { if (!hasProtocolCallToolResult(appVisible) || !hasExactProtocolContent(appVisible.content)) { throw new TypeError('MCP CallToolResult content must use valid protocol content blocks.'); } - if (hasOwn(appVisible, 'isError') && typeof appVisible.isError !== 'boolean') { + if (Object.hasOwn(appVisible, 'isError') && typeof appVisible.isError !== 'boolean') { throw new TypeError('MCP CallToolResult isError must be a boolean.'); } - if (hasOwn(appVisible, 'structuredContent') && !isPlainRecord(appVisible.structuredContent)) { + if (Object.hasOwn(appVisible, 'structuredContent') && !isPlainRecord(appVisible.structuredContent)) { throw new TypeError('MCP CallToolResult structuredContent must be a finite JSON object.'); } const modelVisible: Record = { content: appVisible.content }; - if (hasOwn(appVisible, 'structuredContent')) modelVisible.structuredContent = appVisible.structuredContent!; + if (Object.hasOwn(appVisible, 'structuredContent')) modelVisible.structuredContent = appVisible.structuredContent!; return Object.freeze({ appVisible, isError: appVisible.isError === true, @@ -258,7 +254,7 @@ export const projectMcpAppResult = (value: unknown): McpAppResultInspection => { export const isMcpAppToolVisible = (tool: unknown): boolean => { if (!isPlainRecord(tool) || typeof tool.name !== 'string' || tool.name.length === 0) return false; const metadata = tool._meta; - if (!isPlainRecord(metadata) || !isPlainRecord(metadata.ui) || !hasOwn(metadata.ui, 'visibility')) return true; + if (!isPlainRecord(metadata) || !isPlainRecord(metadata.ui) || !Object.hasOwn(metadata.ui, 'visibility')) return true; const visibility = metadata.ui.visibility; return Array.isArray(visibility) && visibility.every((value) => typeof value === 'string') && visibility.includes('app'); }; diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts index e66f76d07..120637cb9 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-binding-service.ts @@ -1,4 +1,5 @@ import { isRecord } from '../../core/strict-json.ts'; +import { requireMcpAppJson } from './mcp-app-json.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from '../mcp-app-profile-descriptors.ts'; export type McpAppJsonValue = @@ -121,24 +122,8 @@ interface BindingEntry { const defaultTeardownTimeoutMs = 1_000; const maximumTeardownTimeoutMs = 30_000; -const isJsonValue = (value: unknown): value is McpAppJsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return true; - if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - if (!isRecord(value) || Object.getPrototypeOf(value) !== Object.prototype) return false; - return Object.values(value).every(isJsonValue); -}; - -const cloneJson = (value: McpAppJsonValue): McpAppJsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return value; - if (Array.isArray(value)) return Object.freeze(value.map(cloneJson)); - return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child)]))); -}; - -const requireJson = (value: unknown, label: string): McpAppJsonValue => { - if (!isJsonValue(value)) throw new TypeError(`${label} must be a finite JSON value.`); - return cloneJson(value); -}; +const requireJson = (value: unknown, label: string): McpAppJsonValue => + requireMcpAppJson(value, `${label} must be a finite JSON value.`); const requireNonempty = (value: string, label: string): string => { if (value.trim().length === 0) throw new Error(`${label} must be nonempty.`); @@ -158,11 +143,6 @@ const requireTeardownTimeout = (value: number | undefined): number => { return timeout; }; -const throwIfAborted = (signal: AbortSignal | undefined, label: string): void => { - if (signal === undefined || !signal.aborted) return; - throw signal.reason instanceof Error ? signal.reason : new Error(label); -}; - export const selectMcpAppResourceUri = (tool: McpAppToolDefinition): string | undefined => { const metadata = tool._meta; if (!isRecord(metadata) || !isRecord(metadata.ui) || typeof metadata.ui.resourceUri !== 'string') return undefined; @@ -265,17 +245,17 @@ export class McpAppBindingService { } async callTool(bindingId: string, request: McpAppToolCall, signal?: AbortSignal): Promise { - throwIfAborted(signal, 'MCP App bridge tool call was aborted.'); + signal?.throwIfAborted(); const entry = this.#entry(bindingId); const name = requireNonempty(request.name, 'MCP App bridge tool name'); const tools = await entry.lease.session.listBridgeTools(); - throwIfAborted(signal, 'MCP App bridge tool call was aborted.'); + signal?.throwIfAborted(); this.#assertActive(entry); if (!tools.some((tool) => tool.name === name && tool.appVisible)) { throw new Error(`MCP App bridge tool ${JSON.stringify(name)} is not app-visible for this binding.`); } const argumentsValue = request.arguments === undefined ? undefined : requireJson(request.arguments, 'MCP App bridge tool arguments'); - throwIfAborted(signal, 'MCP App bridge tool call was aborted.'); + signal?.throwIfAborted(); return requireJson( await entry.lease.session.callTool({ arguments: argumentsValue, @@ -287,16 +267,16 @@ export class McpAppBindingService { } async readResource(bindingId: string, request: McpAppResourceRead, signal?: AbortSignal): Promise { - throwIfAborted(signal, 'MCP App bridge resource read was aborted.'); + signal?.throwIfAborted(); const entry = this.#entry(bindingId); const uri = requireNonempty(request.uri, 'MCP App bridge resource URI'); const resources = await entry.lease.session.listBridgeResources(); - throwIfAborted(signal, 'MCP App bridge resource read was aborted.'); + signal?.throwIfAborted(); this.#assertActive(entry); if (!resources.some((resource) => resource.uri === uri && resource.appVisible)) { throw new Error(`MCP App bridge resource ${JSON.stringify(uri)} is not app-visible for this binding.`); } - throwIfAborted(signal, 'MCP App bridge resource read was aborted.'); + signal?.throwIfAborted(); return requireJson( await entry.lease.session.readResource({ uri, diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts index d26fe2dcb..851f5b0d4 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-bridge.ts @@ -1,5 +1,6 @@ import { MCP_APP_ROUTE_ID_META_KEY } from '../../contracts/mcp-app-protocol.ts'; import { MAX_APP_HTML_BYTES } from '../../core/mcp-app-limits.ts'; +import { isPlainRecord } from '../../core/strict-json.ts'; import { validateMcpAppDownloadRequest, validateMcpAppExternalLink, @@ -13,6 +14,7 @@ import { type McpAppJsonValue, } from './mcp-app-binding-service.ts'; import { createMcpAppConsentActionDigest } from './mcp-app-consent.ts'; +import { cloneMcpAppJson, snapshotMcpAppJson, snapshotMcpAppJsonRecord } from './mcp-app-json.ts'; import type { McpAppConsentAuthority, McpAppConsentCapability, @@ -252,33 +254,16 @@ const hostStyleVariables = new Set([ '--border-radius-xs', '--border-radius-sm', '--border-radius-md', '--border-radius-lg', '--border-radius-xl', '--border-radius-full', '--border-width-regular', '--shadow-hairline', '--shadow-sm', '--shadow-md', '--shadow-lg', ]); -const hasOwn = (value: object, key: string): boolean => Object.hasOwn(value, key); - const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; - const isRequestId = (value: unknown): value is McpAppBridgeRequestId => value === null || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); -const isJsonValue = (value: unknown): value is McpAppJsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return true; - if (typeof value === 'number') return Number.isFinite(value); - if (Array.isArray(value)) return value.every(isJsonValue); - return isRecord(value) && Object.values(value).every(isJsonValue); -}; +const isJsonValue = (value: unknown): value is McpAppJsonValue => snapshotMcpAppJson(value) !== undefined; -const cloneJson = (value: McpAppJsonValue): McpAppJsonValue => { - if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') return value; - if (Array.isArray(value)) return Object.freeze(value.map(cloneJson)); - return Object.freeze(Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneJson(child)]))); -}; +const cloneJson = cloneMcpAppJson; -const jsonRecord = (value: unknown): McpAppBridgeJsonRecord | undefined => { - if (!isRecord(value) || !isJsonValue(value)) return undefined; - return cloneJson(value) as McpAppBridgeJsonRecord; -}; +const jsonRecord = snapshotMcpAppJsonRecord; const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0; @@ -330,35 +315,35 @@ const snapshotHost = (host: McpAppBridgeHost): McpAppBridgeHost => { }; const messageOf = (value: unknown): McpAppBridgeMessage | undefined => { - if (!isRecord(value) || value.jsonrpc !== '2.0') return undefined; - const hasMethod = hasOwn(value, 'method'); - const hasResult = hasOwn(value, 'result'); - const hasError = hasOwn(value, 'error'); + if (!isPlainRecord(value) || value.jsonrpc !== '2.0') return undefined; + const hasMethod = Object.hasOwn(value, 'method'); + const hasResult = Object.hasOwn(value, 'result'); + const hasError = Object.hasOwn(value, 'error'); if (Number(hasMethod) + Number(hasResult) + Number(hasError) !== 1) return undefined; - if (!hasMethod && !hasOwn(value, 'id')) return undefined; - if (hasOwn(value, 'id') && !isRequestId(value.id)) return undefined; - if (hasOwn(value, 'method') && !nonempty(value.method)) return undefined; - if (!hasMethod && hasOwn(value, 'params')) return undefined; - if (hasOwn(value, 'params') && !isJsonValue(value.params)) return undefined; - if (hasOwn(value, 'result') && !isJsonValue(value.result)) return undefined; - if (hasOwn(value, 'error')) { - if (!isRecord(value.error) || typeof value.error.code !== 'number' || !Number.isFinite(value.error.code) || !nonempty(value.error.message)) return undefined; - if (hasOwn(value.error, 'data') && !isJsonValue(value.error.data)) return undefined; + if (!hasMethod && !Object.hasOwn(value, 'id')) return undefined; + if (Object.hasOwn(value, 'id') && !isRequestId(value.id)) return undefined; + if (Object.hasOwn(value, 'method') && !nonempty(value.method)) return undefined; + if (!hasMethod && Object.hasOwn(value, 'params')) return undefined; + if (Object.hasOwn(value, 'params') && !isJsonValue(value.params)) return undefined; + if (Object.hasOwn(value, 'result') && !isJsonValue(value.result)) return undefined; + if (Object.hasOwn(value, 'error')) { + if (!isPlainRecord(value.error) || typeof value.error.code !== 'number' || !Number.isFinite(value.error.code) || !nonempty(value.error.message)) return undefined; + if (Object.hasOwn(value.error, 'data') && !isJsonValue(value.error.data)) return undefined; } return Object.freeze({ - ...(hasOwn(value, 'error') ? { error: Object.freeze({ code: (value.error as Record).code as number, message: (value.error as Record).message as string }) } : {}), - ...(hasOwn(value, 'id') ? { id: value.id as McpAppBridgeRequestId } : {}), + ...(Object.hasOwn(value, 'error') ? { error: Object.freeze({ code: (value.error as Record).code as number, message: (value.error as Record).message as string }) } : {}), + ...(Object.hasOwn(value, 'id') ? { id: value.id as McpAppBridgeRequestId } : {}), jsonrpc: '2.0' as const, - ...(hasOwn(value, 'method') ? { method: value.method as string } : {}), - ...(hasOwn(value, 'params') ? { params: cloneJson(value.params as McpAppJsonValue) } : {}), - ...(hasOwn(value, 'result') ? { result: cloneJson(value.result as McpAppJsonValue) } : {}), + ...(Object.hasOwn(value, 'method') ? { method: value.method as string } : {}), + ...(Object.hasOwn(value, 'params') ? { params: cloneJson(value.params as McpAppJsonValue) } : {}), + ...(Object.hasOwn(value, 'result') ? { result: cloneJson(value.result as McpAppJsonValue) } : {}), }); }; -const isInitialize = (message: McpAppBridgeMessage): boolean => message.method === 'ui/initialize' && hasOwn(message, 'id'); +const isInitialize = (message: McpAppBridgeMessage): boolean => message.method === 'ui/initialize' && Object.hasOwn(message, 'id'); const initializedNotification = (message: McpAppBridgeMessage): boolean => - message.method === 'ui/notifications/initialized' && !hasOwn(message, 'id') + message.method === 'ui/notifications/initialized' && !Object.hasOwn(message, 'id') && (message.params === undefined || jsonRecord(message.params) !== undefined); const validExperimentalCapabilities = (value: unknown): boolean => { @@ -663,7 +648,7 @@ const validCancelled = (params: McpAppJsonValue | undefined): Readonly<{ readonly requestId: McpAppBridgeRequestId; }> | undefined => { const record = jsonRecord(params); - if (record === undefined || !hasOwn(record, 'requestId') || !isRequestId(record.requestId)) return undefined; + if (record === undefined || !Object.hasOwn(record, 'requestId') || !isRequestId(record.requestId)) return undefined; if (record.reason !== undefined && !nonempty(record.reason)) return undefined; return Object.freeze({ requestId: record.requestId, @@ -979,7 +964,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr try { return options.send(Object.freeze({ ...(message.error === undefined ? {} : { error: Object.freeze({ ...message.error }) }), - ...(hasOwn(message, 'id') ? { id: message.id } : {}), + ...(Object.hasOwn(message, 'id') ? { id: message.id } : {}), jsonrpc: '2.0' as const, ...(message.method === undefined ? {} : { method: message.method }), ...(message.params === undefined ? {} : { params: cloneJson(message.params) }), @@ -1433,7 +1418,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr const message = messageOf(value); if (message === undefined) return false; if (lifecycle === 'closing') { - if (hasTeardownId && !hasOwn(message, 'method') && hasOwn(message, 'id') && message.id === teardownId + if (hasTeardownId && !Object.hasOwn(message, 'method') && Object.hasOwn(message, 'id') && message.id === teardownId && (message.result !== undefined || message.error !== undefined)) { finishTeardown?.(); return true; @@ -1489,12 +1474,12 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr return acceptInitialize(message, true); } if (message.method === 'notifications/cancelled') { - if (hasOwn(message, 'id')) return false; + if (Object.hasOwn(message, 'id')) return false; const cancelled = validCancelled(message.params); return cancelled === undefined ? false : cancelInFlight(cancelled.requestId, cancelled.reason); } if (message.method === 'notifications/message') { - if (hasOwn(message, 'id')) return false; + if (Object.hasOwn(message, 'id')) return false; const event = validLog(message.params); if (event === undefined) return false; try { @@ -1505,7 +1490,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr } } if (message.method === 'ui/notifications/size-changed') { - if (hasOwn(message, 'id')) return false; + if (Object.hasOwn(message, 'id')) return false; const size = validSize(message.params); if (size === undefined) return false; try { @@ -1515,7 +1500,7 @@ export const createMcpAppBridge = (options: CreateMcpAppBridgeOptions): McpAppBr return false; } } - if (!hasOwn(message, 'id')) return false; + if (!Object.hasOwn(message, 'id')) return false; return receiveRequest(message); }, }); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts index 7beb87646..43c931f59 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-host-profiles.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; import { relative, resolve } from 'node:path'; +import { isSpecialPurposeIp } from '../../core/special-ip.ts'; import { isPlainRecord } from '../../core/strict-json.ts'; import { @@ -359,14 +360,10 @@ const emptyConfigExtensions: McpAppConfigExtensionInspection = Object.freeze({ const capabilities = new Set(['camera', 'clipboardWrite', 'geolocation', 'microphone']); -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; - -const isConfigExtensionRecord: (value: unknown) => value is Record = isPlainRecord; const cloneRecord = (value: unknown, label: string): { readonly [key: string]: McpAppJsonValue } => { const cloned = cloneMcpAppFiniteJson(value, label); - if (!isRecord(cloned)) throw new TypeError(`${label} must be a finite JSON object.`); + if (!isPlainRecord(cloned)) throw new TypeError(`${label} must be a finite JSON object.`); return cloned; }; @@ -414,13 +411,13 @@ export const inspectMcpAppConfigExtensions = ( const sourceRevision = requireNonempty(options.sourceRevision, 'MCP App config source revision'); const descriptorTargetByKey = new Map(); for (const descriptor of options.descriptors) { - if (!isRecord(descriptor)) throw new TypeError('MCP App config descriptor must be a plain record.'); + if (!isPlainRecord(descriptor)) throw new TypeError('MCP App config descriptor must be a plain record.'); const key = ownNonemptyString(descriptor, 'key', 'MCP App config descriptor key'); const target = ownNonemptyString(descriptor, 'target', 'MCP App config descriptor target'); if (descriptorTargetByKey.has(key)) throw new TypeError(`MCP App config descriptor has duplicate key ${key}.`); descriptorTargetByKey.set(key, target); } - if (!isConfigExtensionRecord(options.extensions)) { + if (!isPlainRecord(options.extensions)) { throw new TypeError('MCP App normalized extensions must have an ordinary or null prototype.'); } for (const key of Object.keys(options.extensions)) { @@ -431,7 +428,7 @@ export const inspectMcpAppConfigExtensions = ( for (const [key, descriptorTarget] of [...descriptorTargetByKey.entries()].sort(([left], [right]) => left.localeCompare(right))) { if (!Object.hasOwn(options.extensions, key)) continue; const extension = options.extensions[key]; - if (!isRecord(extension)) throw new TypeError(`MCP App config extension ${key} must be a plain record.`); + if (!isPlainRecord(extension)) throw new TypeError(`MCP App config extension ${key} must be a plain record.`); const id = ownNonemptyString(extension, 'id', `MCP App config extension ${key} id`); const extensionKey = ownNonemptyString(extension, 'key', `MCP App config extension ${key} key`); const target = ownNonemptyString(extension, 'target', `MCP App config extension ${key} target`); @@ -439,7 +436,7 @@ export const inspectMcpAppConfigExtensions = ( throw new TypeError(`MCP App config extension ${key} does not match its registered descriptor.`); } const provenance = ownDataValue(extension, 'provenance', `MCP App config extension ${key} provenance`); - if (!isRecord(provenance) || ownDataValue(provenance, 'kind', `MCP App config extension ${key} provenance kind`) !== 'config') { + if (!isPlainRecord(provenance) || ownDataValue(provenance, 'kind', `MCP App config extension ${key} provenance kind`) !== 'config') { throw new TypeError(`MCP App config extension ${key} must have config provenance.`); } const sourcePath = ownNonemptyString(provenance, 'sourcePath', `MCP App config extension ${key} provenance source path`); @@ -501,91 +498,10 @@ const validResourceUri = (value: unknown): value is string => { } }; -const hasPrefix = (address: readonly number[], prefix: readonly number[], prefixLength: number): boolean => { - for (let bit = 0; bit < prefixLength; bit += 1) { - const byte = Math.floor(bit / 8); - const mask = 1 << (7 - (bit % 8)); - if ((address[byte] & mask) !== (prefix[byte] & mask)) return false; - } - return true; -}; - -type IpPrefix = readonly [readonly number[], number]; - -const parseIpv4 = (hostname: string): readonly number[] | undefined => { - const octets = hostname.split('.').map(Number); - if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return undefined; - return Object.freeze(octets); -}; - -const parseIpv6 = (hostname: string): readonly number[] | undefined => { - const source = hostname.replace(/^\[|\]$/g, '').toLowerCase(); - const halves = source.split('::'); - if (halves.length > 2) return undefined; - const left = halves[0] === '' ? [] : halves[0].split(':'); - const right = halves.length === 1 || halves[1] === '' ? [] : halves[1].split(':'); - const segments = [...left, ...right]; - const groups = segments.map((group) => Number.parseInt(group, 16)); - if (groups.some((group, index) => !/^[0-9a-f]{1,4}$/.test(segments[index]) || group < 0 || group > 0xffff)) { - return undefined; - } - const missingGroups = 8 - groups.length; - if ((halves.length === 1 && missingGroups !== 0) || (halves.length === 2 && missingGroups < 1)) return undefined; - const expanded = halves.length === 1 ? groups : [...groups.slice(0, left.length), ...Array(missingGroups).fill(0), ...groups.slice(left.length)]; - return Object.freeze(expanded.flatMap((group) => [group >> 8, group & 0xff])); -}; - -const specialIpv4Prefixes: readonly IpPrefix[] = [ - [[0], 8], - [[10], 8], - [[100, 64], 10], - [[127], 8], - [[169, 254], 16], - [[172, 16], 12], - [[192, 0, 0], 24], - [[192, 0, 2], 24], - [[192, 31, 196], 24], - [[192, 52, 193], 24], - [[192, 88, 99], 24], - [[192, 168], 16], - [[192, 175, 48], 24], - [[198, 18], 15], - [[198, 51, 100], 24], - [[203, 0, 113], 24], - [[224], 4], - [[240], 4], -]; - -const specialIpv6Prefixes: readonly IpPrefix[] = [ - [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 96], - [[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff], 96], - [[0, 100, 255, 155, 0, 0, 0, 0, 0, 0, 0, 0], 96], - [[0, 100, 255, 155, 0, 1], 48], - [[1, 0, 0, 0, 0, 0, 0, 0], 64], - [[1, 0, 0, 0, 0, 0, 0, 1], 64], - [[32, 1], 23], - [[32, 1, 13, 184], 32], - [[32, 2], 16], - [[63, 255, 0], 20], - [[95, 0], 16], - [[252], 7], - [[254, 128], 10], - [[255], 8], -]; - -const isSpecialIpv4 = (address: readonly number[]): boolean => - specialIpv4Prefixes.some(([prefix, prefixLength]) => hasPrefix(address, prefix, prefixLength)); - -const isSpecialIpv6 = (address: readonly number[]): boolean => - specialIpv6Prefixes.some(([prefix, prefixLength]) => hasPrefix(address, prefix, prefixLength)); - const isPublicHostname = (hostname: string): boolean => { const normalized = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase(); if (normalized === 'localhost' || normalized.endsWith('.localhost') || normalized.endsWith('.local')) return false; - const ipv4 = parseIpv4(normalized); - if (ipv4 !== undefined) return !isSpecialIpv4(ipv4); - const ipv6 = parseIpv6(normalized); - return ipv6 === undefined || !isSpecialIpv6(ipv6); + return !isSpecialPurposeIp(normalized); }; const claudeDomain = (publicMcpUrl: string): string | undefined => { @@ -613,7 +529,7 @@ const setOwn = (target: Record, key: string, value: Value) const resourceDeclaredDomain = (metadata: McpAppMetadataInspection): string | undefined => { const ui = metadata.standard.ui; - return isRecord(ui) && typeof ui.domain === 'string' ? ui.domain : undefined; + return isPlainRecord(ui) && typeof ui.domain === 'string' ? ui.domain : undefined; }; const inspectProfileMetadata = ( diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts index 1ae71ee23..606705c76 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-sandbox.ts @@ -1,7 +1,8 @@ import { createHmac, randomBytes } from 'node:crypto'; import { createServer, type Server } from 'node:http'; -import { isIP, type Socket } from 'node:net'; +import type { Socket } from 'node:net'; +import { isNonGlobalUnicastIpv6, isSpecialPurposeIp } from '../../core/special-ip.ts'; import { isRecord } from '../../core/strict-json.ts'; import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; @@ -479,63 +480,12 @@ export const createMcpAppConsentAuthority = (options: Readonly<{ readonly now?: const isCapability = (value: unknown): value is McpAppSandboxCapability => isRecord(value); -const specialIpv4 = (host: string): boolean => { - const octets = host.split('.').map(Number); - if (octets.length !== 4 || octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return false; - const [a, b, c] = octets as [number, number, number, number]; - return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) - || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) - || (a === 192 && (b === 0 || b === 2 || b === 88 || b === 168)) || (a === 198 && (b === 18 || b === 19 || b === 51)) - || (a === 203 && b === 0 && c === 113); -}; - -const ipv6Number = (host: string): bigint | undefined => { - const source = host.toLowerCase().replace(/^\[|\]$/gu, ''); - if (isIP(source) !== 6) return undefined; - const [left, right] = source.split('::', 2); - const leftParts = left === undefined || left.length === 0 ? [] : left.split(':'); - const rightParts = right === undefined || right.length === 0 ? [] : right.split(':'); - if (source.includes('::') ? leftParts.length + rightParts.length >= 8 : leftParts.length !== 8) return undefined; - const parts = source.includes('::') - ? [...leftParts, ...Array.from({ length: 8 - leftParts.length - rightParts.length }, () => '0'), ...rightParts] - : leftParts; - let value = 0n; - for (const part of parts) { - if (!/^[0-9a-f]{1,4}$/u.test(part)) return undefined; - value = (value << 16n) | BigInt(`0x${part}`); - } - return value; -}; - -const inIpv6Prefix = (value: bigint, prefix: bigint, bits: number): boolean => { - const width = 128n; - const mask = ((1n << BigInt(bits)) - 1n) << (width - BigInt(bits)); - return (value & mask) === prefix; -}; - -const specialIpv6 = (host: string): boolean => { - const value = ipv6Number(host); - if (value === undefined) return false; - // CSP network authority is fail-closed: only IANA global-unicast 2000::/3 - // can pass. Everything else (site-local, ULA, NAT64, mapped IPv4, etc.) - // remains a special-purpose address even when a parser accepts its syntax. - if (!inIpv6Prefix(value, 0x2000n << 112n, 3)) return true; - // Deny the special-purpose ranges that live inside global-unicast space. - const ranges: readonly (readonly [bigint, number])[] = [ - [0x20010000n << 96n, 23], // IANA 2001::/23 special-purpose block - [0x20010db8n << 96n, 32], // documentation - [0x2002n << 112n, 16], // 6to4 - [0x3fffn << 112n, 20], // RFC 9637 documentation - ]; - return ranges.some(([prefix, bits]) => inIpv6Prefix(value, prefix, bits)); -}; - const prohibitedHost = (value: string): boolean => { const host = value.toLowerCase().replace(/^\[|\]$/gu, ''); if (host === 'localhost' || host.endsWith('.localhost')) return true; - if (isIP(host) === 4) return specialIpv4(host); - if (isIP(host) !== 6) return false; - return specialIpv6(host); + // CSP network authority is fail-closed: only IANA global-unicast 2000::/3 + // can pass, and the special-purpose blocks inside it are denied too. + return isSpecialPurposeIp(host) || isNonGlobalUnicastIpv6(host); }; const cspSources = (sources: readonly string[] | undefined): Readonly<{ accepted: readonly string[]; warnings: readonly McpAppSandboxWarning[] }> => { @@ -666,8 +616,6 @@ const messageSize = (message: unknown): number | undefined => { } }; -const hasOwn = (value: object, key: string): boolean => Object.hasOwn(value, key); - const isRequestId = (value: unknown): value is McpAppSandboxRequestId => value === null || typeof value === 'string' || typeof value === 'number'; const isMessage = (value: unknown, maxMessageBytes: number): value is McpAppSandboxMessage => { @@ -675,20 +623,20 @@ const isMessage = (value: unknown, maxMessageBytes: number): value is McpAppSand const size = messageSize(value); if (size === undefined || size > maxMessageBytes) return false; const hasMethod = typeof value.method === 'string' && value.method.length > 0; - const hasId = hasOwn(value, 'id') && isRequestId(value.id); - return hasMethod || (hasId && (hasOwn(value, 'result') || hasOwn(value, 'error'))); + const hasId = Object.hasOwn(value, 'id') && isRequestId(value.id); + return hasMethod || (hasId && (Object.hasOwn(value, 'result') || Object.hasOwn(value, 'error'))); }; -const isNotification = (message: McpAppSandboxMessage, method: string): boolean => message.method === method && !hasOwn(message, 'id'); +const isNotification = (message: McpAppSandboxMessage, method: string): boolean => message.method === method && !Object.hasOwn(message, 'id'); const isSandboxNotification = (message: McpAppSandboxMessage): boolean => typeof message.method === 'string' && message.method.startsWith(SANDBOX_NOTIFICATION_PREFIX); const isInitializeRequest = (message: McpAppSandboxMessage): message is McpAppSandboxMessage & { readonly id: McpAppSandboxRequestId } => ( - message.method === INITIALIZE_METHOD && hasOwn(message, 'id') && isRequestId(message.id) + message.method === INITIALIZE_METHOD && Object.hasOwn(message, 'id') && isRequestId(message.id) ); const isInitializeResponse = (message: McpAppSandboxMessage, id: McpAppSandboxRequestId | undefined): boolean => ( - !hasOwn(message, 'method') && hasOwn(message, 'id') && message.id === id && (hasOwn(message, 'result') || hasOwn(message, 'error')) + !Object.hasOwn(message, 'method') && Object.hasOwn(message, 'id') && message.id === id && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error')) ); const notification = (method: string, params: unknown = {}): McpAppSandboxMessage => ({ jsonrpc: JSON_RPC_VERSION, method, params }); diff --git a/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts b/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts index 317af170b..f6600eb7f 100644 --- a/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts +++ b/packages/agent-bundle/src/dev/playground/hook-playground-routes.ts @@ -4,6 +4,7 @@ import { CodedError } from '../../core/errors.ts'; import { isRecord } from '../../core/strict-json.ts'; import { isHookSimulationCancellation } from '../../services/hook-service.ts'; import { + badRequest, decodedOpaqueSegment, diagnostic, hasOnly, @@ -13,7 +14,7 @@ import { readJsonBody, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, } from '../http.ts'; import type { HookPlaygroundDiagnosticResult, @@ -73,9 +74,6 @@ export interface HookPlaygroundRoutesOptions { readonly service?: HookPlaygroundRouteService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - const decodedSegment = (segment: string): string => decodedOpaqueSegment(segment, { code: 'AB8030', message: 'Hook playground route path is not valid.' }); @@ -94,9 +92,7 @@ const route = (requestTarget: string | undefined): Route | undefined => { return Object.freeze({ kind: segments[0] }); }; -const invalidShape = (): never => { - throw requestError(diagnostic('AB8032', 'Hook playground request has an invalid shape.', 400)); -}; +const invalidShape = badRequest('AB8032', 'Hook playground request has an invalid shape.'); const jsonBody = (request: IncomingMessage): Promise => readJsonBody(request, { invalidShape }); @@ -227,20 +223,20 @@ export class HookPlaygroundRoutes { const method = request.method ?? 'GET'; if (parsed.kind === 'hooks') { if (method !== 'GET') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); - return responseJson(response, { hooks: await service.list(listQuery(request.url)) }); + return responseJsonOrDestroy(response, { hooks: await service.list(listQuery(request.url)) }); } if (method !== 'POST') return responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); const body = await jsonBody(request); if (parsed.kind === 'simulations') { const options = simulationRequest(body); - return responseJson(response, simulationResponse(await this.#cancellable( + return responseJsonOrDestroy(response, simulationResponse(await this.#cancellable( 'simulation', response, (signal) => service.simulate({ ...options, signal }), ))); } const replay = replayRequest(body); - return responseJson(response, simulationResponse(await this.#cancellable( + return responseJsonOrDestroy(response, simulationResponse(await this.#cancellable( 'replay', response, (signal) => service.replay(replay, { signal }), diff --git a/packages/agent-bundle/src/dev/playground/host-discovery-routes.ts b/packages/agent-bundle/src/dev/playground/host-discovery-routes.ts index ab0566353..f47f203bc 100644 --- a/packages/agent-bundle/src/dev/playground/host-discovery-routes.ts +++ b/packages/agent-bundle/src/dev/playground/host-discovery-routes.ts @@ -3,11 +3,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { HostDiscoveryReport } from '../../contracts/discovery.ts'; import { + badRequest, diagnostic, + noQuery, rawPathname, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, } from '../http.ts'; export const hostDiscoveryResponseLimit = 16 * 1024 * 1024; @@ -22,9 +24,6 @@ export interface HostDiscoveryRoutesOptions { readonly service?: HostDiscoveryRouteService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - const matchesDiscoveryRoute = (requestTarget: string | undefined): boolean => { const pathname = rawPathname(requestTarget); if (pathname === '/api/discovery') return true; @@ -37,11 +36,7 @@ const matchesDiscoveryRoute = (requestTarget: string | undefined): boolean => { return false; }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) { - throw requestError(diagnostic('AB8216', 'Host discovery request is not valid.', 400)); - } -}; +const invalidRequest = badRequest('AB8216', 'Host discovery request is not valid.'); export class HostDiscoveryRoutes { readonly #authorize: (request: IncomingMessage) => void; @@ -65,7 +60,7 @@ export class HostDiscoveryRoutes { if (this.#closed || this.#service === undefined) { throw requestError(diagnostic('AB8218', 'Host discovery is not available.', 503)); } - noQuery(request.url); + noQuery(request.url, invalidRequest); const method = request.method ?? 'GET'; if (method !== 'GET') { responseDiagnostic(response, diagnostic('AB8216', 'Host discovery request is not valid.', 405)); @@ -75,7 +70,7 @@ export class HostDiscoveryRoutes { if (Buffer.byteLength(JSON.stringify(report), 'utf8') > this.#responseByteLimit) { throw requestError(diagnostic('AB8217', 'Host discovery exceeds the 16 MiB response limit.', 413)); } - responseJson(response, report); + responseJsonOrDestroy(response, report); return true; } } diff --git a/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts b/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts index 2dbfcd67e..ee48fcb54 100644 --- a/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts +++ b/packages/agent-bundle/src/dev/playground/lifecycle-replay-routes.ts @@ -9,15 +9,17 @@ import type { } from '../../contracts/lifecycles.ts'; import { isRecord } from '../../core/strict-json.ts'; import { + badRequest, diagnostic, hasOnly, isRequestDiagnostic, + noQuery, nonemptyString, rawPathname, readJsonBody, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, } from '../http.ts'; type Route = 'list' | 'replay'; @@ -39,12 +41,7 @@ export interface LifecycleReplayRoutesOptions { readonly service?: LifecycleReplayRouteService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - -const invalidShape = (): never => { - throw requestError(diagnostic('AB8211', 'Lifecycle replay request has an invalid shape.', 400)); -}; +const invalidShape = badRequest('AB8211', 'Lifecycle replay request has an invalid shape.'); const route = (requestTarget: string | undefined): Route | undefined => { const pathname = rawPathname(requestTarget); @@ -54,10 +51,6 @@ const route = (requestTarget: string | undefined): Route | undefined => { throw requestError(diagnostic('AB8210', 'Lifecycle replay route path is not valid.', 400)); }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); -}; - const replayRequest = (value: JsonObject): LifecycleReplayRequest => { if (!hasOnly(value, ['binding', 'native', 'source'])) return invalidShape(); const { binding, native, source } = value; @@ -134,7 +127,7 @@ export class LifecycleReplayRoutes { if (this.#closed) throw this.#unavailable(503); const service = this.#service; if (service === undefined) throw this.#unavailable(404); - noQuery(request.url); + noQuery(request.url, invalidShape); try { const method = request.method ?? 'GET'; if (parsed === 'list') { @@ -142,7 +135,7 @@ export class LifecycleReplayRoutes { responseDiagnostic(response, diagnostic('AB8007', 'Route does not accept this method.', 405)); return true; } - responseJson(response, await service.list()); + responseJsonOrDestroy(response, await service.list()); return true; } if (method !== 'POST') { @@ -160,7 +153,7 @@ export class LifecycleReplayRoutes { if (Buffer.byteLength(JSON.stringify(result), 'utf8') > this.#responseByteLimit) { throw requestError(diagnostic('AB8214', 'Lifecycle replay exceeds the 16 MiB response limit.', 413)); } - responseJson(response, result); + responseJsonOrDestroy(response, result); return true; } catch (error) { if (isRequestDiagnostic(error)) throw error; diff --git a/packages/agent-bundle/src/dev/playground/mcp-probe-routes.ts b/packages/agent-bundle/src/dev/playground/mcp-probe-routes.ts index a45fb59f8..7dabb8ba1 100644 --- a/packages/agent-bundle/src/dev/playground/mcp-probe-routes.ts +++ b/packages/agent-bundle/src/dev/playground/mcp-probe-routes.ts @@ -3,15 +3,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import type { McpProbeHost, McpProbeReport } from '../../contracts/mcp-probe.ts'; import { + badRequest, diagnostic, hasOnly, isRequestDiagnostic, + noQuery, nonemptyString, rawPathname, readJsonBody, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, } from '../http.ts'; import { McpProbeTargetNotFoundError } from './mcp-probe-service.ts'; @@ -30,9 +32,6 @@ export interface McpProbeRoutesOptions { readonly service?: McpProbeRouteService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - const matchesProbeRoute = (requestTarget: string | undefined): boolean => { const pathname = rawPathname(requestTarget); if (pathname === '/api/discovery/probes') return true; @@ -42,15 +41,7 @@ const matchesProbeRoute = (requestTarget: string | undefined): boolean => { return false; }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) { - throw requestError(diagnostic('AB8220', 'MCP probe request is not valid.', 400)); - } -}; - -const invalidRequest = (): never => { - throw requestError(diagnostic('AB8220', 'MCP probe request is not valid.', 400)); -}; +const invalidRequest = badRequest('AB8220', 'MCP probe request is not valid.'); const isHost = (value: unknown): value is McpProbeHost => value === 'claude' || value === 'codex' || value === 'cursor'; @@ -97,7 +88,7 @@ export class McpProbeRoutes { responseDiagnostic(response, diagnostic('AB8220', 'MCP probe request is not valid.', 405)); return true; } - noQuery(request.url); + noQuery(request.url, invalidRequest); let report: McpProbeReport; try { report = await this.#service.probe(await probeRequest(request)); @@ -111,7 +102,7 @@ export class McpProbeRoutes { if (Buffer.byteLength(JSON.stringify(report), 'utf8') > this.#responseByteLimit) { throw requestError(diagnostic('AB8222', 'MCP probe exceeds the 16 MiB response limit.', 413)); } - responseJson(response, report); + responseJsonOrDestroy(response, report); return true; } } diff --git a/packages/agent-bundle/src/dev/playground/playground-routes.ts b/packages/agent-bundle/src/dev/playground/playground-routes.ts index 686e0eaaa..2fdd0ce00 100644 --- a/packages/agent-bundle/src/dev/playground/playground-routes.ts +++ b/packages/agent-bundle/src/dev/playground/playground-routes.ts @@ -17,16 +17,18 @@ import { type PlaygroundTraceEvent, } from './playground-store.ts'; import { + badRequest, decodedOpaqueSegment, diagnostic, hasOnly, isRequestDiagnostic, + noQuery, nonemptyString, rawPathname, readJsonBody, requestError, responseDiagnostic, - responseJson as writeJsonResponse, + responseJsonOrDestroy, type RequestDiagnostic, } from '../http.ts'; import type { NativePlaygroundCatalog } from './native-playground-service.ts'; @@ -79,9 +81,6 @@ export interface PlaygroundRoutesOptions { readonly service?: PlaygroundRouteService; } -const responseJson = (response: ServerResponse, body: unknown): void => - writeJsonResponse(response, body, { destroyIfEnded: true }); - /** * Service failures stay actionable without republishing their messages, which * can name store paths. Each code keeps one fixed browser-facing sentence. @@ -137,9 +136,7 @@ const route = (requestTarget: string | undefined): Route | undefined => { return sessionRouteKinds.includes(kind) ? Object.freeze({ id, kind }) : undefined; }; -const invalidShape = (): never => { - throw requestError(diagnostic('AB8042', 'Playground request has an invalid shape.', 400)); -}; +const invalidShape = badRequest('AB8042', 'Playground request has an invalid shape.'); const jsonValue = (value: unknown, depth = 0): PlaygroundJsonValue => { if (depth > maxValueDepth) return invalidShape(); @@ -233,10 +230,6 @@ const queryCursor = (requestTarget: string | undefined): number => { return after; }; -const noQuery = (requestTarget: string | undefined): void => { - if (new URL(requestTarget ?? '/', 'http://localhost').searchParams.size > 0) invalidShape(); -}; - const catalogEpoch = (requestTarget: string | undefined): string | undefined => { const query = new URL(requestTarget ?? '/', 'http://localhost').searchParams; if ([...query.keys()].some((key) => key !== 'epochId') || query.getAll('epochId').length > 1) invalidShape(); @@ -298,37 +291,37 @@ export class PlaygroundRoutes { const catalog = service.catalog; if (catalog === undefined) throw this.#unavailable(404); const epochId = catalogEpoch(request.url); - return responseJson(response, { catalog: await catalog.call(service, epochId === undefined ? undefined : { epochId }) }); + return responseJsonOrDestroy(response, { catalog: await catalog.call(service, epochId === undefined ? undefined : { epochId }) }); } if (parsed.kind === 'runs') { if (method !== 'POST') return this.#methodNotAllowed(response); - return responseJson(response, { run: await service.run(operationInput(await jsonBody(request))) }); + return responseJsonOrDestroy(response, { run: await service.run(operationInput(await jsonBody(request))) }); } if (parsed.kind === 'cancel') { if (method !== 'POST') return this.#methodNotAllowed(response); if (!hasOnly(await jsonBody(request), [])) invalidShape(); - return responseJson(response, { cancelled: await service.cancel(parsed.id) }); + return responseJsonOrDestroy(response, { cancelled: await service.cancel(parsed.id) }); } if (parsed.kind === 'session') { if (method === 'GET') { - noQuery(request.url); + noQuery(request.url, invalidShape); // A settled session may have been evicted from memory; reopen restores // it from its durable record before reporting not-found. const session = service.session(parsed.id) ?? await service.reopen?.(parsed.id).catch(() => undefined); if (session === undefined) throw requestError(serviceDiagnostics.PLAYGROUND_SESSION_NOT_FOUND); - return responseJson(response, { session }); + return responseJsonOrDestroy(response, { session }); } return this.#methodNotAllowed(response); } if (parsed.kind === 'replay') { if (method !== 'GET') return this.#methodNotAllowed(response); const replay = await service.replay(parsed.id, { afterSequence: queryCursor(request.url) }); - return responseJson(response, { replay }); + return responseJsonOrDestroy(response, { replay }); } if (parsed.kind === 'export') { if (method !== 'GET') return this.#methodNotAllowed(response); - noQuery(request.url); - return responseJson(response, { export: await service.export(parsed.id) }); + noQuery(request.url, invalidShape); + return responseJsonOrDestroy(response, { export: await service.export(parsed.id) }); } if (parsed.kind === 'stream') { if (method !== 'GET') return this.#methodNotAllowed(response); @@ -337,7 +330,7 @@ export class PlaygroundRoutes { if (method !== 'POST') return this.#methodNotAllowed(response); const body = await jsonBody(request); const draftEvalCase = await service.promoteToDraftEval(parsed.id, rawEventRefsInput(body)); - return responseJson(response, { draftEvalCase }); + return responseJsonOrDestroy(response, { draftEvalCase }); } #methodNotAllowed(response: ServerResponse): void { diff --git a/packages/agent-bundle/src/dev/runtime-mcp-registry.ts b/packages/agent-bundle/src/dev/runtime-mcp-registry.ts index 954ad0599..03a6ccd0d 100644 --- a/packages/agent-bundle/src/dev/runtime-mcp-registry.ts +++ b/packages/agent-bundle/src/dev/runtime-mcp-registry.ts @@ -114,11 +114,6 @@ interface OrphanedConnection { finalization: Promise | undefined; } -interface CombinedAbortSignal { - readonly dispose: () => void; - readonly signal: AbortSignal; -} - interface Subscription { closed: boolean; lastDeliveredSequence: number; @@ -766,13 +761,12 @@ export class RuntimeMcpRegistry implements DevRuntimeProviderMcpRegistry { throw cancellation.reason ?? registryConflict('Runtime MCP session is restarting.'); } const controller = new AbortController(); - const combined = combineSignals([ + const signal = AbortSignal.any([ this.#closeAbort.signal, sessionAbort.signal, controller.signal, ...(operationSignal === undefined ? [] : [operationSignal]), ]); - const signal = combined.signal; let resolveDone!: () => void; const done = new Promise((resolve) => { resolveDone = resolve; }); const operation: OperationRecord = Object.freeze({ controller, done, sessionAbort }); @@ -798,7 +792,6 @@ export class RuntimeMcpRegistry implements DevRuntimeProviderMcpRegistry { vector, }); } finally { - combined.dispose(); record.operations.delete(operation); resolveDone(); await lease.release(); @@ -879,15 +872,14 @@ export class RuntimeMcpRegistry implements DevRuntimeProviderMcpRegistry { if (oldConnection !== undefined) await this.#closeOrRetain(oldConnection); const nextAbort = new AbortController(); record.abort = nextAbort; - const combined = combineSignals([this.#closeAbort.signal, nextAbort.signal]); - try { - const connected = await this.#connectAndRelist(record.descriptor, record.id, combined.signal); - record.connection = connected.connection; - record.connectionState = connected.state; - record.state = 'ready'; - } finally { - combined.dispose(); - } + const connected = await this.#connectAndRelist( + record.descriptor, + record.id, + AbortSignal.any([this.#closeAbort.signal, nextAbort.signal]), + ); + record.connection = connected.connection; + record.connectionState = connected.state; + record.state = 'ready'; } #finalizeRetirement(retirement: RetiredConnectionBatch): Promise { @@ -1160,32 +1152,6 @@ const bindingCopy = (binding: DevRuntimeMcpSessionBinding): DevRuntimeMcpInvalid sessionRevision: binding.sessionRevision, }); -const combineSignals = (signals: readonly AbortSignal[]): CombinedAbortSignal => { - const controller = new AbortController(); - const registrations: Array void]> = []; - let disposed = false; - const dispose = (): void => { - if (disposed) return; - disposed = true; - for (const [signal, listener] of registrations) signal.removeEventListener('abort', listener); - registrations.length = 0; - }; - const abort = (signal: AbortSignal): void => { - if (!controller.signal.aborted) controller.abort(signal.reason); - dispose(); - }; - for (const signal of signals) { - if (signal.aborted) { - abort(signal); - break; - } - const listener = (): void => abort(signal); - registrations.push([signal, listener]); - signal.addEventListener('abort', listener, { once: true }); - } - return Object.freeze({ dispose, signal: controller.signal }); -}; - const finiteConnectionState = (input: DevRuntimeMcpConnectionState): DevRuntimeMcpConnectionState => Object.freeze({ capabilities: input.capabilities === undefined ? undefined : jsonObject(input.capabilities, 'Runtime MCP connection capabilities'), protocolEra: input.protocolEra, diff --git a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts index fac4e68fe..402d580f9 100644 --- a/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/claude-plugin-validation.ts @@ -8,6 +8,7 @@ import { claudeArtifactValidation } from '../adapters/claude.ts'; import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; +import { isRecord } from '../core/strict-json.ts'; import { liftPromise } from '../effect/lift.ts'; import { isPlatformErrno, readFileString, runWithPlatform } from '../effect/platform.ts'; import { @@ -387,9 +388,6 @@ const findingsFromText = (output: string, pluginDirectory: string): readonly Cla return Object.freeze(findings); }; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const findingsFromReportEntry = ( entry: unknown, severity: ClaudeFindingSeverity, diff --git a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts index 7b8c00477..b3bc47bad 100644 --- a/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/cursor-plugin-validation.ts @@ -1,4 +1,4 @@ -import { lstat, readdir, realpath } from 'node:fs/promises'; +import { readdir, realpath } from 'node:fs/promises'; import { dirname, isAbsolute, join, posix, relative, resolve } from 'node:path'; import { Effect, FileSystem, Result } from 'effect'; @@ -15,7 +15,8 @@ import pluginSchema from '../adapters/schemas/cursor/plugin.schema.json' with { import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; -import { isInsideOrEqual } from '../core/paths.ts'; +import { exists, isInsideOrEqual } from '../core/paths.ts'; +import { isRecord } from '../core/strict-json.ts'; import { isPlatformErrno, readFileString, runWithPlatform } from '../effect/platform.ts'; import { runBoundedChildProcess, @@ -69,9 +70,6 @@ export type CursorHooksSource = /** `hooks` is present but neither a string nor an object; the pinned plugin schema already rejects it. */ | Readonly<{ readonly kind: 'invalid' }>; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const resolveCursorDocumentSource = (manifest: unknown, field: string, defaultPath: string): CursorHooksSource => { const declared = isRecord(manifest) ? manifest[field] : undefined; if (declared === undefined) return Object.freeze({ kind: 'default', path: defaultPath }); @@ -275,16 +273,6 @@ const probeCursor = async ( } }; -const pathExists = async (path: string): Promise => { - try { - await lstat(path); - return true; - } catch (error) { - if (isErrno(error, 'ENOENT')) return false; - throw error; - } -}; - const schemaErrorMessage = (path: string, error: ErrorObject): string => { const location = error.instancePath.length === 0 ? '/' : error.instancePath; return `${path}${location}: ${error.message ?? 'schema validation failed'}.`; @@ -458,7 +446,7 @@ const manifestPrecedenceDiagnostics = async ( ): Promise => { const present = await Promise.all(manifestCandidates.map(async (candidate) => ({ candidate, - exists: await pathExists(join(pluginDirectory, candidate)), + exists: await exists(join(pluginDirectory, candidate)), }))); const selected = present.find((entry) => entry.exists)?.candidate; if (selected === undefined || selected === manifestCandidates[0]) return Object.freeze([]); diff --git a/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts b/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts index fd9673e7e..468ec1622 100644 --- a/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts +++ b/packages/agent-bundle/src/host-contracts/portable-plugin-validation.ts @@ -26,6 +26,7 @@ import type { Diagnostic, DiagnosticSeverity } from '../core/diagnostics.ts'; import { freezeDiagnostics } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; import { isInsideOrEqual } from '../core/paths.ts'; +import { isRecord } from '../core/strict-json.ts'; import { isPlatformErrno, readFileString, runWithPlatform } from '../effect/platform.ts'; /** @@ -124,9 +125,6 @@ const diagnostic = ( target, }); -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const displayPath = (root: string, path: string): string => relative(root, path).replaceAll('\\', '/'); const schemaVersion = (identifier: unknown): string | undefined => diff --git a/packages/agent-bundle/src/install-entry.ts b/packages/agent-bundle/src/install-entry.ts index 759db515d..b76bf8efb 100644 --- a/packages/agent-bundle/src/install-entry.ts +++ b/packages/agent-bundle/src/install-entry.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from 'node:url'; import { stableJson } from './core/digest.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; +import { errorMessage } from './core/errors.ts'; import { formatInstallResult, formatUninstallResult } from './install/format.ts'; import { installBundle, @@ -41,7 +42,7 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => ? error.diagnostics : Object.freeze([Object.freeze({ code: 'AB7004', - message: error instanceof Error ? error.message : String(error), + message: errorMessage(error), severity: 'error' as const, })]); diff --git a/packages/agent-bundle/src/mcp-server-runtime.ts b/packages/agent-bundle/src/mcp-server-runtime.ts index a0f122973..478724f16 100644 --- a/packages/agent-bundle/src/mcp-server-runtime.ts +++ b/packages/agent-bundle/src/mcp-server-runtime.ts @@ -29,6 +29,7 @@ import { runAgentRequest, unavailable, } from '@agent-bundle/runtime'; +import { errorMessage } from './core/errors.ts'; import { isRecord } from './core/strict-json.ts'; import type { createEventRuntimeServer, EventRuntimeTransportError } from './events/ipc.ts'; import type { createCanonicalEventProps, projectEventDocument } from './events/project.ts'; @@ -744,8 +745,6 @@ const noticeDiagnostic = (line: string): void => { process.stderr.write(`[agent-bundle] notice inbox ${line}\n`); }; -const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error)); - /** stderr only: stdout is the protocol stream. */ const eventRuntimeDiagnostic = (line: string): void => { process.stderr.write(`agent-bundle event runtime: ${line}\n`); @@ -819,7 +818,7 @@ const installNoticeInboxSubscriptions = ( } catch (error) { throw new ProtocolError( ProtocolErrorCode.InternalError, - `Notice inbox subscriptions are unavailable: ${describeError(error)}`, + `Notice inbox subscriptions are unavailable: ${errorMessage(error)}`, ); } return {}; @@ -840,7 +839,7 @@ const installNoticeInboxSubscriptions = ( case 'signalled': return; case 'failed': - noticeDiagnostic(`resources/updated ${outcome.stage} failed: ${describeError(outcome.error)}`); + noticeDiagnostic(`resources/updated ${outcome.stage} failed: ${errorMessage(outcome.error)}`); return; default: { const unreachable: never = outcome; @@ -860,7 +859,7 @@ const installNoticeInboxSubscriptions = ( let owed = false; const observe = (): void => { observing = notices.observe(send).then(report, (error: unknown) => { - noticeDiagnostic(`resources/updated observation failed: ${describeError(error)}`); + noticeDiagnostic(`resources/updated observation failed: ${errorMessage(error)}`); }).then(() => { observing = undefined; if (!owed) return; diff --git a/packages/agent-bundle/src/mcp-tasks.ts b/packages/agent-bundle/src/mcp-tasks.ts index 69f1f4d40..20214bad4 100644 --- a/packages/agent-bundle/src/mcp-tasks.ts +++ b/packages/agent-bundle/src/mcp-tasks.ts @@ -52,6 +52,8 @@ import { } from '@modelcontextprotocol/server'; import type { McpProgressNotificationParams } from '@agent-bundle/runtime'; +import { errorMessage } from './core/errors.ts'; +import { isRecord } from './core/strict-json.ts'; import type { ToolTaskSupport } from './routes/public.ts'; /** The one protocol revision whose core specification defines the Tasks utility. */ @@ -106,9 +108,6 @@ type RequestHandler = (request: JSONRPCRequest, ctx: ServerContext) => Promise status === 'completed' || status === 'failed' || status === 'cancelled'; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const now = (): string => new Date().toISOString(); /** A Standard Schema for the params of one task request, without a schema library dependency. */ @@ -149,14 +148,12 @@ const clampPollInterval = (requested: unknown): number => { return Math.max(Math.floor(requested), MIN_MCP_TASK_POLL_INTERVAL_MS); }; -const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error)); - const errorOutcome = (error: unknown): TaskOutcome => { if (ProtocolError.isInstance(error)) { return { code: error.code, ...(error.data === undefined ? {} : { data: error.data }), kind: 'error', message: error.message }; } const code = isRecord(error) && Number.isSafeInteger(error['code']) ? (error['code'] as number) : ProtocolErrorCode.InternalError; - return { code, kind: 'error', message: describeError(error) }; + return { code, kind: 'error', message: errorMessage(error) }; }; /** The page size of `tasks/list`; the cursor is the sequence of the last task returned. */ @@ -491,7 +488,7 @@ export class TaskAugmentedServer extends Server { }, (error: unknown) => { record.outcome = errorOutcome(error); - this.#transition(record, 'failed', describeError(error)); + this.#transition(record, 'failed', errorMessage(error)); }, ).finally(settle); diff --git a/packages/agent-bundle/src/test/packed.ts b/packages/agent-bundle/src/test/packed.ts index b03d03620..3b87858bd 100644 --- a/packages/agent-bundle/src/test/packed.ts +++ b/packages/agent-bundle/src/test/packed.ts @@ -13,12 +13,12 @@ * 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 { readdir, rm } from 'node:fs/promises'; import { isAbsolute, relative, resolve, sep } from 'node:path'; import type { Client } from '@modelcontextprotocol/client'; -import { isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; import { AgentTestError } from './errors.ts'; import { PACKED_DELETED_SOURCE_PROOF_LEVEL, @@ -98,16 +98,6 @@ const loadSdk = async (): Promise => { return sdkPromise; }; -const pathExists = async (path: string): Promise => { - try { - await lstat(path); - return true; - } catch (error) { - if (isErrno(error, 'ENOENT')) return false; - throw error; - } -}; - const deletedSourceError = ( message: string, options: { @@ -192,7 +182,7 @@ const verifyDeletedSourceReceipt = async (receipt: DeletedSourceReceipt): Promis let survived: readonly string[]; try { survived = ( - await Promise.all(paths.map(async (path) => ({ ...path, exists: await pathExists(path.absolute) }))) + await Promise.all(paths.map(async (path) => ({ ...path, exists: await exists(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.', { @@ -247,7 +237,7 @@ export const removeProjectSource = async (options: { const existing: readonly [string, string][] = ( await Promise.all( [...candidates].map(async ([relativePath, absolute]) => ( - await pathExists(absolute) ? [relativePath, absolute] as const : undefined + await exists(absolute) ? [relativePath, absolute] as const : undefined )), ) ).filter((entry): entry is [string, string] => entry !== undefined); diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index d2d65ff84..d80e52f44 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -9,7 +9,7 @@ import ts from 'typescript-5'; import { claudeAdapter } from '../src/adapters/claude.ts'; import { cursorHookWrapperSource, nativeHookWrapperSource, type TargetHookWrapper } from '../src/adapters/hook-contract.ts'; import type { NoticeDeliveryAdvertisement } from '../src/adapters/notice-delivery.ts'; -import { scanEntryExportsSource, stripCommentsAndStrings } from '../src/build/entry-exports.ts'; +import { scanEntryExportsSource } from '../src/build/entry-exports.ts'; import * as entryShellModule from '../src/build/entry-shell.ts'; import { launchEnvLayerSpecifier, operatorEnvLayerImport, operatorEnvLayerModuleSource, operatorEnvLayerVirtualModule } from '../src/build/launch-env-shell.ts'; import { stableJson } from '../src/core/digest.ts'; @@ -71,7 +71,7 @@ describe('entry export scanning', () => { it('survives regex literals containing slashes', () => { const source = "const re = /https:\\/\\//u; export default re;"; expect(scanEntryExportsSource(source).hasDefaultExport).toBe(true); - expect(stripCommentsAndStrings('const division = a / b / c; export const main = 1;')).toContain('export const main'); + expect(scanEntryExportsSource('const division = a / b / c; export const main = 1;').hasMainExport).toBe(true); }); it('handles TypeScript syntax the JS lexers cannot parse', () => { From 9af572bf8e1ad102cc712f73818c621859cc65e4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sun, 6 Sep 2026 00:44:33 +0000 Subject: [PATCH 2/2] Scan entry exports with the file's grammar and skip ambient declarations --- packages/agent-bundle/src/build/entry-exports.ts | 9 +++++---- packages/agent-bundle/src/config/validate.ts | 4 ++-- packages/agent-bundle/tests/entry-shell.test.ts | 11 +++++++++++ 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/src/build/entry-exports.ts b/packages/agent-bundle/src/build/entry-exports.ts index 71df0dd2f..723870c86 100644 --- a/packages/agent-bundle/src/build/entry-exports.ts +++ b/packages/agent-bundle/src/build/entry-exports.ts @@ -26,8 +26,9 @@ const declaresMain = (statement: ts.Statement): boolean => { return false; }; -export const scanEntryExportsSource = (source: string): EntryExportScan => { - const file = ts.createSourceFile('entry.ts', source, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); +/** `fileName` selects the grammar (`.tsx`/`.jsx` parse JSX; `.ts` keeps angle-bracket assertions). */ +export const scanEntryExportsSource = (source: string, fileName = 'entry.ts'): EntryExportScan => { + const file = ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, false); let hasDefaultExport = false; let hasMainExport = false; for (const statement of file.statements) { @@ -45,7 +46,7 @@ export const scanEntryExportsSource = (source: string): EntryExportScan => { } continue; } - if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword)) continue; + if (!hasModifier(statement, ts.SyntaxKind.ExportKeyword) || hasModifier(statement, ts.SyntaxKind.DeclareKeyword)) continue; if (hasModifier(statement, ts.SyntaxKind.DefaultKeyword)) hasDefaultExport = true; else if (declaresMain(statement)) hasMainExport = true; } @@ -53,4 +54,4 @@ export const scanEntryExportsSource = (source: string): EntryExportScan => { }; export const scanEntryExports = async (source: string): Promise => - scanEntryExportsSource(await readFile(source, 'utf8')); + scanEntryExportsSource(await readFile(source, 'utf8'), source); diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 90375766c..645aec2a9 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -632,7 +632,7 @@ const selfConnectingEntryNudge = ( : conventionalEntry; if (source === undefined || !bundleScriptExtensions.has(extname(source).toLowerCase())) return []; try { - if (scanEntryExportsSource(readFileSync(source, 'utf8')).hasDefaultExport) return []; + if (scanEntryExportsSource(readFileSync(source, 'utf8'), source).hasDefaultExport) return []; } catch { // An unreadable entry is already reported by the existence diagnostics. return []; @@ -2141,7 +2141,7 @@ const explicitBinNamesBySource = (loaded: LoadedConfig): ReadonlyMap { try { - return scanEntryExportsSource(readFileSync(source, 'utf8')); + return scanEntryExportsSource(readFileSync(source, 'utf8'), source); } catch { return undefined; } diff --git a/packages/agent-bundle/tests/entry-shell.test.ts b/packages/agent-bundle/tests/entry-shell.test.ts index d80e52f44..538d7c893 100644 --- a/packages/agent-bundle/tests/entry-shell.test.ts +++ b/packages/agent-bundle/tests/entry-shell.test.ts @@ -68,6 +68,17 @@ describe('entry export scanning', () => { expect(scanEntryExportsSource('const t = `a ${`b ${1} export default c`} d`;').hasDefaultExport).toBe(false); }); + it('parses JSX by file name and ignores ambient declarations', () => { + const tsx = 'const view =
export default nothing
;\nexport default () => view;'; + expect(scanEntryExportsSource(tsx, '/app/entry.tsx').hasDefaultExport).toBe(true); + expect(scanEntryExportsSource('const n = 1; export const main = n;', '/app/entry.ts').hasMainExport).toBe(true); + expect(scanEntryExportsSource('export declare const main: number;')).toEqual({ + hasDefaultExport: false, + hasMainExport: false, + }); + expect(scanEntryExportsSource('export declare function main(): void;').hasMainExport).toBe(false); + }); + it('survives regex literals containing slashes', () => { const source = "const re = /https:\\/\\//u; export default re;"; expect(scanEntryExportsSource(source).hasDefaultExport).toBe(true);