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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ponytail-dedupe-helpers.md
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 7 additions & 9 deletions packages/agent-bundle/src/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -702,7 +700,7 @@ export const createAppClient = (options: CreateAppClientOptions = {}): AppClient
},
async rebind(rebindOptions: AppConnectOptions = {}): Promise<AppInitializeResult> {
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;
Expand Down
167 changes: 34 additions & 133 deletions packages/agent-bundle/src/build/entry-exports.ts
Original file line number Diff line number Diff line change
@@ -1,156 +1,57 @@
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;
/** `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) {
if (ts.isExportAssignment(statement)) {
hasDefaultExport ||= !statement.isExportEquals;
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;
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;
while (index < length && /[a-z]/iu.test(source[index]!)) index += 1;
output += ' ';
lastCode = ' ';
continue;
}
if (char === "'" || char === '"') {
index += 1;
while (index < length && source[index] !== char) {
index += source[index] === '\\' ? 2 : 1;
}
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;
}

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<string>();
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);
}
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;
}

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 });
};

export const scanEntryExports = async (source: string): Promise<EntryExportScan> =>
scanEntryExportsSource(await readFile(source, 'utf8'));
scanEntryExportsSource(await readFile(source, 'utf8'), source);
3 changes: 2 additions & 1 deletion packages/agent-bundle/src/config/command.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -116,7 +117,7 @@ export const parseCommand = async (source: string): Promise<CommandDocument> =>
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: {},
Expand Down
5 changes: 3 additions & 2 deletions packages/agent-bundle/src/config/dev-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
});
}
Expand Down Expand Up @@ -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))]),
});
}
};
7 changes: 1 addition & 6 deletions packages/agent-bundle/src/config/notice-retention.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -48,12 +49,6 @@ export const parseNoticeRetentionDuration = (value: unknown): number | undefined
return Number.isSafeInteger(milliseconds) ? milliseconds : undefined;
};

const isPlainRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
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 => ({
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/src/config/render-markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -55,7 +56,7 @@ const resolveNode = async (node: unknown, depth = 0): Promise<unknown> => {
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);
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/src/config/rule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -116,7 +117,7 @@ export const parseRule = async (source: string): Promise<RuleDocument> => {
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: '',
Expand Down
6 changes: 3 additions & 3 deletions packages/agent-bundle/src/config/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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,
},
],
Expand Down
4 changes: 2 additions & 2 deletions packages/agent-bundle/src/config/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -2141,7 +2141,7 @@ const explicitBinNamesBySource = (loaded: LoadedConfig): ReadonlyMap<string, rea
*/
const scriptEntryExports = (source: string): EntryExportScan | undefined => {
try {
return scanEntryExportsSource(readFileSync(source, 'utf8'));
return scanEntryExportsSource(readFileSync(source, 'utf8'), source);
} catch {
return undefined;
}
Expand Down
Loading
Loading