From e9c3cb5f40f7322d402b31f897ef47abb0e68664 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 06:35:39 +0000 Subject: [PATCH 1/6] refactor: collapse duplicated guards and helpers onto canonical modules Round 1 of a repo-wide simplification pass: ~30 hand-rolled isRecord copies now import the strict-json/client-helpers canonicals, freeze.ts and sameRuntimeBinding each get one home, validateCommands/validateRules merge into one parameterized validator, Effect.gen wrappers move to Effect.fnUntraced per the v4 docs, and dead branches in the workbench MCP client are removed. Also caches sqlite prepared statements per store, parallelizes dev rebuild directory walks, and adds reconnect backoff plus a consent-poll render guard in the Workbench. --- .../src/dev/canonical-json.ts | 24 +++ .../src/dev/generation-materializer.ts | 20 +- .../src/dev/rsbuild-runtime-session.ts | 18 +- packages/agent-bundle/src/cli.ts | 3 +- packages/agent-bundle/src/config/discover.ts | 3 +- packages/agent-bundle/src/config/normalize.ts | 3 +- .../agent-bundle/src/config/rendered-skill.ts | 10 +- .../src/config/skill-references.ts | 4 +- packages/agent-bundle/src/config/validate.ts | 194 +++++++----------- packages/agent-bundle/src/contracts/freeze.ts | 5 + .../agent-bundle/src/contracts/mcp-apps.ts | 1 + .../agent-bundle/src/contracts/strict-json.ts | 1 + packages/agent-bundle/src/core/async.ts | 3 + packages/agent-bundle/src/core/errors.ts | 4 + .../src/dev/artifacts/artifact-service.ts | 4 +- packages/agent-bundle/src/dev/coordinator.ts | 7 +- packages/agent-bundle/src/dev/dev-lock.ts | 5 +- .../src/dev/inspector-launcher.ts | 5 +- .../dev/mcp-app-runtime-preview-service.ts | 5 +- .../dev/mcp-apps/mcp-app-binding-service.ts | 3 +- .../src/dev/mcp-apps/mcp-app-routes.ts | 5 +- .../src/dev/mcp-apps/mcp-app-sandbox.ts | 11 +- .../playground/native-playground-service.ts | 5 +- .../src/dev/playground/playground-store.ts | 84 ++++---- .../playground/playground-subscriptions.ts | 3 +- .../agent-bundle/src/dev/project-service.ts | 14 +- .../src/dev/runtime-controller.ts | 5 +- .../src/dev/runtime-mcp-registry.ts | 4 +- .../src/dev/runtime-mcp-routes.ts | 4 +- .../agent-bundle/src/dev/runtime-routes.ts | 6 +- packages/agent-bundle/src/dev/watcher.ts | 9 +- packages/agent-bundle/src/eval/run-store.ts | 5 +- packages/agent-bundle/src/events/ipc.ts | 4 +- .../host-contracts/native-claude-contract.ts | 11 +- packages/agent-bundle/src/install/install.ts | 17 +- packages/agent-bundle/src/test/packed.ts | 3 +- packages/rsc-runtime/src/notices/ledger.ts | 8 +- .../rsc-runtime/src/state/memory-driver.ts | 21 +- packages/rsc-runtime/src/state/sqlite.ts | 50 +++-- .../src/artifacts/artifact-client.ts | 4 +- .../src/comparisons/comparison-client.ts | 4 +- packages/workbench/src/evals/eval-client.ts | 17 +- packages/workbench/src/freeze.ts | 17 +- packages/workbench/src/hooks/hook-client.ts | 4 +- packages/workbench/src/logs/log-client.ts | 30 +-- packages/workbench/src/logs/logs-page.tsx | 12 +- packages/workbench/src/main.tsx | 18 +- packages/workbench/src/mcp/mcp-app-client.ts | 64 ++---- packages/workbench/src/mcp/mcp-app-frame.tsx | 5 +- packages/workbench/src/mcp/mcp-json-input.tsx | 4 +- packages/workbench/src/mcp/mcp-page.tsx | 13 +- .../workbench/src/mcp/mcp-route-client.ts | 22 +- .../src/mcp/mcp-session-controller.ts | 11 +- .../src/playground/playground-client.ts | 4 +- .../src/playground/playground-page.tsx | 3 +- packages/workbench/src/project-client.ts | 4 +- .../src/routes/route-manifest-client.ts | 4 +- packages/workbench/src/runtime-client.ts | 5 +- packages/workbench/src/runtime-playground.tsx | 7 +- packages/workbench/src/strict-json.ts | 1 + .../workbench/src/workbench-capabilities.ts | 4 +- 61 files changed, 370 insertions(+), 478 deletions(-) create mode 100644 examples/rsc-agent-runtime/src/dev/canonical-json.ts create mode 100644 packages/agent-bundle/src/contracts/freeze.ts diff --git a/examples/rsc-agent-runtime/src/dev/canonical-json.ts b/examples/rsc-agent-runtime/src/dev/canonical-json.ts new file mode 100644 index 000000000..25ef09d60 --- /dev/null +++ b/examples/rsc-agent-runtime/src/dev/canonical-json.ts @@ -0,0 +1,24 @@ +import { createHash } from 'node:crypto'; + +/** + * Key-sorted, undefined-skipping canonical JSON used for runtime metadata + * digests. Throws on non-finite numbers and non-JSON values so digests can + * never silently diverge between writers. + */ +export const canonicalJson = (value: unknown): string => { + if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; + if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); + const record = value as Record; + return `{${Object.keys(record).sort().flatMap((key) => { + const item = record[key]; + return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; + }).join(',')}}`; +}; + +export const digestValue = (value: unknown): string => + createHash('sha256').update(canonicalJson(value)).digest('hex'); diff --git a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts index da8102364..8f7a75b67 100644 --- a/examples/rsc-agent-runtime/src/dev/generation-materializer.ts +++ b/examples/rsc-agent-runtime/src/dev/generation-materializer.ts @@ -3,6 +3,7 @@ import { open, lstat, mkdir, readdir, readFile, writeFile } from 'node:fs/promis import { spawn } from 'node:child_process'; import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { canonicalJson, digestValue } from './canonical-json.js'; import { emitRuntimeArtifacts } from '../build/emit-artifacts.js'; import type { RscEnvironmentCheckpointValidator, @@ -90,25 +91,6 @@ export interface MaterializeRuntimeGenerationOptions { const digestBytes = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); -const canonicalJson = (value: unknown): string => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); - return JSON.stringify(value); - } - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); - - const input = value as Record; - return `{${Object.keys(input).sort().flatMap((key) => { - const item = input[key]; - return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; - }).join(',')}}`; -}; - -const digestValue = (value: unknown): string => - createHash('sha256').update(canonicalJson(value)).digest('hex'); - const freezeJson = (value: unknown, seen = new WeakSet()): JsonValue => { if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; if (typeof value === 'number') { diff --git a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts index 3ac712466..a3b1fb5f7 100644 --- a/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts +++ b/examples/rsc-agent-runtime/src/dev/rsbuild-runtime-session.ts @@ -18,6 +18,7 @@ import { type RscEnvironmentCheckpointStore, type RscRuntimeEnvironmentName, } from './environment-checkpoint-store.js'; +import { canonicalJson, digestValue } from './canonical-json.js'; import { captureRuntimeGenerationSnapshot, materializeRuntimeGeneration, @@ -508,23 +509,6 @@ const validateAppBinding = (value: unknown): void => { const clonePrepared = (prepared: DevRuntimePreparedProject): DevRuntimePreparedProject => deepFreeze(structuredClone(prepared)); -const canonicalJson = (value: unknown): string => { - if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value); - if (typeof value === 'number') { - if (!Number.isFinite(value)) throw new TypeError('Runtime metadata contains a non-finite number.'); - return JSON.stringify(value); - } - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`; - if (typeof value !== 'object') throw new TypeError('Runtime metadata is not JSON serializable.'); - const record = value as Record; - return `{${Object.keys(record).sort().flatMap((key) => { - const item = record[key]; - return item === undefined ? [] : [`${JSON.stringify(key)}:${canonicalJson(item)}`]; - }).join(',')}}`; -}; - -const digestValue = (value: unknown): string => createHash('sha256').update(canonicalJson(value)).digest('hex'); - const transportDigest = (prepared: DevRuntimePreparedProject): string => digestValue({ provider: prepared.provider, servers: prepared.servers.map((server) => ({ diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 06d22987d..bf2e546de 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -33,6 +33,7 @@ import type { } from './install/doctor.ts'; import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts'; import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts'; +import { errorMessage } from './core/errors.ts'; import { projectVersionLabel } from './core/project-context.ts'; import { stableJson } from './core/digest.ts'; import type { EvalComparisonDelta, EvalConditionMetrics } from './eval/compare.ts'; @@ -250,7 +251,7 @@ const diagnosticsFor = (error: unknown): readonly Diagnostic[] => { if (error instanceof DiagnosticError) return error.diagnostics; return [{ code: 'AB5000', - message: error instanceof Error ? error.message : String(error), + message: errorMessage(error), severity: 'error', }]; }; diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 36ad03cd2..f95fa7d0f 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -3,6 +3,7 @@ import { basename, dirname, relative, resolve } from 'node:path'; import fastGlob from 'fast-glob'; +import { isErrno } from '../core/errors.ts'; import { isInside } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; @@ -89,7 +90,7 @@ const discoverState = async ( try { moduleText = await readFile(source, 'utf8'); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + if (isErrno(error, 'ENOENT')) return undefined; throw error; } const extracted = extractStateDefinition(moduleText, 'src/state.ts', source); diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 740056c51..29e140241 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -4,6 +4,7 @@ import { readFile, readdir, realpath, stat } from 'node:fs/promises'; import { basename, dirname, extname, posix, relative, resolve, sep, win32 } from 'node:path'; import { digest } from '../core/digest.ts'; +import { isErrno } from '../core/errors.ts'; import { deepFreeze } from '../core/freeze.ts'; import { isInside } from '../core/paths.ts'; import { @@ -586,7 +587,7 @@ const normalizeNativeHooks = async ( }); } catch (error) { nativeHooks.push({ - issue: (error as NodeJS.ErrnoException).code === 'ENOENT' ? 'missing' : 'parse', + issue: isErrno(error, 'ENOENT') ? 'missing' : 'parse', provenance: { ...provenance }, source, target, diff --git a/packages/agent-bundle/src/config/rendered-skill.ts b/packages/agent-bundle/src/config/rendered-skill.ts index 1670e4c4f..5d8f5dc08 100644 --- a/packages/agent-bundle/src/config/rendered-skill.ts +++ b/packages/agent-bundle/src/config/rendered-skill.ts @@ -5,6 +5,7 @@ import { createJiti } from 'jiti'; import { stringify as stringifyYaml } from 'yaml'; import type { Diagnostic } from '../core/diagnostics.ts'; +import { errorMessage } from '../core/errors.ts'; import { MarkdownRenderError, renderElementToMarkdown } from './render-markdown.ts'; /** @@ -56,9 +57,6 @@ const isPlainRecord = (value: unknown): value is Record => { return prototype === Object.prototype || prototype === null; }; -const describeError = (error: unknown): string => - error instanceof Error ? error.message : String(error); - /** * Loads and compiles one rendered skill source to its Markdown document. The * module executes through the same jiti pipeline that already runs consumer @@ -76,7 +74,7 @@ export const compileRenderedSkill = async (source: string): Promise>(source); } catch (error) { - return failure('AB3003', `Rendered Skill module failed to load: ${describeError(error)}`, source); + return failure('AB3003', `Rendered Skill module failed to load: ${errorMessage(error)}`, source); } const component = moduleExports.default; @@ -104,7 +102,7 @@ export const compileRenderedSkill = async (source: string): Promise => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isPlainRecord = (value: object): value is Record => { const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; @@ -330,7 +328,8 @@ const bundleScriptExtensions = new Set([ '.js', '.jsx', '.mjs', '.cjs', '.ts', '.tsx', '.mts', '.cts', ]); -const isSafeScriptName = (name: string): boolean => +/** Shared name guard for scripts, package outputs, and payload destinations. */ +const isSafeOutputName = (name: string): boolean => /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(name); const validateScripts = ( @@ -346,7 +345,7 @@ const validateScripts = ( const diagnostics: Diagnostic[] = []; const outputSources = new Map(); for (const [name, rawDeclaration] of Object.entries(scripts)) { - if (!isSafeScriptName(name)) { + if (!isSafeOutputName(name)) { diagnostics.push(sourceDiagnostic( 'AB4401', `Script name ${JSON.stringify(name)} must be a safe stable output name.`, @@ -867,27 +866,21 @@ const validateMcp = ( }); }; +const portableFrontmatterKeys = [ + 'allowed-tools', + 'compatibility', + 'description', + 'license', + 'metadata', + 'name', +] as const; + const validateSkill = (skill: SkillDocument): Diagnostic[] => { - const portableIssues = validateAgentSkillsFrontmatter({ - ...(Object.hasOwn(skill.frontmatter, 'allowed-tools') - ? { 'allowed-tools': skill.frontmatter['allowed-tools'] } - : {}), - ...(Object.hasOwn(skill.frontmatter, 'compatibility') - ? { compatibility: skill.frontmatter.compatibility } - : {}), - ...(Object.hasOwn(skill.frontmatter, 'description') - ? { description: skill.frontmatter.description } - : {}), - ...(Object.hasOwn(skill.frontmatter, 'license') - ? { license: skill.frontmatter.license } - : {}), - ...(Object.hasOwn(skill.frontmatter, 'metadata') - ? { metadata: skill.frontmatter.metadata } - : {}), - ...(Object.hasOwn(skill.frontmatter, 'name') - ? { name: skill.frontmatter.name } - : {}), - }); + const portableIssues = validateAgentSkillsFrontmatter(Object.fromEntries( + portableFrontmatterKeys + .filter((key) => Object.hasOwn(skill.frontmatter, key)) + .map((key) => [key, skill.frontmatter[key]]), + )); const ir = parseSkillIr(skill); const diagnostics = [...ir.diagnostics]; const name = ir.portable.name ?? skill.frontmatter.name; @@ -927,48 +920,65 @@ const validateSkill = (skill: SkillDocument): Diagnostic[] => { return diagnostics; }; -const validateCommands = ( +interface TargetedDocument { + readonly authoredTargets?: readonly string[]; + readonly diagnostics: readonly Diagnostic[]; + readonly source: string; +} + +interface TargetedDocumentCodes { + /** Explicit target whose capability is degraded, prohibited, or unavailable. */ + readonly capability: string; + readonly duplicateName: string; + readonly outsideTargets: string; +} + +/** Shared validator for commands and rules, which differ only in label and codes. */ +const validateTargetedDocuments = ( loaded: LoadedConfig, - discovered: DiscoveredProject, + documents: readonly TargetedDocument[], + label: 'Command' | 'Rule', + capabilityName: 'commands' | 'rules', + codes: TargetedDocumentCodes, registry: NormalizationTargetRegistry, ): Diagnostic[] => { const diagnostics: Diagnostic[] = []; const selectedTargets = selectedTargetNamesFor(loaded, registry); const names = new Map(); - for (const command of discovered.commands ?? []) { - diagnostics.push(...command.diagnostics); - const name = basename(command.source, extname(command.source)); + for (const document of documents) { + diagnostics.push(...document.diagnostics); + const name = basename(document.source, extname(document.source)); const firstSource = names.get(name); if (firstSource === undefined) { - names.set(name, command.source); + names.set(name, document.source); } else { diagnostics.push(sourceDiagnostic( - 'AB4926', - `Command name ${JSON.stringify(name)} duplicates ${firstSource}.`, - command.source, + codes.duplicateName, + `${label} name ${JSON.stringify(name)} duplicates ${firstSource}.`, + document.source, )); } - for (const target of command.authoredTargets ?? []) { + for (const target of document.authoredTargets ?? []) { if (!registry.has(target) || !selectedTargets.includes(target)) { diagnostics.push({ - code: 'AB4924', - message: `Command ${JSON.stringify(name)} selects target ${JSON.stringify(target)} outside the selected target names.`, + code: codes.outsideTargets, + message: `${label} ${JSON.stringify(name)} selects target ${JSON.stringify(target)} outside the selected target names.`, severity: 'error', - sourcePath: command.source, + sourcePath: document.source, target, }); continue; } - const capability = registry.capabilityState?.(target, 'commands'); + const capability = registry.capabilityState?.(target, capabilityName); if (capability === undefined) { - if (registry.supports(target, 'commands')) continue; + if (registry.supports(target, capabilityName)) continue; diagnostics.push({ - code: 'AB4925', - message: `Command ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose commands capability is unavailable: the target declares no supported commands surface.`, + code: codes.capability, + message: `${label} ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose ${capabilityName} capability is unavailable: the target declares no supported ${capabilityName} surface.`, severity: 'error', - sourcePath: command.source, + sourcePath: document.source, target, }); continue; @@ -980,10 +990,10 @@ const validateCommands = ( case 'prohibited': case 'unavailable': diagnostics.push({ - code: 'AB4925', - message: `Command ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose commands capability is ${capability.state}: ${capability.reason}`, + code: codes.capability, + message: `${label} ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose ${capabilityName} capability is ${capability.state}: ${capability.reason}`, severity: 'error', - sourcePath: command.source, + sourcePath: document.source, target, }); break; @@ -997,78 +1007,27 @@ const validateCommands = ( return diagnostics; }; -const validateRules = ( +const validateCommands = ( loaded: LoadedConfig, discovered: DiscoveredProject, registry: NormalizationTargetRegistry, -): Diagnostic[] => { - const diagnostics: Diagnostic[] = []; - const selectedTargets = selectedTargetNamesFor(loaded, registry); - const names = new Map(); +): Diagnostic[] => + validateTargetedDocuments(loaded, discovered.commands ?? [], 'Command', 'commands', { + capability: 'AB4925', + duplicateName: 'AB4926', + outsideTargets: 'AB4924', + }, registry); - for (const rule of discovered.rules ?? []) { - diagnostics.push(...rule.diagnostics); - const name = basename(rule.source, extname(rule.source)); - const firstSource = names.get(name); - if (firstSource === undefined) { - names.set(name, rule.source); - } else { - diagnostics.push(sourceDiagnostic( - 'AB4906', - `Rule name ${JSON.stringify(name)} duplicates ${firstSource}.`, - rule.source, - )); - } - - for (const target of rule.authoredTargets ?? []) { - if (!registry.has(target) || !selectedTargets.includes(target)) { - diagnostics.push({ - code: 'AB4904', - message: `Rule ${JSON.stringify(name)} selects target ${JSON.stringify(target)} outside the selected target names.`, - severity: 'error', - sourcePath: rule.source, - target, - }); - continue; - } - const capability = registry.capabilityState?.(target, 'rules'); - if (capability === undefined) { - if (registry.supports(target, 'rules')) continue; - diagnostics.push({ - code: 'AB4905', - message: `Rule ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose rules capability is unavailable: the target declares no supported rules surface.`, - severity: 'error', - sourcePath: rule.source, - target, - }); - continue; - } - switch (capability.state) { - case 'supported': - break; - case 'degraded': - case 'prohibited': - case 'unavailable': - diagnostics.push({ - code: 'AB4905', - message: `Rule ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose rules capability is ${capability.state}: ${capability.reason}`, - severity: 'error', - sourcePath: rule.source, - target, - }); - break; - default: { - const exhaustive: never = capability; - return exhaustive; - } - } - } - } - return diagnostics; -}; - -const isSafePackageOutputName = (name: string): boolean => - /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(name); +const validateRules = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, + registry: NormalizationTargetRegistry, +): Diagnostic[] => + validateTargetedDocuments(loaded, discovered.rules ?? [], 'Rule', 'rules', { + capability: 'AB4905', + duplicateName: 'AB4906', + outsideTargets: 'AB4904', + }, registry); const validatePackageEntryPath = ( label: string, @@ -1101,7 +1060,7 @@ const validateBin = (loaded: LoadedConfig): Diagnostic[] => { } const diagnostics: Diagnostic[] = []; for (const [name, rawDeclaration] of Object.entries(bin)) { - if (!isSafePackageOutputName(name)) { + if (!isSafeOutputName(name)) { diagnostics.push(sourceDiagnostic( 'AB4701', `Bin name ${JSON.stringify(name)} must be a safe stable output name.`, @@ -1332,9 +1291,6 @@ const warningDiagnostic = ( recovery: string, ): Diagnostic => ({ code, message, recovery, severity: 'warning', sourcePath }); -const isSafePayloadName = (name: string): boolean => - /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(name); - interface DeclaredPayload { readonly name: string; /** Absolute source directory. */ @@ -1454,7 +1410,7 @@ const validatePayload = ( const diagnostics: Diagnostic[] = []; const sources: { name: string; source: string }[] = []; for (const [name, declaration] of Object.entries(configured)) { - if (!isSafePayloadName(name) || reservedPayloadDestinations.has(name)) { + if (!isSafeOutputName(name) || reservedPayloadDestinations.has(name)) { const diagnostic = sourceDiagnostic( 'AB4741', `Payload destination ${JSON.stringify(name)} must be a safe directory name outside the compiler-owned artifact namespaces.`, diff --git a/packages/agent-bundle/src/contracts/freeze.ts b/packages/agent-bundle/src/contracts/freeze.ts new file mode 100644 index 000000000..f29f6843b --- /dev/null +++ b/packages/agent-bundle/src/contracts/freeze.ts @@ -0,0 +1,5 @@ +/** + * Browser-consumable contract surface for deep freezing. The workbench must + * import from here, never from core/. + */ +export { deepFreeze } from '../core/freeze.ts'; diff --git a/packages/agent-bundle/src/contracts/mcp-apps.ts b/packages/agent-bundle/src/contracts/mcp-apps.ts index c82a26e81..e56de2fbc 100644 --- a/packages/agent-bundle/src/contracts/mcp-apps.ts +++ b/packages/agent-bundle/src/contracts/mcp-apps.ts @@ -27,6 +27,7 @@ export type { McpAppPreviewSnapshot, McpAppRuntimeInvalidationDetails, } from '../dev/mcp-app-runtime-preview-service.ts'; +export { isMcpAppConsentCapability } from '../dev/mcp-apps/mcp-app-sandbox.ts'; export type { McpAppConsentCapability, McpAppConsentChallenge, diff --git a/packages/agent-bundle/src/contracts/strict-json.ts b/packages/agent-bundle/src/contracts/strict-json.ts index 8b683b18b..14ddbdbbd 100644 --- a/packages/agent-bundle/src/contracts/strict-json.ts +++ b/packages/agent-bundle/src/contracts/strict-json.ts @@ -3,6 +3,7 @@ * snapshotting. The workbench must import from here, never from core/. */ export { + isPlainRecord, mapStrictJsonReason, parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, diff --git a/packages/agent-bundle/src/core/async.ts b/packages/agent-bundle/src/core/async.ts index 631047a6a..bed6b2e66 100644 --- a/packages/agent-bundle/src/core/async.ts +++ b/packages/agent-bundle/src/core/async.ts @@ -2,6 +2,9 @@ export interface SerialQueue { run(operation: () => Promise): Promise; } +export const sleep = (delayMs: number): Promise => + new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs)); + export const mapConcurrent = async ( items: readonly T[], concurrency: number, diff --git a/packages/agent-bundle/src/core/errors.ts b/packages/agent-bundle/src/core/errors.ts index e0fd8c62d..7290b5ffa 100644 --- a/packages/agent-bundle/src/core/errors.ts +++ b/packages/agent-bundle/src/core/errors.ts @@ -2,6 +2,10 @@ export const isErrno = (error: unknown, code: string): boolean => typeof error === 'object' && error !== null && 'code' in error && error.code === code; +/** Human-readable message for an arbitrary thrown value. */ +export const errorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error); + /** * Shared shape for the codebase's coded error classes. Subclasses pass their * own name explicitly so bundler minification cannot corrupt wire-visible names. diff --git a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts index ff62fbbd9..4595307d7 100644 --- a/packages/agent-bundle/src/dev/artifacts/artifact-service.ts +++ b/packages/agent-bundle/src/dev/artifacts/artifact-service.ts @@ -11,6 +11,7 @@ import { type ValidateArtifactOptions, } from '../../build/validate-artifact.ts'; import { freezeDiagnostics, hasErrors, DiagnosticError, type Diagnostic } from '../../core/diagnostics.ts'; +import { errorMessage } from '../../core/errors.ts'; import { digest } from '../../core/digest.ts'; import type { ProjectSourceInput, ProjectSourceSnapshotInput } from '../../core/project-context.ts'; import type { NormalizedPlugin } from '../../core/types.ts'; @@ -67,9 +68,6 @@ const summarizeDiagnostics = (diagnostics: readonly Diagnostic[]): DiagnosticSum warnings: diagnostics.filter((diagnostic) => diagnostic.severity === 'warning').length, }); -const errorMessage = (error: unknown): string => - error instanceof Error ? error.message : String(error); - const failureDiagnostics = ( error: unknown, configPath: string, diff --git a/packages/agent-bundle/src/dev/coordinator.ts b/packages/agent-bundle/src/dev/coordinator.ts index f1cd68a49..7b95e5d60 100644 --- a/packages/agent-bundle/src/dev/coordinator.ts +++ b/packages/agent-bundle/src/dev/coordinator.ts @@ -19,7 +19,6 @@ import { freezeProjectStatus, type ArtifactEpoch, type ArtifactStatus, - type BuildAttempt, type FailedBuildAttempt, type Invalidation, type ProjectStatus, @@ -176,9 +175,6 @@ const failedResult = (diagnostics: readonly Diagnostic[]): FailedArtifactEpochRe }); }; -const lastAttempt = (status: ProjectStatus): Exclude | undefined => - status.build.lastAttempt; - const artifactStatusFor = ( epoch: ArtifactEpoch | undefined, sourceRevision: string | undefined, @@ -434,11 +430,12 @@ export class DevCoordinator { sourceRevision: source.revision ?? 'unknown', startedAt: this.#now().toISOString(), }); + const previousAttempt = this.#status.build.lastAttempt; this.#status = freezeProjectStatus({ artifact: artifactStatusFor(this.#activeEpoch, source.revision), build: { activeAttempt: running, - ...(lastAttempt(this.#status) === undefined ? {} : { lastAttempt: lastAttempt(this.#status) }), + ...(previousAttempt === undefined ? {} : { lastAttempt: previousAttempt }), state: 'building', }, source, diff --git a/packages/agent-bundle/src/dev/dev-lock.ts b/packages/agent-bundle/src/dev/dev-lock.ts index 57cd5f034..a6598982f 100644 --- a/packages/agent-bundle/src/dev/dev-lock.ts +++ b/packages/agent-bundle/src/dev/dev-lock.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { link, lstat, mkdir, open, readFile, rm } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; +import { sleep } from '../core/async.ts'; import { stableJson } from '../core/digest.ts'; import { publishFileByLink } from '../core/durable-fs.ts'; import { CodedError, isErrno } from '../core/errors.ts'; @@ -234,10 +235,6 @@ const removeIfOwned = async (storage: DevLockStorage, path: string, contents: st const initialRecoveryRetryDelayMs = 25; const maximumRecoveryRetryDelayMs = 250; -const sleep = async (delayMs: number): Promise => { - await new Promise((resolvePromise) => setTimeout(resolvePromise, delayMs)); -}; - const recoveryContentsFor = (owner: DevLockOwner): string => `${stableJson({ owner })}\n`; const acquireRecoveryGate = async ( diff --git a/packages/agent-bundle/src/dev/inspector-launcher.ts b/packages/agent-bundle/src/dev/inspector-launcher.ts index c6e81ad65..fff166e47 100644 --- a/packages/agent-bundle/src/dev/inspector-launcher.ts +++ b/packages/agent-bundle/src/dev/inspector-launcher.ts @@ -1,6 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process'; import { resolve } from 'node:path'; +import { sleep as delay } from '../core/async.ts'; import { CodedError } from '../core/errors.ts'; import { taskkill, terminateProcessTree } from '../services/process-tree.ts'; @@ -142,10 +143,6 @@ const waitForClose = (child: ChildProcess): Promise => new Promise((resolv child.once('close', () => resolvePromise()); }); -const delay = (ms: number): Promise => new Promise((resolvePromise) => { - setTimeout(resolvePromise, ms); -}); - const terminateTree = (child: ChildProcess, signal: NodeJS.Signals): Promise => terminateProcessTree(child, signal, { onTreeTerminationFailure: () => undefined, diff --git a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts index 74eda9769..3b382841c 100644 --- a/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts +++ b/packages/agent-bundle/src/dev/mcp-app-runtime-preview-service.ts @@ -1,3 +1,4 @@ +import { isPlainRecord } from '../core/strict-json.ts'; import { McpAppRuntimeBindingService, type McpAppBoundOperationResult, @@ -206,9 +207,7 @@ const systemOperationClock: McpAppRuntimeOperationClock = Object.freeze({ setTimeout: (callback: () => void, milliseconds: number) => setTimeout(callback, milliseconds), }); -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const isRecord = isPlainRecord; const canonicalCallToolConsentDetails = ( catalog: PreviewEntry['catalog'], 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 ecbced6c9..a244d809b 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,3 +1,4 @@ +import { isRecord } from '../../core/strict-json.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppProfileId } from '../mcp-app-profile-descriptors.ts'; export type McpAppJsonValue = @@ -116,8 +117,6 @@ interface BindingEntry { const defaultTeardownTimeoutMs = 1_000; const maximumTeardownTimeoutMs = 30_000; -const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(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); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index 543712562..d4c6e9cd0 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -10,6 +10,7 @@ import type { McpAppBindingOperation, McpAppRuntimeRoutePreviewService, } from '../mcp-app-runtime-preview-service.ts'; +import { isMcpAppConsentCapability } from './mcp-app-sandbox.ts'; import type { McpAppConsentChallenge } from './mcp-app-sandbox.ts'; import type { McpAppConsentRequest } from './mcp-app-sandbox.ts'; import { runtimeAppMessageLimits } from '../runtime-app-message-limits.ts'; @@ -422,8 +423,8 @@ const runtimeOperation = (value: JsonObject): McpAppBindingOperation => { const runtimeConsentRequest = (value: JsonObject): McpAppConsentRequest => { if (!hasOnly(value, ['actionFingerprint', 'capability', 'details', 'scope', 'summary']) || !nonemptyString(value.actionFingerprint) || !nonemptyString(value.summary) || !isJsonValue(value.details) || (value.scope !== 'action' && value.scope !== 'document') - || !['call-tool', 'download-file', 'open-external-link', 'clipboard-write', 'camera', 'microphone', 'geolocation', 'request-display-mode'].includes(value.capability as string)) return invalidShape(); - return Object.freeze({ actionFingerprint: value.actionFingerprint, capability: value.capability as McpAppConsentRequest['capability'], details: cloneJson(value.details), scope: value.scope, summary: value.summary }); + || !isMcpAppConsentCapability(value.capability)) return invalidShape(); + return Object.freeze({ actionFingerprint: value.actionFingerprint, capability: value.capability, details: cloneJson(value.details), scope: value.scope, summary: value.summary }); }; const runtimeConsentDecision = (value: JsonObject): 'allow-once' | 'deny' => { 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 9971f31bc..b3a7270c7 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 @@ -2,6 +2,8 @@ import { createHmac, randomBytes } from 'node:crypto'; import { createServer, type Server } from 'node:http'; import { isIP, type Socket } from 'node:net'; +import { isRecord } from '../../core/strict-json.ts'; + import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -143,7 +145,12 @@ export interface McpAppSandboxPolicy { readonly warnings: readonly McpAppSandboxWarning[]; } -export type McpAppConsentCapability = 'call-tool' | 'download-file' | 'open-external-link' | 'clipboard-write' | 'camera' | 'microphone' | 'geolocation' | 'request-display-mode'; +const mcpAppConsentCapabilities = ['call-tool', 'download-file', 'open-external-link', 'clipboard-write', 'camera', 'microphone', 'geolocation', 'request-display-mode'] as const; + +export type McpAppConsentCapability = (typeof mcpAppConsentCapabilities)[number]; + +export const isMcpAppConsentCapability = (value: unknown): value is McpAppConsentCapability => + (mcpAppConsentCapabilities as readonly unknown[]).includes(value); export interface McpAppConsentGrant { readonly authorizationId: string; @@ -301,8 +308,6 @@ export interface McpAppSandboxBridge { send(message: McpAppSandboxMessage): boolean; } -const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); - const finiteJson = (value: unknown): value is McpAppJsonValue => value === null || typeof value === 'string' || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) || Array.isArray(value) && value.every(finiteJson) diff --git a/packages/agent-bundle/src/dev/playground/native-playground-service.ts b/packages/agent-bundle/src/dev/playground/native-playground-service.ts index 353876104..0f9fe4508 100644 --- a/packages/agent-bundle/src/dev/playground/native-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/native-playground-service.ts @@ -3,7 +3,7 @@ import { link, lstat, mkdir, mkdtemp, open, realpath, rename, rm } from 'node:fs import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { digest, stableJson } from '../../core/digest.ts'; -import { parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../../core/strict-json.ts'; +import { isJsonRecord, parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../../core/strict-json.ts'; import { loadConfig } from '../../config/load.ts'; import type { PreparedEvalArtifact } from '../../eval/artifact.ts'; import { runClaudeTrial } from '../../eval/claude-harness.ts'; @@ -454,8 +454,7 @@ const normalizedTrialEvents = ( const selectionKey = (selection: NativePlaygroundCatalogSelection): string => `${selection.caseId}\u0000${selection.fixtureId}\u0000${selection.host}\u0000${selection.modelPinId}`; -const isRecord = (value: JsonValue): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); +const isRecord = isJsonRecord; const boundedJson = (value: JsonValue, depth = 0): boolean => { if (depth > maximumSnapshotDepth) return false; diff --git a/packages/agent-bundle/src/dev/playground/playground-store.ts b/packages/agent-bundle/src/dev/playground/playground-store.ts index 221f893bc..5a91256a4 100644 --- a/packages/agent-bundle/src/dev/playground/playground-store.ts +++ b/packages/agent-bundle/src/dev/playground/playground-store.ts @@ -6,7 +6,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { serialQueue, type SerialQueue } from '../../core/async.ts'; import { isErrno } from '../../core/errors.ts'; import { isInsideOrEqual } from '../../core/paths.ts'; -import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; +import { isRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import type { DevLogSink } from '../logs/dev-log-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -353,9 +353,6 @@ const sensitiveKey = (key: string): boolean => { || /(?:apikey|apitoken|authtoken|accesstoken)$/u.test(compact); }; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const hasExactOwnKeys = (value: Record, keys: readonly string[]): boolean => { const actual = Object.keys(value).sort(); const expected = [...keys].sort(); @@ -477,62 +474,57 @@ const json = (value: unknown, label: string, seen = new WeakSet()): Play if (typeof value !== 'object') throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); if (seen.has(value)) throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must not contain cycles.`); seen.add(value); - if (Array.isArray(value)) { + try { + if (Array.isArray(value)) { + const prototype = Object.getPrototypeOf(value); + const names = Object.getOwnPropertyNames(value); + const symbols = Object.getOwnPropertySymbols(value); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); + if (prototype !== Array.prototype + || symbols.length !== 0 + || lengthDescriptor === undefined + || !('value' in lengthDescriptor) + || !Number.isInteger(lengthDescriptor.value) + || lengthDescriptor.value < 0 + || names.length !== lengthDescriptor.value + 1) { + throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); + } + const copied = new Array(lengthDescriptor.value); + for (let index = 0; index < lengthDescriptor.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !('value' in descriptor)) { + throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must not contain accessors.`); + } + if (!descriptor.enumerable) { + throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); + } + copied[index] = json(descriptor.value, label, seen); + } + return Object.freeze(copied); + } const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); + } + const copied = Object.create(null) as Record; const names = Object.getOwnPropertyNames(value); - const symbols = Object.getOwnPropertySymbols(value); - const lengthDescriptor = Object.getOwnPropertyDescriptor(value, 'length'); - if (prototype !== Array.prototype - || symbols.length !== 0 - || lengthDescriptor === undefined - || !('value' in lengthDescriptor) - || !Number.isInteger(lengthDescriptor.value) - || lengthDescriptor.value < 0 - || names.length !== lengthDescriptor.value + 1) { - seen.delete(value); + if (Object.getOwnPropertySymbols(value).length !== 0) { throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); } - const copied = new Array(lengthDescriptor.value); - for (let index = 0; index < lengthDescriptor.value; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + for (const key of names.sort()) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); if (descriptor === undefined || !('value' in descriptor)) { - seen.delete(value); throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must not contain accessors.`); } if (!descriptor.enumerable) { - seen.delete(value); throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); } - copied[index] = json(descriptor.value, label, seen); + copied[key] = json(descriptor.value, label, seen); } - seen.delete(value); return Object.freeze(copied); - } - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - seen.delete(value); - throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); - } - const copied = Object.create(null) as Record; - const names = Object.getOwnPropertyNames(value); - if (Object.getOwnPropertySymbols(value).length !== 0) { + } finally { seen.delete(value); - throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); - } - for (const key of names.sort()) { - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor === undefined || !('value' in descriptor)) { - seen.delete(value); - throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must not contain accessors.`); - } - if (!descriptor.enumerable) { - seen.delete(value); - throw serviceError('PLAYGROUND_VALUE_INVALID', `${label} must be JSON-compatible.`); - } - copied[key] = json(descriptor.value, label, seen); } - seen.delete(value); - return Object.freeze(copied); }; const jsonObject = (value: unknown, label: string): PlaygroundJsonObject => { diff --git a/packages/agent-bundle/src/dev/playground/playground-subscriptions.ts b/packages/agent-bundle/src/dev/playground/playground-subscriptions.ts index 75bd82bc5..c1200c4c7 100644 --- a/packages/agent-bundle/src/dev/playground/playground-subscriptions.ts +++ b/packages/agent-bundle/src/dev/playground/playground-subscriptions.ts @@ -1,3 +1,4 @@ +import { errorMessage } from '../../core/errors.ts'; import type { PlaygroundCleanupFailure, PlaygroundSubscribeOptions, @@ -14,8 +15,6 @@ export interface PlaygroundSubscriptionEntry { readonly queue: PlaygroundTraceEvent[]; } -const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); - /** Owns bounded subscriber queues and isolates listener failures from durable session state. */ export class PlaygroundSubscriptionSet { readonly #cleanupFailures: PlaygroundCleanupFailure[]; diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index ab14c9568..1d804ef90 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -241,16 +241,16 @@ const sourcePaths = async (root: string, outputRoots: readonly string[]): Promis const visit = async (directory: string): Promise => { const entries = await readdir(directory, { withFileTypes: true }); + const subdirectories: string[] = []; for (const entry of entries) { const source = join(directory, entry.name); if (isProjectPathIgnored(rules, root, source)) continue; if (outputRoots.some((outputRoot) => containedPathComponents(outputRoot, source) !== undefined)) continue; - if (entry.isDirectory()) { - await visit(source); - continue; - } - if (entry.isFile()) paths.push(source); + if (entry.isDirectory()) subdirectories.push(source); + else if (entry.isFile()) paths.push(source); } + // Sibling directories descend concurrently; the final sort restores determinism. + await Promise.all(subdirectories.map(visit)); }; await visit(root); @@ -270,11 +270,13 @@ const payloadSourcePaths = async ( // A payload that does not exist yet contributes no source inputs. return; } + const subdirectories: string[] = []; for (const entry of entries) { const source = join(directory, entry.name); - if (entry.isDirectory()) await visit(source); + if (entry.isDirectory()) subdirectories.push(source); else if (entry.isFile()) paths.push(source); } + await Promise.all(subdirectories.map(visit)); }; for (const payloadRoot of payloadRoots) { const requested = resolve(root, payloadRoot); diff --git a/packages/agent-bundle/src/dev/runtime-controller.ts b/packages/agent-bundle/src/dev/runtime-controller.ts index e131377b0..25f0a9728 100644 --- a/packages/agent-bundle/src/dev/runtime-controller.ts +++ b/packages/agent-bundle/src/dev/runtime-controller.ts @@ -1,6 +1,8 @@ import { randomUUID } from 'node:crypto'; import { resolve } from 'node:path'; +import { isRecord } from '../core/strict-json.ts'; + import type { ArtifactStatus, JsonObject, JsonValue, RuntimeEvent } from './types.ts'; import { DevRuntimeUnavailableError, @@ -79,9 +81,6 @@ const runtimeEvent = ( event: DevRuntimeEventInput, ): RuntimeEvent => Object.freeze({ ...event, providerSessionId }); -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const snapshotInvalid = (): never => { throw new TypeError('Development runtime provider returned an invalid browser snapshot.'); }; diff --git a/packages/agent-bundle/src/dev/runtime-mcp-registry.ts b/packages/agent-bundle/src/dev/runtime-mcp-registry.ts index 49853ba6d..3214a3189 100644 --- a/packages/agent-bundle/src/dev/runtime-mcp-registry.ts +++ b/packages/agent-bundle/src/dev/runtime-mcp-registry.ts @@ -24,6 +24,7 @@ import type { DevRuntimeMcpSessionSnapshot, RuntimeVector, } from './runtime-protocol.ts'; +import { isRecord } from '../core/strict-json.ts'; import { RuntimeGenerationStore, type RuntimeGeneration } from './runtime-generation-store.ts'; import type { JsonObject, JsonValue } from './types.ts'; @@ -197,9 +198,6 @@ const registryNotFound = (message: string): RuntimeMcpRegistryError => const descriptorKey = (name: string, target: string): string => `${name}\u0000${target}`; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const finiteJson = (value: unknown, seen = new WeakSet()): JsonValue => { if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; if (typeof value === 'number') { diff --git a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts index c1088b39e..98b56f9bd 100644 --- a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts @@ -1,6 +1,8 @@ import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; +import { isPlainRecord } from '../core/strict-json.ts'; + import type { DevRuntimeSession } from './runtime-provider.ts'; import type { DevRuntimeMcpAppRunBinding, @@ -33,7 +35,7 @@ const requestError = (value: RequestDiagnostic): RequestDiagnostic & Error => Ob const isRequestDiagnostic = (value: unknown): value is RequestDiagnostic => typeof value === 'object' && value !== null && typeof (value as Partial).status === 'number'; const responseJson = (response: ServerResponse, body: unknown): void => { response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify(body)); }; const responseDiagnostic = (response: ServerResponse, value: RequestDiagnostic): void => { response.writeHead(value.status, { 'content-type': 'application/json; charset=utf-8' }); response.end(JSON.stringify({ diagnostic: { code: value.code, message: value.message } })); }; -const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const isRecord = isPlainRecord; const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4_096 && !value.includes('\0'); const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; const hasOnly = (value: Record, fields: readonly string[]): boolean => Object.keys(value).every((key) => fields.includes(key)); diff --git a/packages/agent-bundle/src/dev/runtime-routes.ts b/packages/agent-bundle/src/dev/runtime-routes.ts index 658e77311..9e7a48fd9 100644 --- a/packages/agent-bundle/src/dev/runtime-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-routes.ts @@ -1,5 +1,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; +import { isPlainRecord } from '../core/strict-json.ts'; + import { DevRuntimeGenerationConflictError, DevRuntimeUnavailableError, @@ -200,9 +202,7 @@ const route = (requestTarget: string | undefined): Route | undefined => { return runtimePathError(); }; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const isRecord = isPlainRecord; const hasOnly = (value: Record, fields: readonly string[]): boolean => Object.keys(value).every((field) => fields.includes(field)); diff --git a/packages/agent-bundle/src/dev/watcher.ts b/packages/agent-bundle/src/dev/watcher.ts index 17a1fcfe0..693b23665 100644 --- a/packages/agent-bundle/src/dev/watcher.ts +++ b/packages/agent-bundle/src/dev/watcher.ts @@ -100,10 +100,11 @@ export class ProjectWatcher { this.#ignored = (path) => { const source = relativePath(this.#root, path); if (source === undefined) return true; - const parts = source.split('/'); - return parts.some((part) => excludedDirectoryNames.has(part)) || - [...this.#outputPaths].some((ignored) => source === ignored || source.startsWith(`${ignored}/`)) || - (source.length > 0 && options.isIgnored?.(resolve(this.#root, path)) === true); + if (source.split('/').some((part) => excludedDirectoryNames.has(part))) return true; + for (const ignored of this.#outputPaths) { + if (source === ignored || source.startsWith(`${ignored}/`)) return true; + } + return source.length > 0 && options.isIgnored?.(resolve(this.#root, path)) === true; }; const ready = Promise.withResolvers(); this.#ready = ready.promise; diff --git a/packages/agent-bundle/src/eval/run-store.ts b/packages/agent-bundle/src/eval/run-store.ts index 853a82d19..89ba2495d 100644 --- a/packages/agent-bundle/src/eval/run-store.ts +++ b/packages/agent-bundle/src/eval/run-store.ts @@ -7,7 +7,7 @@ import { serialQueue } from '../core/async.ts'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { isInsideOrEqual } from '../core/paths.ts'; -import { parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts'; +import { isRecord, parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../core/strict-json.ts'; import { defaultEvalRunsDir } from './config.ts'; import { findCredentialConfiguration } from './credentials.ts'; import { EvalRunStoreError } from './errors.ts'; @@ -216,9 +216,6 @@ const runEvalRunStoreDurabilityTestHook = async ( const sameFile = (left: Stats, right: Stats): boolean => left.dev === right.dev && left.ino === right.ino; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isProcessRunning = (pid: number): boolean => { try { process.kill(pid, 0); diff --git a/packages/agent-bundle/src/events/ipc.ts b/packages/agent-bundle/src/events/ipc.ts index 05156d5d3..adae3b67f 100644 --- a/packages/agent-bundle/src/events/ipc.ts +++ b/packages/agent-bundle/src/events/ipc.ts @@ -697,10 +697,10 @@ const listenServer = ( }); }); -const openServer = ( +const openServer = Effect.fnUntraced(function*( options: CreateEventRuntimeServerOptions, testHooks?: EventRuntimeServerTestHooks, -): Effect.Effect => Effect.gen(function*() { +): Effect.fn.Return { const endpoint = eventRuntimeEndpoint(options.endpointId); if (process.platform === 'win32') return yield* listenServer(options, endpoint); diff --git a/packages/agent-bundle/src/host-contracts/native-claude-contract.ts b/packages/agent-bundle/src/host-contracts/native-claude-contract.ts index f0961c5ab..0dd485d16 100644 --- a/packages/agent-bundle/src/host-contracts/native-claude-contract.ts +++ b/packages/agent-bundle/src/host-contracts/native-claude-contract.ts @@ -1,4 +1,7 @@ import { createHash } from 'node:crypto'; + +import { isErrno } from '../core/errors.ts'; +import { isRecord } from '../core/strict-json.ts'; import { spawn } from 'node:child_process'; import { lstat, readFile, readdir } from 'node:fs/promises'; import { homedir } from 'node:os'; @@ -76,9 +79,6 @@ export interface NativeClaudeStreamNormalizationOptions { readonly candidateSkillEventName?: string; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isSafeLabel = (value: unknown): value is string => typeof value === 'string' && /^[A-Za-z][A-Za-z0-9_.-]{0,127}$/u.test(value); @@ -313,7 +313,7 @@ const digestClaudeFileTree = async (path: string, includeContents = true): Promi digest.update(`other\0${entry.mode}\0`); return digest.digest('hex'); } catch (error) { - if (isRecord(error) && error.code === 'ENOENT') return 'absent'; + if (isErrno(error, 'ENOENT')) return 'absent'; throw error; } }; @@ -359,8 +359,7 @@ const normalHomeChangedDiagnostic = Object.freeze({ message: 'Claude normal config/settings/plugins state changed; inspect local state without retaining its output.', }); -const isMissingExecutableError = (error: unknown): boolean => - isRecord(error) && error.code === 'ENOENT'; +const isMissingExecutableError = (error: unknown): boolean => isErrno(error, 'ENOENT'); const looksUnauthenticated = (output: string): boolean => /(?:not\s+logged\s+in|authentication|authenticate|unauthorized|subscription)/iu.test(output); diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index c0b954be2..5d7277ccf 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -13,9 +13,10 @@ import { import { homedir } from 'node:os'; import { basename, join, resolve } from 'node:path'; -import { Effect } from 'effect'; +import { Effect, Predicate } from 'effect'; import { DiagnosticError } from '../core/diagnostics.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; @@ -72,9 +73,6 @@ const failure = ( target, }]); -const isErrno = (error: unknown, code: string): boolean => - error instanceof Error && (error as NodeJS.ErrnoException).code === code; - const exists = async (path: string): Promise => { try { await lstat(path); @@ -282,13 +280,8 @@ const readInstalledVersion = async (destination: string): Promise => { await lstat(path); return true; } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + if (isErrno(error, 'ENOENT')) return false; throw error; } }; diff --git a/packages/rsc-runtime/src/notices/ledger.ts b/packages/rsc-runtime/src/notices/ledger.ts index ecb7bd567..44a6bbbbe 100644 --- a/packages/rsc-runtime/src/notices/ledger.ts +++ b/packages/rsc-runtime/src/notices/ledger.ts @@ -167,13 +167,13 @@ const assertOpen = (closed: boolean, signal: AbortSignal): void => { } }; -const publishProgram = ( +const publishProgram = Effect.fnUntraced(function*( store: NoticeStore, authorize: AgentNoticeAuthorizer, request: AgentNoticeRequest, input: AgentNoticePublishInput, options: AgentNoticePublishOptions, -): Effect.Effect => Effect.gen(function*() { +): Effect.fn.Return { const prepared = yield* noticeEffect(() => { const target = recipient(input.recipient); const createdAt = timestamp(request.invocation.startedAt, 'Notice createdAt'); @@ -248,11 +248,11 @@ const deliveryFor = ( return receipt === undefined ? undefined : Object.freeze({ notice, receipt }); }; -const inboxProgram = ( +const inboxProgram = Effect.fnUntraced(function*( store: NoticeStore, authorize: AgentNoticeAuthorizer, request: AgentNoticeRequest, -): Effect.Effect => Effect.gen(function*() { +): Effect.fn.Return { const before = yield* storeEffect(() => store.read({ signal: request.signal })); const readTime = Date.parse(request.invocation.startedAt); const candidates = before.state.notices.filter((notice) => diff --git a/packages/rsc-runtime/src/state/memory-driver.ts b/packages/rsc-runtime/src/state/memory-driver.ts index 135cc53cc..699795dcf 100644 --- a/packages/rsc-runtime/src/state/memory-driver.ts +++ b/packages/rsc-runtime/src/state/memory-driver.ts @@ -89,6 +89,13 @@ interface MemoryStoreInternals { keys: Map>; } +/** Generics-erased entry type for the driver-wide open-store collections. */ +type AnyMemoryStoreEntry = MemoryStoreEntry; + +const eraseEntry = ( + entry: MemoryStoreEntry, +): AnyMemoryStoreEntry => entry as unknown as AnyMemoryStoreEntry; + interface MemoryStoreEntry { readonly activate: () => Promise; readonly internals: MemoryStoreInternals; @@ -366,8 +373,8 @@ export const createMemoryStateDriver = (options: MemoryStateDriverOptions = {}): const now = options.now ?? ((): Date => new Date()); // Heterogeneously typed per definition; entries are cast back at the one // retrieval site below, keyed by the definition id they were created for. - const registry = new Map>(); - const openStores = new Set>(); + const registry = new Map(); + const openStores = new Set(); const pendingOpens = createPendingOpenTracker(); let closed = false; let closing: Promise | undefined; @@ -410,9 +417,9 @@ export const createMemoryStateDriver = (options: MemoryStateDriverOptions = {}): switch (lifetime) { case 'request': { const created = createMemoryStore(definition, lifetime, now, () => { - openStores.delete(created as unknown as MemoryStoreEntry); + openStores.delete(eraseEntry(created)); }); - openStores.add(created as unknown as MemoryStoreEntry); + openStores.add(eraseEntry(created)); return created; } case 'process': { @@ -420,10 +427,10 @@ export const createMemoryStateDriver = (options: MemoryStateDriverOptions = {}): if (existing === undefined) { const created = createMemoryStore(definition, lifetime, now, () => { registry.delete(definition.id); - openStores.delete(created as unknown as MemoryStoreEntry); + openStores.delete(eraseEntry(created)); }); - registry.set(definition.id, created as unknown as MemoryStoreEntry); - openStores.add(created as unknown as MemoryStoreEntry); + registry.set(definition.id, eraseEntry(created)); + openStores.add(eraseEntry(created)); return created; } if (definition.version !== existing.internals.definition.version) { diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index bfe172cc1..f053188ac 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -10,7 +10,7 @@ import { dirname, join, resolve } from 'node:path'; // without flags, and G3 chose it precisely because it adds zero dependencies. // This import lives behind the dedicated `./state/sqlite` subpath so volatile // state users and stateless projects never load it or see the warning. -import { DatabaseSync } from 'node:sqlite'; +import { DatabaseSync, type StatementSync } from 'node:sqlite'; import { Context, @@ -251,6 +251,8 @@ class SqliteStore implements Age readonly #now: () => Date; readonly #onClose: () => void; readonly #runtime: ScopedEffectRuntime; + /** Statement cache: the schema is fixed after open, so entries never go stale. */ + readonly #statements = new WeakMap>(); constructor( definition: AgentStateDefinition, @@ -309,8 +311,22 @@ class SqliteStore implements Age }); } + #prepare(db: DatabaseSync, sql: string): StatementSync { + let cache = this.#statements.get(db); + if (cache === undefined) { + cache = new Map(); + this.#statements.set(db, cache); + } + let statement = cache.get(sql); + if (statement === undefined) { + statement = db.prepare(sql); + cache.set(sql, statement); + } + return statement; + } + #headRow(db: DatabaseSync, action: string): { revision: number; state: string } { - const row = db.prepare('SELECT revision, state FROM agent_state_head WHERE id = 1').get() as + const row = this.#prepare(db, 'SELECT revision, state FROM agent_state_head WHERE id = 1').get() as | { revision: number; state: string } | undefined; if (row === undefined || !Number.isInteger(row.revision) || row.revision < 0) { @@ -335,15 +351,15 @@ class SqliteStore implements Age #journalRecords(db: DatabaseSync, upTo?: number): AgentStateJournalRecord[] { const rows = ( upTo === undefined - ? db.prepare('SELECT * FROM agent_state_journal ORDER BY revision').all() - : db.prepare('SELECT * FROM agent_state_journal WHERE revision <= ? ORDER BY revision').all(upTo) + ? this.#prepare(db, 'SELECT * FROM agent_state_journal ORDER BY revision').all() + : this.#prepare(db, 'SELECT * FROM agent_state_journal WHERE revision <= ? ORDER BY revision').all(upTo) ) as unknown as JournalRow[]; return rows.map((row) => recordFromRow(this.#definition.id, row)); } #latestMigrationRevision(db: DatabaseSync): number { - const row = db - .prepare("SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal WHERE kind = 'migrate'") + const row = this + .#prepare(db, "SELECT COALESCE(MAX(revision), 0) AS revision FROM agent_state_journal WHERE kind = 'migrate'") .get() as { revision: number }; return row.revision; } @@ -352,7 +368,7 @@ class SqliteStore implements Age db: DatabaseSync, key: string, ): { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null } | undefined { - const row = db.prepare('SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as + const row = this.#prepare(db, 'SELECT * FROM agent_state_journal WHERE idempotency_key = ?').get(key) as | JournalRow | undefined; return row === undefined @@ -401,8 +417,9 @@ class SqliteStore implements Age state: TState, stateText: string, ): AgentStateCommitResult { - db - .prepare( + this + .#prepare( + db, 'INSERT INTO agent_state_journal (revision, kind, name, payload, state, result_state, to_version, idempotency_key, committed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', ) .run( @@ -418,7 +435,7 @@ class SqliteStore implements Age record.idempotencyKey, record.committedAt, ); - db.prepare('UPDATE agent_state_head SET revision = ?, state = ? WHERE id = 1').run(record.revision, stateText); + this.#prepare(db, 'UPDATE agent_state_head SET revision = ?, state = ? WHERE id = 1').run(record.revision, stateText); return Object.freeze({ replayed: false, revision: record.revision, state }); } @@ -614,8 +631,8 @@ class SqliteStore implements Age Effect.andThen( this.#transaction('read', 'changes', (db) => { const head = this.#headRow(db, 'changes'); - const rows = db - .prepare('SELECT * FROM agent_state_journal WHERE revision > ? ORDER BY revision LIMIT ?') + const rows = this + .#prepare(db, 'SELECT * FROM agent_state_journal WHERE revision > ? ORDER BY revision LIMIT ?') .all(options.afterRevision, options.limit ?? -1) as unknown as JournalRow[]; const changes = rows.map((row) => changeFromJournalRecord(recordFromRow(this.#definition.id, row))); return Object.freeze({ changes: Object.freeze(changes), headRevision: head.revision }); @@ -787,6 +804,9 @@ class SqliteStore implements Age } } +/** Generics-erased store type for the driver-wide open-store collection. */ +type AnySqliteStore = SqliteStore; + export const createSqliteStateDriver = (options: SqliteStateDriverOptions): AgentStateDriver => { if ((options.root === undefined) === (options.file === undefined)) { throw new AgentStateError('invalid-input', 'Sqlite state drivers require exactly one of root or file'); @@ -796,7 +816,7 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen throw new AgentStateError('invalid-input', 'busyTimeoutMs must be an integer >= 1'); } const now = options.now ?? ((): Date => new Date()); - const openStores = new Set>(); + const openStores = new Set(); const pendingOpens = createPendingOpenTracker(); let closed = false; let closing: Promise | undefined; @@ -894,7 +914,7 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen let store: SqliteStore; try { store = new SqliteStore(definition, file, now, () => - openStores.delete(store as unknown as SqliteStore), + openStores.delete(store as unknown as AnySqliteStore), runtime, ); await store.initialize(busyTimeoutMs); @@ -911,7 +931,7 @@ export const createSqliteStateDriver = (options: SqliteStateDriverOptions): Agen await store.close(); throw new AgentStateError('store-closed', `State '${definition.id}' cannot open on a closed driver`); } - openStores.add(store as unknown as SqliteStore); + openStores.add(store as unknown as AnySqliteStore); return store; })(), ); diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index 694231636..534f83330 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -2,6 +2,7 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics import { snapshotStrictJsonValue } from '../../../agent-bundle/src/contracts/strict-json.ts'; import type { ArtifactEpochDiff, ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.ts'; import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; +import { isRecord } from '../client-helpers.ts'; export interface ArtifactClientOptions { readonly foreground: ForegroundRequestAuthority; @@ -22,9 +23,6 @@ export class ArtifactClientError extends Error { } } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const invalidResponse = (): ArtifactClientError => new ArtifactClientError('AB8063', 'Artifact route returned an invalid response.'); diff --git a/packages/workbench/src/comparisons/comparison-client.ts b/packages/workbench/src/comparisons/comparison-client.ts index d2b6f2261..1f1c91eeb 100644 --- a/packages/workbench/src/comparisons/comparison-client.ts +++ b/packages/workbench/src/comparisons/comparison-client.ts @@ -6,6 +6,7 @@ import { semanticGraderIdentityPattern, } from '../../../agent-bundle/src/contracts/eval.ts'; import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; +import { isRecord } from '../client-helpers.ts'; import { nonnegativeIntegerSchema, nonnegativeNumberSchema, @@ -34,9 +35,6 @@ export class ComparisonClientError extends Error { } } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const invalidResponse = (): ComparisonClientError => new ComparisonClientError('AB8083', 'Eval comparison route returned an invalid response.'); diff --git a/packages/workbench/src/evals/eval-client.ts b/packages/workbench/src/evals/eval-client.ts index 2e5c603dd..4f3f25b39 100644 --- a/packages/workbench/src/evals/eval-client.ts +++ b/packages/workbench/src/evals/eval-client.ts @@ -6,7 +6,8 @@ import type { EvalRunSelection, EvalSuiteListing, } from '../../../agent-bundle/src/contracts/eval.ts'; -import { parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { type JsonValue } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { exactKeys, isRecord, parseStrictResponseJson } from '../client-helpers.ts'; import type { EvalHarnessName, EvalRunEvent, EvalRunRecord } from '../../../agent-bundle/src/contracts/eval.ts'; import { awaitWithAbort, type ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; import { @@ -70,10 +71,6 @@ const maximumArtifactBytes = 8 * 1024 * 1024; const maximumEventFrameBytes = 256 * 1024; const safeArtifactSegment = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; const evalHarnesses = new Set(['deterministic', 'claude', 'codex']); -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); -const exactKeys = (value: unknown, keys: readonly string[]): value is Readonly> => - isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); const safeInteger = (value: unknown, minimum = 0): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum; const isIsoTimestamp = (value: unknown): value is string => @@ -254,15 +251,7 @@ const runResultSchema = z.strictObject({ }); const conforms = (schema: z.ZodType, value: unknown): boolean => schema.safeParse(value).success; -const snapshot = (value: unknown): JsonValue => { - try { return snapshotStrictJsonValue(value); } - catch { throw invalidResponse(); } -}; - -const parseResponseJson = (bytes: Uint8Array): JsonValue => { - try { return snapshot(parseJsonWithoutDuplicateKeys(new TextDecoder('utf-8', { fatal: true }).decode(bytes))); } - catch { throw invalidResponse(); } -}; +const parseResponseJson = (bytes: Uint8Array): JsonValue => parseStrictResponseJson(bytes, invalidResponse); const eventFor = (value: unknown): EvalRunEvent => { if (!exactKeys(value, ['kind', 'payload', 'sequence', 'timestamp']) || diff --git a/packages/workbench/src/freeze.ts b/packages/workbench/src/freeze.ts index 2cb805ab9..c0cece569 100644 --- a/packages/workbench/src/freeze.ts +++ b/packages/workbench/src/freeze.ts @@ -1,16 +1 @@ -const isPlainObjectOrArray = (value: object): boolean => { - if (Array.isArray(value)) return true; - const proto = Object.getPrototypeOf(value); - return proto === Object.prototype || proto === null; -}; - -/** Freezes a value tree in place; cycle-safe and symbol-aware. Freezing is idempotent. */ -export const deepFreeze = (value: Value, seen = new WeakSet()): Value => { - if (typeof value !== 'object' || value === null || seen.has(value)) return value; - if (!isPlainObjectOrArray(value)) return value; - seen.add(value); - for (const property of Reflect.ownKeys(value)) { - deepFreeze(Reflect.get(value, property), seen); - } - return Object.freeze(value); -}; +export { deepFreeze } from '../../agent-bundle/src/contracts/freeze.ts'; diff --git a/packages/workbench/src/hooks/hook-client.ts b/packages/workbench/src/hooks/hook-client.ts index c47772380..7c97b7c42 100644 --- a/packages/workbench/src/hooks/hook-client.ts +++ b/packages/workbench/src/hooks/hook-client.ts @@ -6,6 +6,7 @@ import type { HookPlaygroundReplay, HookPlaygroundSimulation, } from '../../../agent-bundle/src/contracts/hooks.ts'; +import { isRecord } from '../client-helpers.ts'; import { z } from 'zod'; import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; @@ -33,9 +34,6 @@ export class HookClientError extends Error { } } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const invalidResponse = (): HookClientError => new HookClientError('AB8033', 'Hook playground route returned an invalid response.'); diff --git a/packages/workbench/src/logs/log-client.ts b/packages/workbench/src/logs/log-client.ts index 6c468669e..3555eaf30 100644 --- a/packages/workbench/src/logs/log-client.ts +++ b/packages/workbench/src/logs/log-client.ts @@ -6,9 +6,9 @@ import type { } from '../../../agent-bundle/src/contracts/dev-logs.ts'; import { parseJsonWithoutDuplicateKeys, - snapshotStrictJsonValue, type JsonValue, } from '../../../agent-bundle/src/contracts/strict-json.ts'; +import { exactKeys, isRecord, parseStrictResponseJson, strictJsonSnapshot } from '../client-helpers.ts'; import { isCredentialKey, redactEvalCredentialText } from '../../../agent-bundle/src/contracts/credentials.ts'; import { awaitWithAbort, @@ -71,10 +71,6 @@ const safeIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,255}$/u; const safeInteger = (value: unknown, minimum = 0): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum; const isDate = (value: unknown): value is string => typeof value === 'string' && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); -const hasExactKeys = (value: unknown, keys: readonly string[]): value is Readonly> => - isRecord(value) && Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key)); const hasControlOrSeparators = (value: string): boolean => [...value].some((character) => character === '/' || character === '\\' || character <= '\u001F' || character === '\u007F'); const safeProjectRelativePath = /(?:\/[A-Za-z0-9._@+-]+)*/gu; @@ -98,27 +94,21 @@ const isLevel = (value: unknown): boolean => typeof value === 'string' && (devLo const isContext = (value: unknown): value is Readonly> => isRecord(value) && Object.entries(value).every(([key, entry]) => safeContextKeys.has(key) && typeof entry === 'string' && safeIdentifier.test(entry) && isSafeWireText(entry, 256)); const isDevRecord = (value: unknown): value is DevLogRecord => { - if (!hasExactKeys(value, ['context', 'details', 'kind', 'level', 'occurredAt', 'producer', 'sequence', 'summary']) || !isProducer(value.producer)) return false; + if (!exactKeys(value, ['context', 'details', 'kind', 'level', 'occurredAt', 'producer', 'sequence', 'summary']) || !isProducer(value.producer)) return false; return safeInteger(value.sequence, 1) && isDate(value.occurredAt) && isLevel(value.level) && typeof value.kind === 'string' && (devLogKinds[value.producer] as readonly string[]).includes(value.kind) && isSafeWireText(value.summary, maximumSummaryLength) && isContext(value.context) && isSafeDetail(value.details as JsonValue); }; -const isGap = (value: unknown): value is DevLogReplayGap => hasExactKeys(value, [ +const isGap = (value: unknown): value is DevLogReplayGap => exactKeys(value, [ 'earliestAvailableSequence', 'latestDroppedSequence', 'requestedAfterSequence', 'type', ]) && value.type === 'replay.gap' && safeInteger(value.requestedAfterSequence) && safeInteger(value.earliestAvailableSequence, 1) && safeInteger(value.latestDroppedSequence) && value.earliestAvailableSequence === value.latestDroppedSequence + 1 && value.requestedAfterSequence < value.earliestAvailableSequence; const invalid = (): LogClientError => new LogClientError('AB8093', 'Dev Log route returned an invalid response.'); -const snapshot = (value: unknown): JsonValue => { - try { return snapshotStrictJsonValue(value); } - catch { throw invalid(); } -}; -const parseResponseJson = (bytes: Uint8Array): JsonValue => { - try { return snapshot(parseJsonWithoutDuplicateKeys(new TextDecoder('utf-8', { fatal: true }).decode(bytes))); } - catch { throw invalid(); } -}; +const snapshot = (value: unknown): JsonValue => strictJsonSnapshot(value, invalid); +const parseResponseJson = (bytes: Uint8Array): JsonValue => parseStrictResponseJson(bytes, invalid); const parseMessage = (line: string): JsonValue => { - try { return snapshot(parseJsonWithoutDuplicateKeys(line)); } + try { return strictJsonSnapshot(parseJsonWithoutDuplicateKeys(line), invalid); } catch { throw invalid(); } }; const contiguous = (records: readonly DevLogRecord[], afterSequence: number): boolean => records.every((record, index) => @@ -126,11 +116,11 @@ const contiguous = (records: readonly DevLogRecord[], afterSequence: number): bo const replayFor = (value: unknown, afterSequence: number): DevLogReplay => { const detached = snapshot(value); - if (!hasExactKeys(detached, ['replay']) || !isRecord(detached.replay)) throw invalid(); + if (!exactKeys(detached, ['replay']) || !isRecord(detached.replay)) throw invalid(); const rawReplay = detached.replay; if ( - !(hasExactKeys(rawReplay, ['cursor', 'records']) || hasExactKeys(rawReplay, ['cursor', 'gap', 'records'])) || - !hasExactKeys(rawReplay.cursor, ['afterSequence']) || !safeInteger(rawReplay.cursor.afterSequence) || + !(exactKeys(rawReplay, ['cursor', 'records']) || exactKeys(rawReplay, ['cursor', 'gap', 'records'])) || + !exactKeys(rawReplay.cursor, ['afterSequence']) || !safeInteger(rawReplay.cursor.afterSequence) || rawReplay.cursor.afterSequence < afterSequence || !Array.isArray(rawReplay.records) || !rawReplay.records.every(isDevRecord) || (Object.hasOwn(rawReplay, 'gap') && !isGap(rawReplay.gap)) ) throw invalid(); @@ -168,7 +158,7 @@ const diagnosticMessages = new Map([ ['AB8093', 'Dev Log route returned an invalid response.'], ]); const diagnosticFor = (value: unknown): LogClientError => { - if (!hasExactKeys(value, ['diagnostic']) || !hasExactKeys(value.diagnostic, ['code', 'message']) || + if (!exactKeys(value, ['diagnostic']) || !exactKeys(value.diagnostic, ['code', 'message']) || typeof value.diagnostic.code !== 'string' || typeof value.diagnostic.message !== 'string') return invalid(); const message = diagnosticMessages.get(value.diagnostic.code); return message === undefined ? invalid() : new LogClientError(value.diagnostic.code, message); diff --git a/packages/workbench/src/logs/logs-page.tsx b/packages/workbench/src/logs/logs-page.tsx index 9969f5588..c5e5a4dae 100644 --- a/packages/workbench/src/logs/logs-page.tsx +++ b/packages/workbench/src/logs/logs-page.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import type { DevLogRecord, DevLogReplayGap } from '../../../agent-bundle/src/contracts/dev-logs.ts'; +import { errorMessage as sharedErrorMessage } from '../client-helpers.ts'; import { LogClient, LogClientError } from './log-client.ts'; import { logsViewFor, mergeDevLogRecords, type LogsView as LogsViewModel } from './logs-model.ts'; import './logs-page.css'; @@ -13,10 +14,7 @@ export interface LogsPageProps { const all = ''; const filter = (value: string): string | undefined => value === all ? undefined : value; -const errorMessage = (reason: unknown): string => { - try { return reason instanceof Error && typeof reason.message === 'string' ? reason.message : 'Production logs could not be read.'; } - catch { return 'Production logs could not be read.'; } -}; +const errorMessage = (reason: unknown): string => sharedErrorMessage(reason, 'Production logs could not be read.'); const isCursorAhead = (reason: unknown): boolean => { try { return reason instanceof LogClientError && reason.code === 'AB8092'; } catch { return false; } @@ -95,10 +93,13 @@ export const LogsPage = ({ client, records: suppliedRecords }: LogsPageProps) => if (merged.discardedThroughSequence !== undefined) recordLocalGap(merged.discardedThroughSequence); return true; }; + let reconnectDelayMs = 250; const reconnectLater = (): void => { if (!current) return; if (reconnect !== undefined) clearTimeout(reconnect); - reconnect = setTimeout(() => { void connect(); }, 250); + reconnect = setTimeout(() => { void connect(); }, reconnectDelayMs); + // Back off while the dev server stays down; a successful replay resets the delay. + reconnectDelayMs = Math.min(reconnectDelayMs * 2, 5000); }; const connect = async (): Promise => { const attempt = generation + 1; @@ -120,6 +121,7 @@ export const LogsPage = ({ client, records: suppliedRecords }: LogsPageProps) => const replay = await client.replay(latestSequence, generationController.signal); if (!current || attempt !== generation) return; latestSequence = replay.cursor.afterSequence; + reconnectDelayMs = 250; setError(undefined); if (replay.gap !== undefined) setGap(replay.gap); if (!observe(replay.records)) return; diff --git a/packages/workbench/src/main.tsx b/packages/workbench/src/main.tsx index 123c6a390..0f0c0fccc 100644 --- a/packages/workbench/src/main.tsx +++ b/packages/workbench/src/main.tsx @@ -62,7 +62,7 @@ import { } from './routes/routes-model.ts'; import { RoutesPage } from './routes/routes-page.tsx'; import { overviewFor } from './overview-model.ts'; -import { downloadBlob } from './client-helpers.ts'; +import { downloadBlob, errorMessage as messageFrom } from './client-helpers.ts'; import { BundleWorkflow } from './overview-page.tsx'; import { ProjectClient, type ProjectConnectionState } from './project-client.ts'; import { SkillClient } from './skill-client.ts'; @@ -98,7 +98,7 @@ const sourceFor = (diagnostic: Diagnostic): string => diagnostic.sourcePath ?? diagnostic.generatedPath ?? diagnostic.target ?? 'Project'; const errorMessage = (reason: unknown): string => - reason instanceof Error ? reason.message : 'Foreground project state could not be refreshed.'; + messageFrom(reason, 'Foreground project state could not be refreshed.'); const activeEpochFor = (status: ProjectStatus) => status.artifact.state === 'missing' ? undefined : status.artifact.activeEpoch; @@ -219,16 +219,6 @@ const isRuntimeOperationTrace = (entry: RuntimeAppBridgeTrace): entry is Runtime vectorRecord.stateVersion >= 0; }; -const traceMatchesAuthority = (trace: RuntimeAppBridgeOperationTrace, authority: RuntimeOperationTraceAuthority): boolean => - trace.bindingId === authority.bindingId && - trace.registryRevision === authority.registryRevision && - trace.sessionId === authority.sessionId && - trace.sessionRevision === authority.sessionRevision && - trace.vector.artifactEpochId === authority.vector.artifactEpochId && - trace.vector.runtimeGenerationId === authority.vector.runtimeGenerationId && - trace.vector.sourceRevision === authority.vector.sourceRevision && - trace.vector.stateVersion === authority.vector.stateVersion; - /** The stable host callback admits only the existing controller's route-free runtime binding. */ const createWorkbenchRuntimeBridgeFactory = ( appClient: McpAppClient, @@ -405,7 +395,7 @@ const Overview = ({ capabilities, changedFiles, client, connectionError, onNavig try { onStatus(await client.rebuild()); } catch (reason) { - setError(reason instanceof Error ? reason.message : 'Rebuild request could not be completed.'); + setError(messageFrom(reason, 'Rebuild request could not be completed.')); } finally { setRebuilding(false); } @@ -1081,7 +1071,7 @@ const Workbench = () => { const onRuntimeBridgeTrace = useCallback((binding: McpAppPreviewAppsSnapshot['binding'], entry: RuntimeAppBridgeTrace): void => { const authority = runtimeOperationTraceAuthorities.current.get(binding.id); if (authority === undefined || !sameRuntimeOperationTraceAuthority(authority, runtimeOperationTraceAuthority(binding)) || - !isRuntimeOperationTrace(entry) || !traceMatchesAuthority(entry, authority)) return; + !isRuntimeOperationTrace(entry) || !sameRuntimeOperationTraceAuthority(entry, authority)) return; setRuntimeOperationTraces((current) => Object.freeze([ ...current.filter((trace) => trace.bindingId !== authority.bindingId).slice(-63), entry, diff --git a/packages/workbench/src/mcp/mcp-app-client.ts b/packages/workbench/src/mcp/mcp-app-client.ts index efaa52ace..2c823b7dd 100644 --- a/packages/workbench/src/mcp/mcp-app-client.ts +++ b/packages/workbench/src/mcp/mcp-app-client.ts @@ -2,7 +2,7 @@ import { isCallToolResult } from '@modelcontextprotocol/client'; import type { ProjectClient } from '../project-client.ts'; import type { ProjectEventMessage } from '../../../agent-bundle/src/contracts/project.ts'; -import { validateMcpAppUiUri } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; +import { isMcpAppConsentCapability, validateMcpAppUiUri } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import { MCP_APP_PROFILE_DESCRIPTORS, runtimeAppMessageLimits } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import type { McpAppProfileId } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import type { @@ -20,8 +20,9 @@ import type { } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import type { McpAppConsentRequest, McpAppDocumentPolicySnapshot } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; +import { isRecord } from '../client-helpers.ts'; import { finiteOrdinaryJsonByteLength } from './finite-json.ts'; -import { ForegroundRouteClient, ForegroundRouteClientError } from './mcp-route-client.ts'; +import { ForegroundRouteClient, ForegroundRouteClientError, sameRuntimeBinding, type McpRuntimeBindingIdentity } from './mcp-route-client.ts'; export type McpAppJsonPrimitive = null | boolean | number | string; @@ -187,9 +188,6 @@ const runtimeResponseJson = async (response: Response): Promise => { return parsed; }; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const detachedJson = (value: unknown, ancestors = new WeakSet()): McpAppJsonValue => { if (value === null || typeof value === 'boolean' || typeof value === 'string') return value; if (typeof value === 'number') { @@ -321,38 +319,23 @@ const runtimeProfileId = (value: unknown): 'portable' | 'chatgpt' | 'claude' => return runtimeInvalid('Runtime MCP App route returned an invalid profile.'); }; -const runtimeJsonRecord = (value: unknown, message?: string): Readonly> => { - const record = asRecord(value); - if (message !== undefined && !hasOnlyKeys(record, Object.keys(record))) runtimeInvalid(message); - return record; -}; - const runtimeVector = (value: unknown): McpAppPublicRuntimeVector => { const record = runtimeOptionalRecord(value, ['artifactEpochId', 'runtimeGenerationId', 'sourceRevision', 'stateVersion']); + const runtimeGenerationId = runtimeText(record.runtimeGenerationId, 'runtime generation'); + const sourceRevision = runtimeText(record.sourceRevision, 'source revision'); if ( - !runtimeText(record.runtimeGenerationId, 'runtime generation') || - !runtimeText(record.sourceRevision, 'source revision') || !nonnegativeInteger(record.stateVersion) || (record.artifactEpochId !== undefined && typeof record.artifactEpochId !== 'string') ) runtimeInvalid('Runtime MCP App route returned an invalid runtime vector.'); return Object.freeze({ ...(record.artifactEpochId === undefined ? {} : { artifactEpochId: record.artifactEpochId }), - runtimeGenerationId: record.runtimeGenerationId, - sourceRevision: record.sourceRevision, + runtimeGenerationId, + sourceRevision, stateVersion: record.stateVersion, }) as McpAppPublicRuntimeVector; }; -const runtimeStableBinding = (value: unknown): Readonly<{ - readonly definitionDigest: string; - readonly registryRevision: number; - readonly serverDigest: string; - readonly serverName: string; - readonly sessionId: string; - readonly sessionRevision: number; - readonly target: string; - readonly transportDigest: string; -}> => { +const runtimeStableBinding = (value: unknown): McpRuntimeBindingIdentity => { const record = runtimeRecord(value, [ 'definitionDigest', 'registryRevision', 'serverDigest', 'serverName', 'sessionId', 'sessionRevision', 'target', 'transportDigest', ]); @@ -370,13 +353,6 @@ const runtimeStableBinding = (value: unknown): Readonly<{ }); }; -const sameRuntimeBinding = ( - left: Readonly<{ readonly definitionDigest: string; readonly registryRevision: number; readonly serverDigest: string; readonly serverName: string; readonly sessionId: string; readonly sessionRevision: number; readonly target: string; readonly transportDigest: string }>, - right: Readonly<{ readonly definitionDigest: string; readonly registryRevision: number; readonly serverDigest: string; readonly serverName: string; readonly sessionId: string; readonly sessionRevision: number; readonly target: string; readonly transportDigest: string }>, -): boolean => left.definitionDigest === right.definitionDigest && left.registryRevision === right.registryRevision && - left.serverDigest === right.serverDigest && left.serverName === right.serverName && left.sessionId === right.sessionId && - left.sessionRevision === right.sessionRevision && left.target === right.target && left.transportDigest === right.transportDigest; - const runtimeBinding = (value: unknown): McpAppRuntimeBindingSnapshot => { const record = runtimeRecord(value, [ 'definitionDigest', 'evidence', 'id', 'profileId', 'profileVersion', 'registryRevision', 'runVector', 'serverDigest', 'serverName', @@ -417,7 +393,7 @@ const runtimeConnection = (value: unknown): Readonly<{ const record = runtimeRecord(value, ['capabilities', 'protocolEra', 'protocolVersion', 'server']); if (record.capabilities !== undefined && !isRecord(record.capabilities)) runtimeInvalid('Runtime MCP App route returned invalid server capabilities.'); const server = record.server === undefined ? undefined : runtimeRecord(record.server, ['name', 'version']); - const capabilities = record.capabilities === undefined ? undefined : runtimeJsonRecord(record.capabilities); + const capabilities = record.capabilities === undefined ? undefined : asRecord(record.capabilities); const protocolEra = runtimeEra(record.protocolEra); const protocolVersion = runtimeStringOrUndefined(record.protocolVersion, 'protocol version'); return Object.freeze({ @@ -462,12 +438,12 @@ const runtimeMetadata = (value: unknown, host = false): unknown => { if (!hasExactKeys(record, host && record.claudeDomain !== undefined ? ['claudeDomain', 'extensions', 'provenance', 'raw', 'standard'] : ['extensions', 'provenance', 'raw', 'standard'])) runtimeInvalid('Runtime MCP App route returned invalid metadata inspection.'); - const raw = runtimeJsonRecord(record.raw); + const raw = asRecord(record.raw); const standard = runtimeOptionalRecord(record.standard, ['ui']); const extensions = runtimeRecord(record.extensions, ['claude', 'openai']); - const openai = runtimeJsonRecord(extensions.openai); - const claude = runtimeJsonRecord(extensions.claude); - const provenance = runtimeJsonRecord(record.provenance); + const openai = asRecord(extensions.openai); + const claude = asRecord(extensions.claude); + const provenance = asRecord(record.provenance); if (!Object.keys(raw).every((key) => Object.hasOwn(provenance, key)) || Object.keys(provenance).some((key) => !Object.hasOwn(raw, key))) { runtimeInvalid('Runtime MCP App metadata provenance does not match its raw metadata.'); } @@ -511,15 +487,15 @@ const runtimeHostContext = (value: unknown): unknown => { return Object.freeze({ availableDisplayModes: Object.freeze([...availableDisplayModes]), containerDimensions: Object.freeze({ height: dimensions.height, width: dimensions.width }), - deviceCapabilities: runtimeJsonRecord(record.deviceCapabilities), + deviceCapabilities: asRecord(record.deviceCapabilities), displayMode: record.displayMode, locale: record.locale, platform: record.platform, safeAreaInsets: Object.freeze({ bottom: insets.bottom, left: insets.left, right: insets.right, top: insets.top }), - styles: runtimeJsonRecord(record.styles), + styles: asRecord(record.styles), theme: record.theme, timeZone: record.timeZone, - toolInfo: runtimeJsonRecord(record.toolInfo), + toolInfo: asRecord(record.toolInfo), userAgent: record.userAgent, }); }; @@ -808,9 +784,7 @@ const runtimeOperationRequest = (value: unknown): McpAppBindingOperation => { const runtimeConsentRequest = (value: unknown): McpAppConsentRequest => { const record = runtimeRequestRecord(value); if (!hasExactKeys(record, ['actionFingerprint', 'capability', 'details', 'scope', 'summary']) || - (record.capability !== 'call-tool' && record.capability !== 'download-file' && record.capability !== 'open-external-link' && - record.capability !== 'clipboard-write' && record.capability !== 'camera' && record.capability !== 'microphone' && - record.capability !== 'geolocation' && record.capability !== 'request-display-mode') || + !isMcpAppConsentCapability(record.capability) || (record.scope !== 'action' && record.scope !== 'document')) runtimeInputInvalid(); return Object.freeze({ actionFingerprint: runtimeText(record.actionFingerprint, 'consent fingerprint'), @@ -858,9 +832,7 @@ const runtimeGrant = ( ): NonNullable => { const record = runtimeRecord(value, ['authorizationId', 'bindingId', 'capability', 'challengeId', 'scope']); if (record.bindingId !== bindingId || record.challengeId !== consentId || record.capability !== challenge.capability || record.scope !== challenge.scope || - (record.capability !== 'call-tool' && record.capability !== 'download-file' && record.capability !== 'open-external-link' && - record.capability !== 'clipboard-write' && record.capability !== 'camera' && record.capability !== 'microphone' && - record.capability !== 'geolocation' && record.capability !== 'request-display-mode')) runtimeInvalid('Runtime MCP App route returned an invalid consent grant.'); + !isMcpAppConsentCapability(record.capability)) runtimeInvalid('Runtime MCP App route returned an invalid consent grant.'); return Object.freeze({ authorizationId: runtimeText(record.authorizationId, 'authorization id'), bindingId, diff --git a/packages/workbench/src/mcp/mcp-app-frame.tsx b/packages/workbench/src/mcp/mcp-app-frame.tsx index c84cfe73c..3b57cf2fe 100644 --- a/packages/workbench/src/mcp/mcp-app-frame.tsx +++ b/packages/workbench/src/mcp/mcp-app-frame.tsx @@ -6,6 +6,7 @@ import type { McpAppRouteClose, McpAppRouteMessages, } from './mcp-app-client.ts'; +import { isPlainRecord } from '../strict-json.ts'; import { assertCurrentMcpAppDocumentPolicy, type McpAppRuntimeClient, type McpAppTrustedDocumentPolicy } from './mcp-app-client.ts'; import { finiteOrdinaryJsonByteLength } from './finite-json.ts'; import { AppRenderer, type BridgeFactory, type AppRendererProps } from './app-renderer.tsx'; @@ -86,9 +87,7 @@ interface RpcMessage extends Readonly => - typeof value === 'object' && value !== null && !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const isRecord = isPlainRecord; const validRequestId = (value: unknown): boolean => value === null || typeof value === 'string' || (typeof value === 'number' && Number.isFinite(value)); diff --git a/packages/workbench/src/mcp/mcp-json-input.tsx b/packages/workbench/src/mcp/mcp-json-input.tsx index dbb42363b..2059b0956 100644 --- a/packages/workbench/src/mcp/mcp-json-input.tsx +++ b/packages/workbench/src/mcp/mcp-json-input.tsx @@ -1,6 +1,7 @@ import React, { type Ref, useState } from 'react'; import type { JsonObject, JsonValue } from '../../../agent-bundle/src/contracts/runtime.ts'; +import { isRecord } from '../client-helpers.ts'; export type ImmutableJsonValue = JsonValue; export type ImmutableJsonRecord = JsonObject; @@ -82,9 +83,6 @@ const supportedFieldKeywords = new Set([ 'type', ]); -const isRecord = (value: unknown): value is Record => - value !== null && typeof value === 'object' && !Array.isArray(value); - const hasOnlyKeys = (value: Record, allowed: ReadonlySet): boolean => Object.keys(value).every((key) => allowed.has(key)); diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index fa4fa6c4e..b204f8e16 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState, type KeyboardEvent } f import type { McpSessionBinding, McpSessionInspectorConfig, McpSessionOperation } from '../../../agent-bundle/src/contracts/mcp-session.ts'; import type { DevRuntimeMcpAppRunBinding } from '../../../agent-bundle/src/contracts/runtime.ts'; +import { isRecord } from '../client-helpers.ts'; import { McpJsonInput, type ImmutableJsonRecord } from './mcp-json-input.tsx'; import { @@ -559,9 +560,6 @@ export const mcpPageSessionControls = ( }; }; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const text = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; const browserMcpAppHost = (): McpAppHostContext => { @@ -954,12 +952,17 @@ const McpPageArtifactPreview = ({ client, host, onLifecycleChange, previewProfil onLifecycleChange(current); const unsubscribe = current.subscribe(setState); let active = true; + // The poll ticks constantly; keeping the previous array reference when the + // challenge set is unchanged avoids re-rendering the preview 4x/second. + const sameChallenges = (left: readonly McpAppConsentChallenge[], right: readonly McpAppConsentChallenge[]): boolean => + left.length === right.length && + left.every((challenge, index) => challenge.id === right[index]?.id && challenge.expiresAt === right[index]?.expiresAt); const refreshConsent = async (): Promise => { try { const challenges = await current.consentChallenges(); - if (active) setConsentChallenges(challenges); + if (active) setConsentChallenges((previous) => sameChallenges(previous, challenges) ? previous : challenges); } catch { - if (active) setConsentChallenges(Object.freeze([])); + if (active) setConsentChallenges((previous) => previous.length === 0 ? previous : Object.freeze([])); } }; void current.start().then(refreshConsent); diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index a26a15fa7..9a3082134 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -9,6 +9,7 @@ import type { } from '../../../agent-bundle/src/contracts/runtime.ts'; import type { JsonObject } from '../../../agent-bundle/src/contracts/runtime.ts'; import { isMcpSessionTarget, type McpSessionTarget } from '../../../agent-bundle/src/contracts/mcp-session.ts'; +import { isRecord } from '../client-helpers.ts'; export type McpRouteTarget = McpSessionTarget; @@ -39,6 +40,24 @@ export interface McpRouteRuntimeSession { readonly state: 'connecting' | 'ready' | 'restarting' | 'failed' | 'closed'; } +/** The stable identity fields a runtime binding is compared by. */ +export type McpRuntimeBindingIdentity = Pick< + DevRuntimeMcpAppRunBinding, + | 'definitionDigest' + | 'registryRevision' + | 'serverDigest' + | 'serverName' + | 'sessionId' + | 'sessionRevision' + | 'target' + | 'transportDigest' +>; + +export const sameRuntimeBinding = (left: McpRuntimeBindingIdentity, right: McpRuntimeBindingIdentity): boolean => + left.definitionDigest === right.definitionDigest && left.registryRevision === right.registryRevision && + left.serverDigest === right.serverDigest && left.serverName === right.serverName && left.sessionId === right.sessionId && + left.sessionRevision === right.sessionRevision && left.target === right.target && left.transportDigest === right.transportDigest; + export interface McpRouteRuntimeRestart { readonly reconcile: DevRuntimeMcpRegistryReconcileResult; readonly session: McpRouteRuntimeSession; @@ -106,9 +125,6 @@ interface Diagnostic { readonly phase?: string; } -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isTarget = isMcpSessionTarget; const detachedJson = (value: unknown, ancestors = new WeakSet()): unknown => { diff --git a/packages/workbench/src/mcp/mcp-session-controller.ts b/packages/workbench/src/mcp/mcp-session-controller.ts index ca80a476f..04d4d349a 100644 --- a/packages/workbench/src/mcp/mcp-session-controller.ts +++ b/packages/workbench/src/mcp/mcp-session-controller.ts @@ -19,7 +19,7 @@ import type { JsonObject } from '../../../agent-bundle/src/contracts/runtime.ts' import type { McpAppBoundOperationResult } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import type { McpAppJsonValue } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import type { McpAppBindingOperation } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; -import { parseStrictResponseJson } from '../client-helpers.ts'; +import { isRecord, parseStrictResponseJson } from '../client-helpers.ts'; import { readNdjsonResponseFrames } from '../ndjson.ts'; import { AgentBundleRemoteTransport, dispatchAgentBundleMcpRequest, type AgentBundleMcpDispatchResult } from './agent-bundle-remote-transport.ts'; import { @@ -35,6 +35,7 @@ import { } from './mcp-session-model.ts'; import { McpRouteClientError, + sameRuntimeBinding, type McpRouteCatalog, type McpRouteClient, type McpRouteConnection, @@ -234,9 +235,6 @@ const constructionDrain = (): ConstructionDrain => { return { settled, settle }; }; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isDataDescriptor = (value: PropertyDescriptor | undefined): value is PropertyDescriptor & { readonly value: unknown } => value !== undefined && Object.hasOwn(value, 'value') && !Object.hasOwn(value, 'get') && !Object.hasOwn(value, 'set'); @@ -289,11 +287,6 @@ const isRuntimeSession = (value: unknown): value is McpRouteRuntimeSession => isRecord(value) && Object.keys(value).length === 3 && isRuntimeBinding(value.binding) && isRuntimeConnection(value.connection) && (value.state === 'connecting' || value.state === 'ready' || value.state === 'restarting' || value.state === 'failed' || value.state === 'closed'); -const sameRuntimeBinding = (left: DevRuntimeMcpAppRunBinding, right: DevRuntimeMcpAppRunBinding): boolean => - left.definitionDigest === right.definitionDigest && left.registryRevision === right.registryRevision && - left.serverDigest === right.serverDigest && left.serverName === right.serverName && left.sessionId === right.sessionId && - left.sessionRevision === right.sessionRevision && left.target === right.target && left.transportDigest === right.transportDigest; - type RuntimeSessionAdoptionLane = 'implementation' | 'restart'; const runtimeSessionAdoptionLane = ( diff --git a/packages/workbench/src/playground/playground-client.ts b/packages/workbench/src/playground/playground-client.ts index 9bab22b92..0b7872261 100644 --- a/packages/workbench/src/playground/playground-client.ts +++ b/packages/workbench/src/playground/playground-client.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { NATIVE_HOSTS } from '../../../agent-bundle/src/contracts/playground.ts'; +import { isRecord } from '../client-helpers.ts'; import type { DraftEvalCase, PlaygroundExport, @@ -37,9 +38,6 @@ export class PlaygroundClientError extends Error { } } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const invalidResponse = (): PlaygroundClientError => new PlaygroundClientError('AB8043', 'Playground route returned an invalid response.'); diff --git a/packages/workbench/src/playground/playground-page.tsx b/packages/workbench/src/playground/playground-page.tsx index 778ce5bbd..35f60d945 100644 --- a/packages/workbench/src/playground/playground-page.tsx +++ b/packages/workbench/src/playground/playground-page.tsx @@ -16,6 +16,7 @@ import type { PlaygroundOperationRequest, PlaygroundRun } from '../../../agent-b import { NATIVE_HOST_LABELS } from '../../../agent-bundle/src/contracts/playground.ts'; import type { NativePlaygroundCatalog, NativePlaygroundHost } from '../../../agent-bundle/src/contracts/playground.ts'; +import { errorMessage as messageFrom } from '../client-helpers.ts'; import { canonicalHookInputFor } from '../hooks/hooks-page.tsx'; import { parseRawJsonRecord, serializeJsonRecord } from '../mcp/mcp-json-input.tsx'; import { PlaygroundClientError, type PlaygroundClient } from './playground-client.ts'; @@ -83,7 +84,7 @@ export type PlaygroundOperation = PlaygroundOperationRequest['operation']; const jsonDraftError = 'This field must contain a JSON object.'; const errorMessage = (reason: unknown): string => - reason instanceof Error ? reason.message : 'The playground request could not be completed.'; + messageFrom(reason, 'The playground request could not be completed.'); const asJsonObject = (value: Readonly>): PlaygroundJsonObject => value as PlaygroundJsonObject; diff --git a/packages/workbench/src/project-client.ts b/packages/workbench/src/project-client.ts index 4c30c7e69..230f5f1d9 100644 --- a/packages/workbench/src/project-client.ts +++ b/packages/workbench/src/project-client.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts'; +import { isRecord } from './client-helpers.ts'; import { freezeJsonValue, type ArtifactEpoch, @@ -89,9 +90,6 @@ const browserEvents: EventSourceFactory = (url) => new EventSource(url); const retryDelay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); const retryDelayMilliseconds = 250; -const isRecord = (value: unknown): value is Readonly> => - value !== null && typeof value === 'object' && !Array.isArray(value); - const hasExactKeys = (value: Readonly>, keys: readonly string[]): boolean => Object.keys(value).length === keys.length && Object.keys(value).every((key) => keys.includes(key)); diff --git a/packages/workbench/src/routes/route-manifest-client.ts b/packages/workbench/src/routes/route-manifest-client.ts index 742569df8..0528eebbc 100644 --- a/packages/workbench/src/routes/route-manifest-client.ts +++ b/packages/workbench/src/routes/route-manifest-client.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics.ts'; +import { isRecord } from '../client-helpers.ts'; import type { RouteManifest, RouteManifestCliCommand, @@ -190,9 +191,6 @@ const manifestSchema: z.ZodType = z.strictObject({ const responseSchema = z.strictObject({ manifest: manifestSchema }); -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const diagnosticError = (value: unknown, status: number): RouteManifestClientError => { if ( isRecord(value) && isRecord(value.diagnostic) && diff --git a/packages/workbench/src/runtime-client.ts b/packages/workbench/src/runtime-client.ts index c27851d11..f9f1a2f2b 100644 --- a/packages/workbench/src/runtime-client.ts +++ b/packages/workbench/src/runtime-client.ts @@ -19,6 +19,7 @@ import type { RuntimeVector, } from '../../agent-bundle/src/contracts/runtime.ts'; import { ForegroundRouteClient, ForegroundRouteClientError } from './mcp/mcp-route-client.ts'; +import { isPlainRecord } from './strict-json.ts'; import { AgentDocumentClient, AgentDocumentClientError, @@ -54,9 +55,7 @@ const diagnosticPhases = new Set([ 'provider-lifecycle', ]); -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value) && - (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); +const isRecord = isPlainRecord; const hasOnly = (value: Readonly>, fields: readonly string[]): boolean => Object.keys(value).every((field) => fields.includes(field)); diff --git a/packages/workbench/src/runtime-playground.tsx b/packages/workbench/src/runtime-playground.tsx index b245828a7..b3ac69a24 100644 --- a/packages/workbench/src/runtime-playground.tsx +++ b/packages/workbench/src/runtime-playground.tsx @@ -9,7 +9,7 @@ import type { DevRuntimeSurface, } from '../../agent-bundle/src/contracts/runtime.ts'; import type { ProjectEventMessage, ProjectReplayGap } from '../../agent-bundle/src/contracts/runtime.ts'; -import { downloadBlob } from './client-helpers.ts'; +import { downloadBlob, errorMessage as messageFrom } from './client-helpers.ts'; import { RuntimeClientError, type RuntimeBootstrap } from './runtime-client.ts'; import { McpJsonInput, serializeJsonValue, type ImmutableJsonValue } from './mcp/mcp-json-input.tsx'; import { @@ -98,9 +98,8 @@ export interface RuntimePlaygroundControllerOptions { export const runtimePlaygroundLiveMcpPageAdapter: RuntimeLiveMcpPageAdapter = Object.freeze({ kind: 'disabled' }); -const errorMessage = (reason: unknown): string => reason instanceof Error - ? reason.message - : 'Runtime request could not be completed.'; +const errorMessage = (reason: unknown): string => + messageFrom(reason, 'Runtime request could not be completed.'); const isForegroundEffect = (effect: RuntimeModel['activeEffect'] | RuntimeModel['pendingEffect']): boolean => effect?.kind === 'create-run' || effect?.kind === 'replay-run' || effect?.kind === 'reset-state'; diff --git a/packages/workbench/src/strict-json.ts b/packages/workbench/src/strict-json.ts index 2e5544fc7..9c08e1d9d 100644 --- a/packages/workbench/src/strict-json.ts +++ b/packages/workbench/src/strict-json.ts @@ -1,4 +1,5 @@ export { + isPlainRecord, mapStrictJsonReason, snapshotStrictJsonValue, StrictJsonError, diff --git a/packages/workbench/src/workbench-capabilities.ts b/packages/workbench/src/workbench-capabilities.ts index 020ff6c6b..ff0a5393c 100644 --- a/packages/workbench/src/workbench-capabilities.ts +++ b/packages/workbench/src/workbench-capabilities.ts @@ -1,6 +1,8 @@ import type { ArtifactInspection } from '../../agent-bundle/src/contracts/artifacts.ts'; import type { SkillDocumentTree } from '../../agent-bundle/src/contracts/skills.ts'; +import { errorMessage as messageFrom } from './client-helpers.ts'; + import type { ArtifactClient } from './artifacts/artifact-client.ts'; import type { EvalClient } from './evals/eval-client.ts'; import type { RouteManifestClient } from './routes/route-manifest-client.ts'; @@ -73,7 +75,7 @@ const pagesFor = ( }; const errorMessage = (reason: unknown): string => - reason instanceof Error ? reason.message : 'The compiled route manifest could not be read.'; + messageFrom(reason, 'The compiled route manifest could not be read.'); /** * An absent or refused manifest route degrades this one section rather than the From 978e19fb79ee45658f9f9debcb42e880d7d38ef6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 07:15:51 +0000 Subject: [PATCH 2/6] refactor: round-2 consolidation from multi-model review fleet POSIX path helpers get one home in core/paths.ts (toPosixPath/ toPosixRelative plus exists/resolveContained), key-shape guards consolidate onto hasExactOwnKeys and a new hasOnlyOwnKeys canonical, and the state drivers share their input validators via state/contract. Removes the dead Mcp component-set guard, the sqlite #commit rebinding block (Effect.gen self idiom), the Lease double-cast, a triplicated abortError, duplicated routes AST helpers, and the scaffolder's hardcoded default-targets literal. Audiobook example consolidates errorMessage and probes inventory files concurrently. --- examples/audiobook-curator/src/audible.ts | 7 ++-- examples/audiobook-curator/src/evidence.ts | 6 +-- examples/audiobook-curator/src/foundation.ts | 3 ++ .../audiobook-curator/src/integrity-audit.ts | 4 +- examples/audiobook-curator/src/library.ts | 18 ++++----- .../audiobook-curator/src/media-mutation.ts | 5 +-- packages/agent-bundle/src/build/emit.ts | 15 +------ .../agent-bundle/src/build/package-build.ts | 7 +--- packages/agent-bundle/src/build/provenance.ts | 10 ++--- packages/agent-bundle/src/config/ignore.ts | 7 ++-- packages/agent-bundle/src/config/validate.ts | 4 +- .../agent-bundle/src/contracts/strict-json.ts | 1 + packages/agent-bundle/src/core/paths.ts | 28 +++++++++++++ packages/agent-bundle/src/core/strict-json.ts | 4 ++ packages/agent-bundle/src/dev/epoch-store.ts | 7 +--- .../agent-bundle/src/dev/eval/eval-routes.ts | 5 +-- .../agent-bundle/src/dev/eval/eval-service.ts | 5 +-- packages/agent-bundle/src/dev/http.ts | 6 +-- .../src/dev/mcp-apps/mcp-app-routes.ts | 4 +- .../src/dev/package-build-service.ts | 7 ++-- .../playground/native-playground-service.ts | 9 ++--- .../src/dev/playground/playground-store.ts | 8 +--- .../src/dev/runtime-mcp-routes.ts | 4 +- .../agent-bundle/src/dev/runtime-routes.ts | 5 +-- packages/agent-bundle/src/install/doctor.ts | 11 +----- packages/agent-bundle/src/install/install.ts | 11 +----- .../agent-bundle/src/routes/config-extract.ts | 25 +----------- .../agent-bundle/src/routes/input-schema.ts | 4 +- packages/agent-bundle/src/services/mcp-run.ts | 7 +--- .../agent-bundle/src/services/mcp-service.ts | 7 +--- packages/create-agent-bundle/src/scaffold.ts | 10 +++-- packages/rsc-runtime/src/agent-document.ts | 4 ++ packages/rsc-runtime/src/agent-request.ts | 19 +++++---- packages/rsc-runtime/src/dispatcher.ts | 3 +- packages/rsc-runtime/src/lower-mcp.ts | 11 ++---- packages/rsc-runtime/src/reconciler.ts | 26 +++++-------- packages/rsc-runtime/src/state/contract.ts | 15 +++++++ .../rsc-runtime/src/state/memory-driver.ts | 17 +------- packages/rsc-runtime/src/state/sqlite.ts | 39 ++++--------------- .../src/artifacts/artifact-client.ts | 6 +-- packages/workbench/src/mcp/mcp-app-client.ts | 11 ++---- .../workbench/src/mcp/mcp-route-client.ts | 7 ++-- .../src/playground/playground-client.ts | 7 +--- packages/workbench/src/project-client.ts | 5 +-- packages/workbench/src/runtime-client.ts | 5 +-- packages/workbench/src/strict-json.ts | 1 + scripts/audit-packed-release.mjs | 19 +++------ scripts/npm-pack-json.mjs | 24 ++++++++++++ scripts/run-packed-tests.mjs | 22 +---------- 49 files changed, 207 insertions(+), 288 deletions(-) create mode 100644 scripts/npm-pack-json.mjs diff --git a/examples/audiobook-curator/src/audible.ts b/examples/audiobook-curator/src/audible.ts index a4fee8823..e8b6becc2 100644 --- a/examples/audiobook-curator/src/audible.ts +++ b/examples/audiobook-curator/src/audible.ts @@ -7,6 +7,7 @@ import { CuratorError, audibleHosts, contributorNames, + errorMessage, normalizedIdentity, syncDirectory, syncFile, @@ -199,7 +200,7 @@ export const requestWithAttempts = async ( failure = error; } } - throw new CuratorError(failure instanceof Error ? failure.message : `Request failed: ${url}`); + throw new CuratorError(errorMessage(failure, `Request failed: ${url}`)); }; export const searchAudible = async ( @@ -232,7 +233,7 @@ export const searchAudible = async ( region, }))); } catch (error) { - errors.push({ error: error instanceof Error ? error.message : 'Audible search failed.', region }); + errors.push({ error: errorMessage(error, 'Audible search failed.'), region }); } } candidates.sort((left, right) => right.evidence.score - left.evidence.score); @@ -316,7 +317,7 @@ export const cacheAudibleEdition = async ( chapterPath = join(cache, 'chapters.json'); await writeReceipt(chapterPath, chapters); } catch (error) { - chapterError = error instanceof Error ? error.message : 'Audible chapter request failed.'; + chapterError = errorMessage(error, 'Audible chapter request failed.'); } const images = productRecord.product_images; const imageUrl = images !== null && typeof images === 'object' && !Array.isArray(images) diff --git a/examples/audiobook-curator/src/evidence.ts b/examples/audiobook-curator/src/evidence.ts index 1286bec99..ecdf909da 100644 --- a/examples/audiobook-curator/src/evidence.ts +++ b/examples/audiobook-curator/src/evidence.ts @@ -10,7 +10,7 @@ import { type AudibleRegion, type CuratorHttpClient, } from './audible.ts'; -import { CuratorError, asRecord, audibleHosts, contributorNames, readJson, utcNow, writeReceipt } from './foundation.ts'; +import { CuratorError, asRecord, audibleHosts, contributorNames, errorMessage, readJson, utcNow, writeReceipt } from './foundation.ts'; import { probeMediaRecord, type LibraryDependencies } from './library.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; @@ -152,7 +152,7 @@ const pythonMatcher = (python: string, process: MediaProcess): AcousticMatcher = asRecord(parsed); return Object.freeze(parsed); } catch (error) { - throw new CuratorError(`Audiolocate is optional; install it for ${python}, or inject an acoustic matcher. ${error instanceof Error ? error.message : ''}`.trim()); + throw new CuratorError(`Audiolocate is optional; install it for ${python}, or inject an acoustic matcher. ${errorMessage(error, '')}`.trim()); } }; @@ -282,7 +282,7 @@ export const identifyAudibleSample = async ( if (input.all !== true) break; } } catch (error) { - const reason = error instanceof Error ? error.message : 'Acoustic comparison failed.'; + const reason = errorMessage(error, 'Acoustic comparison failed.'); attempts.push({ ...base, reason, status: reason.includes('no sample URL') ? 'skipped' : 'error' }); } } diff --git a/examples/audiobook-curator/src/foundation.ts b/examples/audiobook-curator/src/foundation.ts index 3c2e7f491..0fd594198 100644 --- a/examples/audiobook-curator/src/foundation.ts +++ b/examples/audiobook-curator/src/foundation.ts @@ -24,6 +24,9 @@ export class CuratorError extends Error {} export const utcNow = (): string => new Date().toISOString(); +export const errorMessage = (error: unknown, fallback: string): string => + error instanceof Error ? error.message : fallback; + /** Narrows an unknown value to a plain record, or returns an empty one. */ export const asRecord = (value: unknown): Record => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record diff --git a/examples/audiobook-curator/src/integrity-audit.ts b/examples/audiobook-curator/src/integrity-audit.ts index 7d464dd66..d910f9a43 100644 --- a/examples/audiobook-curator/src/integrity-audit.ts +++ b/examples/audiobook-curator/src/integrity-audit.ts @@ -2,7 +2,7 @@ import { lstat } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { chapterMappingIssues, type ChapterRow } from './conversion.ts'; -import { CuratorError, asRecord, readJson, sha256File, utcNow, writeReceipt } from './foundation.ts'; +import { CuratorError, asRecord, errorMessage, readJson, sha256File, utcNow, writeReceipt } from './foundation.ts'; import { probeMediaDetails, probeMediaRecord, type LibraryDependencies, type MediaDetails, type MediaRecord } from './library.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; @@ -117,7 +117,7 @@ export const auditAudiobookIntegrity = async ( fullDecode = 'verified'; } catch (error) { fullDecode = 'failed'; - issues.push(`full decode failed: ${error instanceof Error ? error.message : 'unknown failure'}`); + issues.push(`full decode failed: ${errorMessage(error, 'unknown failure')}`); } } const after = await lstat(file); diff --git a/examples/audiobook-curator/src/library.ts b/examples/audiobook-curator/src/library.ts index b45dc56c4..9cdcae551 100644 --- a/examples/audiobook-curator/src/library.ts +++ b/examples/audiobook-curator/src/library.ts @@ -1,7 +1,7 @@ import { lstat, opendir } from 'node:fs/promises'; import { basename, dirname, extname, join, relative, resolve } from 'node:path'; -import { audioExtensions, mapWithConcurrency, naturalCompare, normalizedIdentity, utcNow } from './foundation.ts'; +import { audioExtensions, errorMessage, mapWithConcurrency, naturalCompare, normalizedIdentity, utcNow } from './foundation.ts'; import { runMediaProcess, type MediaProcess } from './media-process.ts'; const maximumEntries = 65_536; @@ -204,23 +204,21 @@ const discover = async (source: string): Promise<{ readonly files: string[]; rea return { files, root: selected }; }; -const errorMessage = (error: unknown): string => error instanceof Error ? error.message : 'Audiobook inspection failed.'; - export const createInventory = async ( input: { readonly source: string; readonly strict?: boolean }, dependencies: LibraryDependencies = {}, ): Promise => { const discovered = await discover(input.source); - const files: MediaRecord[] = []; - const errors: Array<{ error: string; path: string }> = []; - for (const path of discovered.files) { + const outcomes = await mapWithConcurrency(discovered.files, 2, async (path) => { dependencies.signal?.throwIfAborted(); try { - files.push(await probeMediaRecord(path, discovered.root, dependencies)); + return { ok: true as const, record: await probeMediaRecord(path, discovered.root, dependencies) }; } catch (error) { - errors.push(Object.freeze({ error: errorMessage(error), path })); + return { error: errorMessage(error, 'Audiobook inspection failed.'), ok: false as const, path }; } - } + }); + const files = outcomes.flatMap((outcome) => outcome.ok ? [outcome.record] : []); + const errors = outcomes.flatMap((outcome) => outcome.ok ? [] : [Object.freeze({ error: outcome.error, path: outcome.path })]); return Object.freeze({ errors: Object.freeze(errors), exitCode: input.strict === true && errors.length > 0 ? 1 : 0, @@ -275,7 +273,7 @@ const auditFile = async (path: string, root: string, dependencies: LibraryDepend const metadata = await lstat(path); return Object.freeze({ bytes: metadata.size, - error: errorMessage(error), + error: errorMessage(error, 'Audiobook inspection failed.'), extension: extname(path).toLowerCase(), missing: Object.freeze({ album: false, artwork: false, author: false, chapters: false, title: false }), path: resolve(path), diff --git a/examples/audiobook-curator/src/media-mutation.ts b/examples/audiobook-curator/src/media-mutation.ts index 763a2d074..62373734a 100644 --- a/examples/audiobook-curator/src/media-mutation.ts +++ b/examples/audiobook-curator/src/media-mutation.ts @@ -7,6 +7,7 @@ import { asRecord, contributorNames, escapeFfmetadata, + errorMessage, readJson, sha256File, syncDirectory, @@ -106,8 +107,6 @@ const chaptersFromDetails = (details: MediaDetails): Omit[ const duration = (details: MediaDetails): number => Number(asRecord(details.format).duration ?? 0); -const errorText = (error: unknown): string => error instanceof Error ? error.message : 'Media mutation failed.'; - export const cleanCatalogText = (value: unknown): string => String(value ?? '') .replaceAll(//giu, '\n') .replaceAll(/<[^>]+>/gu, '') @@ -400,6 +399,6 @@ export const applyAudiobookChapters = async ( return receipt; } catch (error) { if (error instanceof CuratorError) throw error; - throw new CuratorError(errorText(error)); + throw new CuratorError(errorMessage(error, 'Media mutation failed.')); } finally { await rm(work, { force: true, recursive: true }); } }; diff --git a/packages/agent-bundle/src/build/emit.ts b/packages/agent-bundle/src/build/emit.ts index fb61ba2ed..652c33448 100644 --- a/packages/agent-bundle/src/build/emit.ts +++ b/packages/agent-bundle/src/build/emit.ts @@ -12,8 +12,7 @@ import { import { basename, dirname, join, resolve } from 'node:path'; import { sha256Hex, stableJson } from '../core/digest.ts'; -import { isErrno } from '../core/errors.ts'; -import { assertInside } from '../core/paths.ts'; +import { assertInside, exists, toPosixPath } from '../core/paths.ts'; import type { TargetArtifactEntry } from '../adapters/types.ts'; import { artifactHookIndexName, @@ -56,21 +55,11 @@ export { artifactHookIndexName } from './hook-index.ts'; export type { ArtifactHook, ArtifactHookIndex } from './hook-index.ts'; export const artifactManifestName = 'agent-bundle.manifest.json'; -const normalizeRelativePath = (path: string): string => path.replaceAll('\\', '/'); +const normalizeRelativePath = toPosixPath; const executableFileMode = (file: ArtifactFile): number | undefined => (file.mode & 0o111) === 0 ? undefined : file.mode; -const exists = async (path: string): Promise => { - try { - await lstat(path); - return true; - } catch (error) { - if (isErrno(error, 'ENOENT')) return false; - throw error; - } -}; - export const resolveArtifactDestination = (root: string, relativePath: string): string => assertInside(root, resolve(root, relativePath)); diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index 5a149bcb2..f2b1b6f64 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -1,10 +1,10 @@ import { existsSync } from 'node:fs'; import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve } from 'node:path'; +import { basename, dirname, join, resolve } from 'node:path'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; -import { assertInside } from '../core/paths.ts'; +import { assertInside, toPosixRelative } from '../core/paths.ts'; import { declarationBuildDiagnostics, replayDeclarationEmit } from './declaration-diagnostics.ts'; import { listArtifactFiles, publishArtifact, resolveArtifactDestination } from './emit.ts'; import { scanEntryExports } from './entry-exports.ts'; @@ -53,9 +53,6 @@ export interface PackageBuildResult { readonly outputRoot: string; } -const toPosixRelative = (root: string, path: string): string => - relative(resolve(root), path).replaceAll('\\', '/'); - const relativeSourceInputs = (projectRoot: string, inputs: readonly string[]): readonly string[] => Object.freeze([...new Set(inputs.map((input) => toPosixRelative(projectRoot, assertInside(projectRoot, input))))] .sort((left, right) => left.localeCompare(right))); diff --git a/packages/agent-bundle/src/build/provenance.ts b/packages/agent-bundle/src/build/provenance.ts index 30f2e7e29..67b3ac679 100644 --- a/packages/agent-bundle/src/build/provenance.ts +++ b/packages/agent-bundle/src/build/provenance.ts @@ -1,6 +1,6 @@ import { isAbsolute, relative, resolve, win32 } from 'node:path'; -import { assertInside } from '../core/paths.ts'; +import { assertInside, toPosixRelative } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; export type ArtifactOutputKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; @@ -45,14 +45,14 @@ interface PublicStats { type JsonRecord = Readonly>; -const toPosixRelative = (root: string, path: string): string => - relative(resolve(root), assertInside(root, path)).replaceAll('\\', '/'); +const containedPosixRelative = (root: string, path: string): string => + toPosixRelative(root, assertInside(root, path)); const sortedUnique = (paths: readonly string[]): readonly string[] => Object.freeze([...new Set(paths)].sort((left, right) => left.localeCompare(right))); const sourceInputsFor = (projectRoot: string, sourceInputs: readonly string[]): readonly string[] => - sortedUnique(sourceInputs.map((source) => toPosixRelative(projectRoot, source))); + sortedUnique(sourceInputs.map((source) => containedPosixRelative(projectRoot, source))); const asRecord = (value: unknown): JsonRecord | undefined => isRecord(value) ? value as JsonRecord : undefined; @@ -225,7 +225,7 @@ export const createOutputProvenance = (options: { } return Object.freeze({ kind: output.kind, - path: toPosixRelative(options.artifactRoot, output.path), + path: containedPosixRelative(options.artifactRoot, output.path), sourceInputs, }); }) diff --git a/packages/agent-bundle/src/config/ignore.ts b/packages/agent-bundle/src/config/ignore.ts index 4c3f36c0d..760281224 100644 --- a/packages/agent-bundle/src/config/ignore.ts +++ b/packages/agent-bundle/src/config/ignore.ts @@ -1,9 +1,10 @@ import { readFile } from 'node:fs/promises'; -import { join, relative, sep } from 'node:path'; +import { join } from 'node:path'; import ignore, { type Ignore } from 'ignore'; import { isErrno } from '../core/errors.ts'; +import { toPosixRelative } from '../core/paths.ts'; const mandatoryDirectoryNames = new Set([ '.agent-bundle', @@ -12,7 +13,7 @@ const mandatoryDirectoryNames = new Set([ 'node_modules', ]); -export const toPosixPath = (path: string): string => path.split(sep).join('/'); +export { toPosixPath } from '../core/paths.ts'; const isMandatoryIgnored = (relativePath: string): boolean => relativePath.split('/').some((part) => mandatoryDirectoryNames.has(part)); @@ -36,7 +37,7 @@ export const isProjectPathIgnored = ( root: string, source: string, ): boolean => { - const relativePath = toPosixPath(relative(root, source)); + const relativePath = toPosixRelative(root, source); return ( relativePath.length > 0 && diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index eca3137c1..05425ab64 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'n import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } from 'node:path'; import { scanEntryExportsSource } from '../build/entry-exports.ts'; +import { toPosixRelative } from '../core/paths.ts'; import { isRecord } from '../core/strict-json.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; @@ -601,8 +602,7 @@ const validateMcpApps = ( return diagnostics; }; -const relativePosix = (root: string, path: string): string => - relative(root, path).replaceAll('\\', '/'); +const relativePosix = toPosixRelative; /** * AB4730: a local stdio entry whose module never default-exports a factory diff --git a/packages/agent-bundle/src/contracts/strict-json.ts b/packages/agent-bundle/src/contracts/strict-json.ts index 14ddbdbbd..c95c915b8 100644 --- a/packages/agent-bundle/src/contracts/strict-json.ts +++ b/packages/agent-bundle/src/contracts/strict-json.ts @@ -3,6 +3,7 @@ * snapshotting. The workbench must import from here, never from core/. */ export { + hasOnlyOwnKeys, isPlainRecord, mapStrictJsonReason, parseJsonWithoutDuplicateKeys, diff --git a/packages/agent-bundle/src/core/paths.ts b/packages/agent-bundle/src/core/paths.ts index b3594bef7..14d3cd23a 100644 --- a/packages/agent-bundle/src/core/paths.ts +++ b/packages/agent-bundle/src/core/paths.ts @@ -1,9 +1,22 @@ import { isAbsolute, posix, relative, resolve, sep } from 'node:path'; +import { lstat } from 'node:fs/promises'; import type { Stats } from 'node:fs'; +import { isErrno } from './errors.ts'; + const escapesRoot = (path: string): boolean => path === '..' || path.startsWith('../') || path.startsWith('..\\') || isAbsolute(path); +/** + * Converts a host-native path to POSIX separators. Callers must pass paths + * produced by host `join`/`relative`; literal backslashes inside POSIX + * segment names are preserved. + */ +export const toPosixPath = (path: string): string => path.split(sep).join('/'); + +/** POSIX-form path of `path` relative to `root`; asserts nothing about containment. */ +export const toPosixRelative = (root: string, path: string): string => toPosixPath(relative(root, path)); + /** True when candidate resolves strictly inside root. */ export const isInside = (root: string, candidate: string): boolean => { const path = relative(root, candidate); @@ -62,3 +75,18 @@ export const joinArtifact = (root: string, relativePath: string): string => { /** dev/ino identity check shared by the symlink/TOCTOU defenses. */ export const sameFile = (left: Stats, right: Stats): boolean => left.dev === right.dev && left.ino === right.ino; + +/** True when a filesystem entry (including a symlink itself) exists at path. */ +export const exists = async (path: string): Promise => { + try { + await lstat(path); + return true; + } catch (error) { + if (isErrno(error, 'ENOENT')) return false; + throw error; + } +}; + +/** Absolute paths pass through; relative paths must resolve inside root. */ +export const resolveContained = (root: string, path: string): string => + isAbsolute(path) ? path : assertInside(root, resolve(root, path)); diff --git a/packages/agent-bundle/src/core/strict-json.ts b/packages/agent-bundle/src/core/strict-json.ts index 960658432..22dd2fc36 100644 --- a/packages/agent-bundle/src/core/strict-json.ts +++ b/packages/agent-bundle/src/core/strict-json.ts @@ -152,6 +152,10 @@ export const hasExactOwnKeys = (value: object, keys: readonly string[]): boolean return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); }; +/** Every own key is allowed; missing keys are tolerated (subset contract). */ +export const hasOnlyOwnKeys = (value: object, keys: readonly string[]): boolean => + Object.keys(value).every((key) => keys.includes(key)); + /** Plain object whose own properties are all string-keyed data properties (no accessors, no symbols). */ export const isPlainDataRecord = (value: unknown): value is Record => { if (!isRecord(value)) return false; diff --git a/packages/agent-bundle/src/dev/epoch-store.ts b/packages/agent-bundle/src/dev/epoch-store.ts index a0f53a1e6..f9d70f246 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 { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { hasExactOwnKeys, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { runPromise, runSync } from '../effect/boundary.ts'; import { liftPromise, liftTry } from '../effect/lift.ts'; import { freezeArtifactEpoch, type ArtifactEpoch } from './types.ts'; @@ -156,11 +156,6 @@ const epochReferenceCounts = new Map(); */ const epochLeaseMutexes = new Map(); -const hasExactOwnKeys = (value: object, keys: readonly string[]): boolean => { - const actual = Object.keys(value); - return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); -}; - /** Every required key present, every present key either required or optional. */ const hasRequiredOwnKeys = ( value: object, diff --git a/packages/agent-bundle/src/dev/eval/eval-routes.ts b/packages/agent-bundle/src/dev/eval/eval-routes.ts index a12c03b21..856d508e1 100644 --- a/packages/agent-bundle/src/dev/eval/eval-routes.ts +++ b/packages/agent-bundle/src/dev/eval/eval-routes.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; -import { parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; +import { hasOnlyOwnKeys, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { EvalConfigError, EvalDefinitionError, @@ -243,8 +243,7 @@ const route = (requestTarget: string | undefined): Route | undefined => { const isRecord = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value); -const hasOnly = (value: JsonObject, fields: readonly string[]): boolean => - Object.keys(value).every((field) => fields.includes(field)); +const hasOnly: (value: JsonObject, fields: readonly string[]) => boolean = hasOnlyOwnKeys; const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && value.length <= 4_096 && !value.includes('\0'); diff --git a/packages/agent-bundle/src/dev/eval/eval-service.ts b/packages/agent-bundle/src/dev/eval/eval-service.ts index 91faf4830..01d45c913 100644 --- a/packages/agent-bundle/src/dev/eval/eval-service.ts +++ b/packages/agent-bundle/src/dev/eval/eval-service.ts @@ -34,7 +34,7 @@ import { } from '../../eval/run-store.ts'; import type { EvalAssertionKind, EvalCase, EvalInvocation } from '../../eval/types.ts'; import type { DevLogKindFor, DevLogSink } from '../logs/dev-log-service.ts'; -import { isInsideOrEqual } from '../../core/paths.ts'; +import { isInsideOrEqual, toPosixRelative } from '../../core/paths.ts'; import { isErrno } from '../../core/errors.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -237,8 +237,7 @@ export class EvalServiceBackgroundFailureRetention { } } -const projectRelative = (projectRoot: string, path: string): string => - relative(projectRoot, path).replaceAll('\\', '/'); +const projectRelative = toPosixRelative; /** Persist artifact identity without leaking or trusting an absolute host path. */ const storedArtifactBinding = ( diff --git a/packages/agent-bundle/src/dev/http.ts b/packages/agent-bundle/src/dev/http.ts index 72c3a134d..deeb05c15 100644 --- a/packages/agent-bundle/src/dev/http.ts +++ b/packages/agent-bundle/src/dev/http.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { isRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { hasOnlyOwnKeys, isRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; export interface RequestDiagnostic { readonly code: string; @@ -169,8 +169,8 @@ export const decodedOpaqueSegment = (segment: string, options: OpaqueSegmentOpti return decoded; }; -export const hasOnly = (value: Readonly>, fields: readonly string[]): boolean => - Object.keys(value).every((field) => fields.includes(field)); +export const hasOnly: (value: Readonly>, fields: readonly string[]) => boolean = + hasOnlyOwnKeys; export const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && value.length <= 4_096 && !value.includes('\0'); diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts index d4c6e9cd0..5195eaf27 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-routes.ts @@ -10,6 +10,7 @@ import type { McpAppBindingOperation, McpAppRuntimeRoutePreviewService, } from '../mcp-app-runtime-preview-service.ts'; +import { hasOnlyOwnKeys } from '../../core/strict-json.ts'; import { isMcpAppConsentCapability } from './mcp-app-sandbox.ts'; import type { McpAppConsentChallenge } from './mcp-app-sandbox.ts'; import type { McpAppConsentRequest } from './mcp-app-sandbox.ts'; @@ -253,8 +254,7 @@ const isRuntimeRoute = (value: Route): value is RuntimeRoute => value.kind.start const isRecord = (value: unknown): value is JsonObject => typeof value === 'object' && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; -const hasOnly = (value: JsonObject, fields: readonly string[]): boolean => - Object.keys(value).every((field) => fields.includes(field)); +const hasOnly: (value: JsonObject, fields: readonly string[]) => boolean = hasOnlyOwnKeys; const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0 && value.length <= 4_096 && !value.includes('\0'); diff --git a/packages/agent-bundle/src/dev/package-build-service.ts b/packages/agent-bundle/src/dev/package-build-service.ts index d8e6f0fd4..b67466cc5 100644 --- a/packages/agent-bundle/src/dev/package-build-service.ts +++ b/packages/agent-bundle/src/dev/package-build-service.ts @@ -1,5 +1,7 @@ import { rm, rmdir } from 'node:fs/promises'; -import { dirname, join, relative } from 'node:path'; +import { dirname, join } from 'node:path'; + +import { toPosixRelative } from '../core/paths.ts'; import { buildPackageOutputs } from '../build/package-build.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; @@ -79,8 +81,7 @@ const toolsIdentity = (value: unknown): unknown => { return value; }; -const relativePosix = (root: string, path: string): string => - relative(root, path).replaceAll('\\', '/'); +const relativePosix = toPosixRelative; export class DevPackageBuildService implements DevPackageBuilder { readonly #buildOutputs: typeof buildPackageOutputs; diff --git a/packages/agent-bundle/src/dev/playground/native-playground-service.ts b/packages/agent-bundle/src/dev/playground/native-playground-service.ts index 0f9fe4508..3ff4423dc 100644 --- a/packages/agent-bundle/src/dev/playground/native-playground-service.ts +++ b/packages/agent-bundle/src/dev/playground/native-playground-service.ts @@ -3,7 +3,7 @@ import { link, lstat, mkdir, mkdtemp, open, realpath, rename, rm } from 'node:fs import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import { digest, stableJson } from '../../core/digest.ts'; -import { isJsonRecord, parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../../core/strict-json.ts'; +import { hasExactOwnKeys, isJsonRecord, parseJsonWithoutDuplicateKeys, snapshotStrictJsonValue, type JsonValue } from '../../core/strict-json.ts'; import { loadConfig } from '../../config/load.ts'; import type { PreparedEvalArtifact } from '../../eval/artifact.ts'; import { runClaudeTrial } from '../../eval/claude-harness.ts'; @@ -469,11 +469,8 @@ const boundedJson = (value: JsonValue, depth = 0): boolean => { const nonemptySnapshotText = (value: JsonValue | undefined): value is string => typeof value === 'string' && value.length > 0 && value.length <= maximumSnapshotStringLength; -const exactKeys = (value: Readonly>, keys: readonly string[]): boolean => { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -}; +const exactKeys: (value: Readonly>, keys: readonly string[]) => boolean = + hasExactOwnKeys; const withinCatalogSnapshotNodeBudget = (root: unknown): boolean => { let remaining = maximumCatalogSnapshotNodes; diff --git a/packages/agent-bundle/src/dev/playground/playground-store.ts b/packages/agent-bundle/src/dev/playground/playground-store.ts index 5a91256a4..9c559c8c4 100644 --- a/packages/agent-bundle/src/dev/playground/playground-store.ts +++ b/packages/agent-bundle/src/dev/playground/playground-store.ts @@ -6,7 +6,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'nod import { serialQueue, type SerialQueue } from '../../core/async.ts'; import { isErrno } from '../../core/errors.ts'; import { isInsideOrEqual } from '../../core/paths.ts'; -import { isRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; +import { hasExactOwnKeys, isRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import type { DevLogSink } from '../logs/dev-log-service.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -353,12 +353,6 @@ const sensitiveKey = (key: string): boolean => { || /(?:apikey|apitoken|authtoken|accesstoken)$/u.test(compact); }; -const hasExactOwnKeys = (value: Record, keys: readonly string[]): boolean => { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -}; - const hasOptionalOwnKey = (value: Record, required: readonly string[], optional: string): boolean => hasExactOwnKeys(value, required) || hasExactOwnKeys(value, [...required, optional]); diff --git a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts index 98b56f9bd..dff8203dd 100644 --- a/packages/agent-bundle/src/dev/runtime-mcp-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-mcp-routes.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; -import { isPlainRecord } from '../core/strict-json.ts'; +import { hasOnlyOwnKeys, isPlainRecord } from '../core/strict-json.ts'; import type { DevRuntimeSession } from './runtime-provider.ts'; import type { @@ -38,7 +38,7 @@ const responseDiagnostic = (response: ServerResponse, value: RequestDiagnostic): const isRecord = isPlainRecord; const nonempty = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4_096 && !value.includes('\0'); const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; -const hasOnly = (value: Record, fields: readonly string[]): boolean => Object.keys(value).every((key) => fields.includes(key)); +const hasOnly: (value: Record, fields: readonly string[]) => boolean = hasOnlyOwnKeys; const readBody = async (request: IncomingMessage): Promise> => { const contentType = request.headers['content-type']; diff --git a/packages/agent-bundle/src/dev/runtime-routes.ts b/packages/agent-bundle/src/dev/runtime-routes.ts index 9e7a48fd9..380a40ee5 100644 --- a/packages/agent-bundle/src/dev/runtime-routes.ts +++ b/packages/agent-bundle/src/dev/runtime-routes.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { isPlainRecord } from '../core/strict-json.ts'; +import { hasOnlyOwnKeys, isPlainRecord } from '../core/strict-json.ts'; import { DevRuntimeGenerationConflictError, @@ -204,8 +204,7 @@ const route = (requestTarget: string | undefined): Route | undefined => { const isRecord = isPlainRecord; -const hasOnly = (value: Record, fields: readonly string[]): boolean => - Object.keys(value).every((field) => fields.includes(field)); +const hasOnly: (value: Record, fields: readonly string[]) => boolean = hasOnlyOwnKeys; const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4_096 && !value.includes('\0'); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index a34346bb1..0d9bf623d 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -9,6 +9,7 @@ import { type DiagnosticSeverity, } from '../core/diagnostics.ts'; import { isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; import { validateClaudePluginFiles } from '../host-contracts/claude-plugin-validation.ts'; import { validateCodexPluginFiles } from '../host-contracts/codex-plugin-validation.ts'; import { @@ -203,16 +204,6 @@ const marketplacePath = (host: Exclude): string => ? '.claude-plugin/marketplace.json' : '.agents/plugins/marketplace.json'; -const exists = async (path: string): Promise => { - try { - await lstat(path); - return true; - } catch (error) { - if (isErrno(error, 'ENOENT')) return false; - throw error; - } -}; - const readRecord = async (path: string, kind: string): Promise> => { let value: unknown; try { diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 5d7277ccf..34c540136 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -17,6 +17,7 @@ import { Effect, Predicate } from 'effect'; import { DiagnosticError } from '../core/diagnostics.ts'; import { errorMessage, isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; @@ -73,16 +74,6 @@ const failure = ( target, }]); -const exists = async (path: string): Promise => { - try { - await lstat(path); - return true; - } catch (error) { - if (isErrno(error, 'ENOENT')) return false; - throw error; - } -}; - const hostManifestPath = (host: InstallHost): string => { switch (host) { case 'claude': diff --git a/packages/agent-bundle/src/routes/config-extract.ts b/packages/agent-bundle/src/routes/config-extract.ts index ea5aeea54..0b48ae5f4 100644 --- a/packages/agent-bundle/src/routes/config-extract.ts +++ b/packages/agent-bundle/src/routes/config-extract.ts @@ -6,6 +6,7 @@ import ts from 'typescript-5'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { hasExportModifier, positionOf, unwrapExpression } from './input-schema.ts'; import { emptyRouteConfig } from './types.ts'; /** @@ -91,21 +92,6 @@ const describeExpression = (node: ts.Node): string => { return `a ${ts.SyntaxKind[node.kind] ?? 'dynamic'} expression`; }; -/** Casts, assertions, and parentheses carry no runtime value; unwrap them. */ -const unwrapExpression = (expression: ts.Expression): ts.Expression => { - let current = expression; - while ( - ts.isParenthesizedExpression(current) || - ts.isAsExpression(current) || - ts.isSatisfiesExpression(current) || - ts.isNonNullExpression(current) || - ts.isTypeAssertionExpression(current) - ) { - current = current.expression; - } - return current; -}; - const literalPropertyName = (name: ts.PropertyName): string | undefined => { if (ts.isIdentifier(name)) return name.text; if (ts.isStringLiteral(name) || ts.isNumericLiteral(name)) return name.text; @@ -180,10 +166,6 @@ const extractExpression = (expression: ts.Expression): Extraction => { return dynamic(describeExpression(node), node); }; -const hasExportModifier = (statement: ts.Statement): boolean => - ts.canHaveModifiers(statement) && - (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); - /** The named binding this pattern would introduce for `config`, if any. */ const bindsConfigName = (name: ts.BindingName): boolean => { if (ts.isIdentifier(name)) return name.text === 'config'; @@ -235,11 +217,6 @@ const scriptKindOf = (relativePath: string): ts.ScriptKind => { return ts.ScriptKind.TS; }; -const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { - const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); - return `${line + 1}:${character + 1}`; -}; - /** * Statically extracts the `export const config = ` declaration of * one route module. The module is parsed with the TypeScript compiler and diff --git a/packages/agent-bundle/src/routes/input-schema.ts b/packages/agent-bundle/src/routes/input-schema.ts index f6b16b6fe..dec130ad8 100644 --- a/packages/agent-bundle/src/routes/input-schema.ts +++ b/packages/agent-bundle/src/routes/input-schema.ts @@ -64,7 +64,7 @@ export const unwrapExpression = (expression: ts.Expression): ts.Expression => { return current; }; -const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { +export const positionOf = (sourceFile: ts.SourceFile, node: ts.Node): string => { const { character, line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); return `${line + 1}:${character + 1}`; }; @@ -300,7 +300,7 @@ const bindsInputSchemaName = (name: ts.BindingName): boolean => { !ts.isOmittedExpression(element) && bindsInputSchemaName(element.name)); }; -const hasExportModifier = (statement: ts.Statement): boolean => +export const hasExportModifier = (statement: ts.Statement): boolean => ts.canHaveModifiers(statement) && (ts.getModifiers(statement) ?? []).some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); diff --git a/packages/agent-bundle/src/services/mcp-run.ts b/packages/agent-bundle/src/services/mcp-run.ts index 899990257..aea0a87da 100644 --- a/packages/agent-bundle/src/services/mcp-run.ts +++ b/packages/agent-bundle/src/services/mcp-run.ts @@ -1,14 +1,14 @@ import { loadEnv } from '@rsbuild/core'; import { spawn, type ChildProcess } from 'node:child_process'; import { mkdir, readFile } from 'node:fs/promises'; -import { isAbsolute, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { parseEnv } from 'node:util'; import { createDefaultRegistry, type TargetRegistry } from '../adapters/registry.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; import { sha256Hex } from '../core/digest.ts'; -import { assertInside, joinArtifact } from '../core/paths.ts'; +import { joinArtifact, resolveContained } from '../core/paths.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { resolveMcpPathTokens } from './mcp-path-tokens.ts'; import { @@ -50,9 +50,6 @@ export interface ResolveMcpStdioLaunchOptions { readonly workspaceRoot: string; } -const resolveContained = (root: string, path: string): string => - isAbsolute(path) ? path : assertInside(root, resolve(root, path)); - const safeStateSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; /** diff --git a/packages/agent-bundle/src/services/mcp-service.ts b/packages/agent-bundle/src/services/mcp-service.ts index 39b6b4fe4..298a7aac2 100644 --- a/packages/agent-bundle/src/services/mcp-service.ts +++ b/packages/agent-bundle/src/services/mcp-service.ts @@ -10,13 +10,13 @@ import { import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { isAbsolute, resolve } from 'node:path'; +import { resolve } from 'node:path'; import type { Stream } from 'node:stream'; import { createDefaultRegistry, TargetRegistry } from '../adapters/registry.ts'; import { validateArtifact } from '../build/validate-artifact.ts'; import { DiagnosticError } from '../core/diagnostics.ts'; -import { joinArtifact, assertInside } from '../core/paths.ts'; +import { joinArtifact, resolveContained } from '../core/paths.ts'; import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; import { resolveMcpPathTokens } from './mcp-path-tokens.ts'; import { readTargetMcpServer, type ModernMcpServer, type TargetMcpRuntimeContract } from './mcp-runtime.ts'; @@ -107,9 +107,6 @@ interface StderrCapture { readonly waitForEnd: (timeoutMs: number) => Promise; } -const resolveContained = (root: string, path: string): string => - isAbsolute(path) ? path : assertInside(root, resolve(root, path)); - const captureStderr = (stream: Stream | null, close: () => Promise): StderrCapture => { if (stream === null) { return { diff --git a/packages/create-agent-bundle/src/scaffold.ts b/packages/create-agent-bundle/src/scaffold.ts index 601591c02..cd574d490 100644 --- a/packages/create-agent-bundle/src/scaffold.ts +++ b/packages/create-agent-bundle/src/scaffold.ts @@ -1,7 +1,7 @@ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { UsageError, type TargetName } from './options.ts'; +import { defaultTargets, UsageError, type TargetName } from './options.ts'; import { assertLocalFrameworkTarball, validatedRuntimeSpecForFramework } from './framework.ts'; /** @@ -24,7 +24,11 @@ const renamedEntries: Readonly> = { package_json: 'package.json', }; -const defaultTargetsLiteral = "targets: ['portable', 'codex', 'claude']"; +const renderTargets = (targets: readonly TargetName[]): string => + targets.map((target) => `'${target}'`).join(', '); + +/** Derived from the shared default list so a change there cannot silently break the drift check. */ +const defaultTargetsLiteral = `targets: [${renderTargets(defaultTargets)}]`; const installerTargetNames: readonly TargetName[] = ['claude', 'codex', 'cursor', 'plugin']; export interface ScaffoldRequest { @@ -92,7 +96,7 @@ const rewriteConfigTargets = (contents: string, targets: readonly TargetName[]): if (!contents.includes(defaultTargetsLiteral)) { throw new Error(`Template drift: agent-bundle.config.ts no longer contains \`${defaultTargetsLiteral}\`.`); } - return contents.replace(defaultTargetsLiteral, `targets: [${targets.map((target) => `'${target}'`).join(', ')}]`); + return contents.replace(defaultTargetsLiteral, `targets: [${renderTargets(targets)}]`); }; /** diff --git a/packages/rsc-runtime/src/agent-document.ts b/packages/rsc-runtime/src/agent-document.ts index 3284e3b07..491c03c67 100644 --- a/packages/rsc-runtime/src/agent-document.ts +++ b/packages/rsc-runtime/src/agent-document.ts @@ -4,6 +4,10 @@ import { snapshotJsonValue, type JsonSnapshotBudget, type JsonValue } from './lo export const AGENT_DOCUMENT_VERSION = 1 as const; +/** Host-visible cancellation error shared by the render pipeline. */ +export const agentRenderAbortError = (): DOMException => + new DOMException('Agent render was aborted', 'AbortError'); + export type AgentDocumentStatus = 'success' | 'represented-error' | 'failed'; export interface AgentResultNode { diff --git a/packages/rsc-runtime/src/agent-request.ts b/packages/rsc-runtime/src/agent-request.ts index 8a5cef38b..54f574d4c 100644 --- a/packages/rsc-runtime/src/agent-request.ts +++ b/packages/rsc-runtime/src/agent-request.ts @@ -262,10 +262,14 @@ interface FrozenValues { readonly workspace: Observed; } -interface Lease { - closed: boolean; - handle: AgentRequestContext; - readonly values: FrozenValues; +/** A class so the lease/handle reference cycle closes in the constructor without casts. */ +class Lease { + closed = false; + readonly handle: AgentRequestContext; + + constructor(readonly values: FrozenValues) { + this.handle = createHandle(this); + } } interface RealmStore { @@ -392,12 +396,7 @@ export const runAgentRequest = async ( state: init.state, workspace, }); - const lease: Lease = { - closed: false, - handle: undefined as unknown as AgentRequestContext, - values, - }; - lease.handle = createHandle(lease); + const lease = new Lease(values); try { return await getStore().storage.run(lease, operation); diff --git a/packages/rsc-runtime/src/dispatcher.ts b/packages/rsc-runtime/src/dispatcher.ts index 09d53f8e9..6400e3e67 100644 --- a/packages/rsc-runtime/src/dispatcher.ts +++ b/packages/rsc-runtime/src/dispatcher.ts @@ -1,5 +1,6 @@ import { AgentContractError, + agentRenderAbortError, type AgentDocument, type AgentRenderEvent, type AgentRenderLimits, @@ -30,7 +31,7 @@ export interface AgentRenderDispatcherOptions { readonly limits?: Partial; } -const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError'); +const abortError = agentRenderAbortError; const abortedStream = (): ReadableStream => new ReadableStream({ diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts index 1e948400a..8dc2a205d 100644 --- a/packages/rsc-runtime/src/lower-mcp.ts +++ b/packages/rsc-runtime/src/lower-mcp.ts @@ -3,24 +3,19 @@ import { Buffer } from 'node:buffer'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { Children, isValidElement, type ReactElement, type ReactNode } from 'react'; -import { Mcp } from './elements.js'; - type McpElement = { type: string; props: Record; }; -const mcpComponents = new Set(Object.values(Mcp)); - -const isMcpComponent = (value: unknown): value is ((props: Record) => ReactElement) => - mcpComponents.has(value); - +// Every Mcp.* export is itself a function component, so a single function +// check unwraps both framework and user server components. const isServerComponent = (value: unknown): value is ((props: Record) => ReactNode) => typeof value === 'function'; const asMcpElement = (node: ReactNode): McpElement => { let element = node; - while (isValidElement(element) && (isMcpComponent(element.type) || isServerComponent(element.type))) { + while (isValidElement(element) && isServerComponent(element.type)) { element = element.type(element.props as Record); } diff --git a/packages/rsc-runtime/src/reconciler.ts b/packages/rsc-runtime/src/reconciler.ts index 534218b29..92a9445b4 100644 --- a/packages/rsc-runtime/src/reconciler.ts +++ b/packages/rsc-runtime/src/reconciler.ts @@ -5,6 +5,7 @@ import { createFromReadableStream } from 'react-server-dom-rspack/client.node'; import { AgentContractError, createAgentRenderEventSequence, + agentRenderAbortError, elapsedTimeExceeded, resolveAgentRenderLimits, type AgentRenderError, @@ -67,7 +68,7 @@ type ClassifiedNode = | { readonly kind: 'fragment'; readonly value: ReactElement } | { readonly kind: 'protocol'; readonly value: ReactElement }; -const abortError = (): DOMException => new DOMException('Agent render was aborted', 'AbortError'); +const abortError = agentRenderAbortError; const isObject = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -219,7 +220,6 @@ const materializeNode = (node: ReactNode, path: string, ctx: MaterializeContext) const status = thenableStatus(childKind.value._payload); switch (status) { case 'pending': - return materializePending(childKind.value._payload, path, fallback, ctx); case 'rejected': return materializePending(childKind.value._payload, path, fallback, ctx); case 'fulfilled': @@ -279,9 +279,15 @@ const snapshotTree = (root: ReactNode, ids: Map): TreeSnapshot = const hostError = (signal: AbortSignal, error: unknown): Error => signal.aborted || isAbortError(error) ? abortError() : toRuntimeError(error); +type SettledBoundary = { + readonly boundary: PendingBoundary; + readonly error?: unknown; + readonly ok: boolean; +}; + const waitSettledBoundary = ( pending: readonly PendingBoundary[], -): Effect.Effect<{ readonly boundary: PendingBoundary; readonly error?: unknown; readonly ok: boolean }, Error> => +): Effect.Effect => Effect.raceAll( pending.map((boundary) => Effect.tryPromise({ @@ -294,12 +300,6 @@ const waitSettledBoundary = ( ), ); -type SettledBoundary = { - readonly boundary: PendingBoundary; - readonly error?: unknown; - readonly ok: boolean; -}; - const waitOrDeadline = ( wait: Effect.Effect, sequence: AgentRenderEventSequence, @@ -314,12 +314,6 @@ const waitOrDeadline = ( ); }; -const waitPendingOrDeadline = ( - pending: readonly PendingBoundary[], - sequence: AgentRenderEventSequence, -): Effect.Effect => - waitOrDeadline(waitSettledBoundary(pending), sequence); - const settledBoundaryInputs = ( previous: TreeSnapshot, next: TreeSnapshot, @@ -389,7 +383,7 @@ const reconcileLoopStream = ( ); } return Effect.raceFirst( - waitPendingOrDeadline(snapshot.pending, sequence).pipe( + waitOrDeadline(waitSettledBoundary(snapshot.pending), sequence).pipe( Effect.map((winner): LoopWait => ({ kind: 'boundary', winner })), ), Queue.take(progressInputs).pipe( diff --git a/packages/rsc-runtime/src/state/contract.ts b/packages/rsc-runtime/src/state/contract.ts index 9fc8c35d1..5603c969c 100644 --- a/packages/rsc-runtime/src/state/contract.ts +++ b/packages/rsc-runtime/src/state/contract.ts @@ -464,3 +464,18 @@ export const expectCanonicalPayload = (value: unknown, label: string): string => } return canonicalJson(value); }; + +export const expectRevisionShape = (revision: number | undefined, label: string): void => { + if (revision !== undefined && (!Number.isInteger(revision) || revision < 0)) { + throw new AgentStateError('invalid-input', `${label} must be an integer >= 0`); + } +}; + +export const expectOperable = (closed: boolean, definitionId: string, signal: AbortSignal | undefined): void => { + if (closed) { + throw new AgentStateError('store-closed', `State '${definitionId}' store is closed`); + } + if (signal?.aborted === true) { + throw new AgentStateError('aborted', `State '${definitionId}' operation was aborted`, { cause: signal.reason }); + } +}; diff --git a/packages/rsc-runtime/src/state/memory-driver.ts b/packages/rsc-runtime/src/state/memory-driver.ts index 699795dcf..047f271e7 100644 --- a/packages/rsc-runtime/src/state/memory-driver.ts +++ b/packages/rsc-runtime/src/state/memory-driver.ts @@ -18,7 +18,7 @@ import type { AgentStateSnapshot, AgentStateStore, } from './contract.js'; -import { AgentStateError, expectIdempotencyKey } from './contract.js'; +import { AgentStateError, expectIdempotencyKey, expectOperable, expectRevisionShape } from './contract.js'; import type { AgentStateJournalRecord } from './journal.js'; import { canonicalCommitInput, @@ -102,21 +102,6 @@ interface MemoryStoreEntry { readonly store: AgentStateStore; } -const expectRevisionShape = (revision: number | undefined, label: string): void => { - if (revision !== undefined && (!Number.isInteger(revision) || revision < 0)) { - throw new AgentStateError('invalid-input', `${label} must be an integer >= 0`); - } -}; - -const expectOperable = (closed: boolean, definitionId: string, signal: AbortSignal | undefined): void => { - if (closed) { - throw new AgentStateError('store-closed', `State '${definitionId}' store is closed`); - } - if (signal?.aborted === true) { - throw new AgentStateError('aborted', `State '${definitionId}' operation was aborted`, { cause: signal.reason }); - } -}; - const createMemoryStore = ( definition: AgentStateDefinition, lifetime: MemoryLifetime, diff --git a/packages/rsc-runtime/src/state/sqlite.ts b/packages/rsc-runtime/src/state/sqlite.ts index f053188ac..de73c619d 100644 --- a/packages/rsc-runtime/src/state/sqlite.ts +++ b/packages/rsc-runtime/src/state/sqlite.ts @@ -56,6 +56,7 @@ import { resolveResetState, runStateMigrations, } from './index.js'; +import { expectOperable, expectRevisionShape } from './contract.js'; import { createPendingOpenTracker } from './pending-opens.js'; /** @@ -170,21 +171,6 @@ const sqliteEffect = ( }), ); -const expectRevisionShape = (revision: number | undefined, label: string): void => { - if (revision !== undefined && (!Number.isInteger(revision) || revision < 0)) { - throw new AgentStateError('invalid-input', `${label} must be an integer >= 0`); - } -}; - -const expectOperable = (closed: boolean, definitionId: string, signal: AbortSignal | undefined): void => { - if (closed) { - throw new AgentStateError('store-closed', `State '${definitionId}' store is closed`); - } - if (signal?.aborted === true) { - throw new AgentStateError('aborted', `State '${definitionId}' operation was aborted`, { cause: signal.reason }); - } -}; - const parseStoredJson = (definitionId: string, column: string, revision: number, text: string): unknown => { try { return JSON.parse(text); @@ -446,16 +432,7 @@ class SqliteStore implements Age options: AgentStateDispatchOptions | AgentStateResetOptions, ): Effect.Effect, AgentStateError, SqliteConnection> { const definition = this.#definition; - const appendRecord = (db: DatabaseSync, record: AgentStateJournalRecord, state: TState, stateText: string) => - this.#appendRecord(db, record, state, stateText); - const committedByKey = (db: DatabaseSync, key: string) => this.#committedByKey(db, key); - const committedState = ( - db: DatabaseSync, - committed: { readonly record: AgentStateJournalRecord; readonly resultStateText: string | null }, - ) => this.#committedState(db, committed); - const headState = (db: DatabaseSync) => this.#headState(db, 'commit'); const now = this.#now; - const transaction = this.#transaction.bind(this); // Validation and canonicalization happen before the reducer and before // any storage access: a committed key must replay its stored result even // when the reducer would fail against the current head. @@ -493,10 +470,10 @@ class SqliteStore implements Age return { key, prepared: { canonicalInput, kind: 'reset', state }, startedAtMs }; }, ); - return Effect.gen(function*() { + return Effect.gen({ self: this }, function* (this: SqliteStore) { const { key, prepared, startedAtMs } = yield* validate; - return yield* transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', (db) => { - const committed = committedByKey(db, key); + return yield* this.#transaction('write', input.kind === 'event' ? `dispatch '${input.name}'` : 'reset', (db) => { + const committed = this.#committedByKey(db, key); if (committed !== undefined) { if (canonicalCommitInput(committed.record) !== prepared.canonicalInput) { throw new AgentStateError( @@ -507,10 +484,10 @@ class SqliteStore implements Age return Object.freeze({ replayed: true, revision: committed.record.revision, - state: committedState(db, committed), + state: this.#committedState(db, committed), }); } - const head = headState(db); + const head = this.#headState(db, 'commit'); if (options.expectedRevision !== undefined && options.expectedRevision !== head.revision) { throw new AgentStateError( 'revision-conflict', @@ -529,7 +506,7 @@ class SqliteStore implements Age startedAtMs, stateText, }); - return appendRecord( + return this.#appendRecord( db, { committedAt: committedAt.toISOString(), @@ -553,7 +530,7 @@ class SqliteStore implements Age startedAtMs, stateText, }); - return appendRecord( + return this.#appendRecord( db, { committedAt: committedAt.toISOString(), diff --git a/packages/workbench/src/artifacts/artifact-client.ts b/packages/workbench/src/artifacts/artifact-client.ts index 534f83330..40f5c767c 100644 --- a/packages/workbench/src/artifacts/artifact-client.ts +++ b/packages/workbench/src/artifacts/artifact-client.ts @@ -2,7 +2,7 @@ import type { Diagnostic } from '../../../agent-bundle/src/contracts/diagnostics import { snapshotStrictJsonValue } from '../../../agent-bundle/src/contracts/strict-json.ts'; import type { ArtifactEpochDiff, ArtifactInspection } from '../../../agent-bundle/src/contracts/artifacts.ts'; import type { ForegroundRequestAuthority } from '../mcp/mcp-route-client.ts'; -import { isRecord } from '../client-helpers.ts'; +import { hasAllowedKeys, isRecord } from '../client-helpers.ts'; export interface ArtifactClientOptions { readonly foreground: ForegroundRequestAuthority; @@ -36,9 +36,7 @@ const detachedRecord = (value: unknown): Readonly> => { } }; -const exactRecord = (value: unknown, required: readonly string[], optional: readonly string[] = []): value is Readonly> => - isRecord(value) && required.every((key) => Object.hasOwn(value, key)) && - Object.keys(value).every((key) => required.includes(key) || optional.includes(key)); +const exactRecord = hasAllowedKeys; const arrayOf = (value: unknown, predicate: (entry: unknown) => boolean): boolean => Array.isArray(value) && value.every(predicate); diff --git a/packages/workbench/src/mcp/mcp-app-client.ts b/packages/workbench/src/mcp/mcp-app-client.ts index 2c823b7dd..aefa49be1 100644 --- a/packages/workbench/src/mcp/mcp-app-client.ts +++ b/packages/workbench/src/mcp/mcp-app-client.ts @@ -20,7 +20,8 @@ import type { } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; import type { McpAppConsentRequest, McpAppDocumentPolicySnapshot } from '../../../agent-bundle/src/contracts/mcp-apps.ts'; -import { isRecord } from '../client-helpers.ts'; +import { exactKeys, isRecord } from '../client-helpers.ts'; +import { hasOnlyOwnKeys } from '../strict-json.ts'; import { finiteOrdinaryJsonByteLength } from './finite-json.ts'; import { ForegroundRouteClient, ForegroundRouteClientError, sameRuntimeBinding, type McpRuntimeBindingIdentity } from './mcp-route-client.ts'; @@ -262,13 +263,9 @@ const runtimeInputInvalid = (message = 'Runtime MCP App request is not valid.'): throw new McpAppClientError('AB8016', message); }; -const hasExactKeys = (value: Readonly>, keys: readonly string[]): boolean => { - const actual = Object.keys(value); - return actual.length === keys.length && actual.every((key) => keys.includes(key)); -}; +const hasExactKeys: (value: Readonly>, keys: readonly string[]) => boolean = exactKeys; -const hasOnlyKeys = (value: Readonly>, keys: readonly string[]): boolean => - Object.keys(value).every((key) => keys.includes(key)); +const hasOnlyKeys: (value: Readonly>, keys: readonly string[]) => boolean = hasOnlyOwnKeys; const runtimeRecord = (value: unknown, keys: readonly string[], message?: string): Readonly> => { const record = asRecord(value); diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index 9a3082134..c505bfc5f 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -9,7 +9,8 @@ import type { } from '../../../agent-bundle/src/contracts/runtime.ts'; import type { JsonObject } from '../../../agent-bundle/src/contracts/runtime.ts'; import { isMcpSessionTarget, type McpSessionTarget } from '../../../agent-bundle/src/contracts/mcp-session.ts'; -import { isRecord } from '../client-helpers.ts'; +import { exactKeys, isRecord } from '../client-helpers.ts'; +import { hasOnlyOwnKeys } from '../strict-json.ts'; export type McpRouteTarget = McpSessionTarget; @@ -167,9 +168,9 @@ const asArray = (value: unknown): readonly unknown[] => { return detachedJson(value) as readonly unknown[]; }; -const hasOnlyKeys = (value: Readonly>, keys: readonly string[]): boolean => Object.keys(value).every((key) => keys.includes(key)); +const hasOnlyKeys: (value: Readonly>, keys: readonly string[]) => boolean = hasOnlyOwnKeys; -const hasExactKeys = (value: Readonly>, keys: readonly string[]): boolean => Object.keys(value).length === keys.length && hasOnlyKeys(value, keys); +const hasExactKeys: (value: Readonly>, keys: readonly string[]) => boolean = exactKeys; const diagnostic = (value: unknown, status: number): Diagnostic => { if (isRecord(value) && isRecord(value.diagnostic) && typeof value.diagnostic.code === 'string' && typeof value.diagnostic.message === 'string') { diff --git a/packages/workbench/src/playground/playground-client.ts b/packages/workbench/src/playground/playground-client.ts index 0b7872261..b829cff81 100644 --- a/packages/workbench/src/playground/playground-client.ts +++ b/packages/workbench/src/playground/playground-client.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { NATIVE_HOSTS } from '../../../agent-bundle/src/contracts/playground.ts'; -import { isRecord } from '../client-helpers.ts'; +import { exactKeys, isRecord } from '../client-helpers.ts'; import type { DraftEvalCase, PlaygroundExport, @@ -109,11 +109,6 @@ const detachedJson = ( } }; -const exactKeys = (value: Readonly>, keys: readonly string[]): boolean => { - const actual = Object.keys(value).sort(); - const expected = [...keys].sort(); - return actual.length === expected.length && actual.every((key, index) => key === expected[index]); -}; const optionalKeys = (value: Readonly>, required: readonly string[], optional: readonly string[]): boolean => optional.some((key) => exactKeys(value, [...required, key])) || exactKeys(value, required) || diff --git a/packages/workbench/src/project-client.ts b/packages/workbench/src/project-client.ts index 230f5f1d9..4193a61b2 100644 --- a/packages/workbench/src/project-client.ts +++ b/packages/workbench/src/project-client.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import type { Diagnostic } from '../../agent-bundle/src/contracts/diagnostics.ts'; -import { isRecord } from './client-helpers.ts'; +import { exactKeys, isRecord } from './client-helpers.ts'; import { freezeJsonValue, type ArtifactEpoch, @@ -90,8 +90,7 @@ const browserEvents: EventSourceFactory = (url) => new EventSource(url); const retryDelay = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); const retryDelayMilliseconds = 250; -const hasExactKeys = (value: Readonly>, keys: readonly string[]): boolean => - Object.keys(value).length === keys.length && Object.keys(value).every((key) => keys.includes(key)); +const hasExactKeys: (value: Readonly>, keys: readonly string[]) => boolean = exactKeys; const diagnosticSchema: z.ZodType = z.strictObject({ code: z.string(), diff --git a/packages/workbench/src/runtime-client.ts b/packages/workbench/src/runtime-client.ts index f9f1a2f2b..5530894bb 100644 --- a/packages/workbench/src/runtime-client.ts +++ b/packages/workbench/src/runtime-client.ts @@ -19,7 +19,7 @@ import type { RuntimeVector, } from '../../agent-bundle/src/contracts/runtime.ts'; import { ForegroundRouteClient, ForegroundRouteClientError } from './mcp/mcp-route-client.ts'; -import { isPlainRecord } from './strict-json.ts'; +import { hasOnlyOwnKeys, isPlainRecord } from './strict-json.ts'; import { AgentDocumentClient, AgentDocumentClientError, @@ -57,8 +57,7 @@ const diagnosticPhases = new Set([ const isRecord = isPlainRecord; -const hasOnly = (value: Readonly>, fields: readonly string[]): boolean => - Object.keys(value).every((field) => fields.includes(field)); +const hasOnly: (value: Readonly>, fields: readonly string[]) => boolean = hasOnlyOwnKeys; const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 4_096 && !value.includes('\0'); diff --git a/packages/workbench/src/strict-json.ts b/packages/workbench/src/strict-json.ts index 9c08e1d9d..80df1ca93 100644 --- a/packages/workbench/src/strict-json.ts +++ b/packages/workbench/src/strict-json.ts @@ -1,4 +1,5 @@ export { + hasOnlyOwnKeys, isPlainRecord, mapStrictJsonReason, snapshotStrictJsonValue, diff --git a/scripts/audit-packed-release.mjs b/scripts/audit-packed-release.mjs index f70731255..9397bced5 100644 --- a/scripts/audit-packed-release.mjs +++ b/scripts/audit-packed-release.mjs @@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { npmCliInvocation } from './npm-cli.mjs'; +import { packOutputFromJson as sharedPackOutputFromJson } from './npm-pack-json.mjs'; const execFile = promisify(executeFile); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -29,21 +30,11 @@ const asString = (value, message) => { }; const packOutputFromJson = (stdout) => { - const parsed = JSON.parse(stdout); - const entries = Array.isArray(parsed) - ? parsed - : parsed !== null && typeof parsed === 'object' - ? Object.values(parsed) - : undefined; - if (entries === undefined) fail('npm pack --json returned neither an array nor a package-keyed object'); - if (entries.length !== 1) { - fail(`npm pack --json returned ${String(entries.length)} entries; expected exactly one`); - } - const [entry] = entries; - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { - fail('npm pack --json returned an invalid pack entry; expected one object'); + try { + return sharedPackOutputFromJson(stdout); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); } - return entry; }; /** Every name -> Set(version) reachable under the consumer's own node_modules tree. */ diff --git a/scripts/npm-pack-json.mjs b/scripts/npm-pack-json.mjs new file mode 100644 index 000000000..bc6b5f145 --- /dev/null +++ b/scripts/npm-pack-json.mjs @@ -0,0 +1,24 @@ +/** + * Parses `npm pack --json` output into its single pack entry. npm emits + * either an array or a package-keyed object depending on version; both are + * accepted, anything else throws. + */ +export const packOutputFromJson = (stdout) => { + const parsed = JSON.parse(stdout); + const entries = Array.isArray(parsed) + ? parsed + : parsed !== null && typeof parsed === 'object' + ? Object.values(parsed) + : undefined; + if (entries === undefined) { + throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.'); + } + if (entries.length !== 1) { + throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`); + } + const [entry] = entries; + if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { + throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.'); + } + return entry; +}; diff --git a/scripts/run-packed-tests.mjs b/scripts/run-packed-tests.mjs index daf04c5ba..7b47acfbc 100644 --- a/scripts/run-packed-tests.mjs +++ b/scripts/run-packed-tests.mjs @@ -15,32 +15,14 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; +import { packOutputFromJson } from './npm-pack-json.mjs'; + const execFile = promisify(executeFile); const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const { NODE_PATH: _nodePath, ...environment } = process.env; const releasePool = process.argv.includes('--release'); const rstestArguments = process.argv.slice(2).filter((argument) => argument !== '--release'); -const packOutputFromJson = (stdout) => { - const parsed = JSON.parse(stdout); - const entries = Array.isArray(parsed) - ? parsed - : parsed !== null && typeof parsed === 'object' - ? Object.values(parsed) - : undefined; - if (entries === undefined) { - throw new TypeError('npm pack --json returned neither an array nor a package-keyed object.'); - } - if (entries.length !== 1) { - throw new TypeError(`npm pack --json returned ${String(entries.length)} entries; expected exactly one.`); - } - const [entry] = entries; - if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) { - throw new TypeError('npm pack --json returned an invalid pack entry; expected one object.'); - } - return entry; -}; - const run = (command, args, extraEnvironment = {}) => new Promise((resolvePromise, rejectPromise) => { const child = spawn(command, args, { cwd: repositoryRoot, From a44b5bfc67a79fc9d6f2fcc578e8de5fb9603fa8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 16:05:24 +0000 Subject: [PATCH 3/6] refactor: round-3 dedup and hot-path fixes from cross-model review Document depth/node-count limits get one source shared by snapshotting, JSON budgets, and Flight decode; Ajv issue plumbing collapses into schemas/ajv-issues.ts; mount/index.ts replaces callback-threaded module state with a slot factory; and exists/resolveContained/expectRevision validators join their canonicals. Route-graph compile now reads each module once, Workbench trace lists cache pretty-printed JSON in WeakMaps, and the rebuild directory walks are bounded to eight concurrent readdirs after review flagged EMFILE risk in the unbounded fan-out. --- .../agent-bundle/src/config/rendered-skill.ts | 7 +- .../agent-bundle/src/dev/project-service.ts | 77 +++++++------ packages/agent-bundle/src/routes/graph.ts | 76 ++++++------- .../src/schemas/agent-skills/contract.ts | 43 +------- .../agent-bundle/src/schemas/ajv-issues.ts | 37 +++++++ .../src/schemas/skill-hosts/contract.ts | 37 +------ packages/rsc-runtime/src/agent-document.ts | 51 +++++---- packages/rsc-runtime/src/application.ts | 8 +- packages/rsc-runtime/src/decode-document.ts | 21 +--- packages/rsc-runtime/src/lower-mcp.ts | 7 +- packages/rsc-runtime/src/mount/index.ts | 102 ++++++++---------- packages/workbench/src/mcp/mcp-page.tsx | 11 ++ .../src/playground/playground-page.tsx | 79 ++++++++------ scripts/eslint-plugin-effect-boundary.ts | 12 +-- 14 files changed, 267 insertions(+), 301 deletions(-) create mode 100644 packages/agent-bundle/src/schemas/ajv-issues.ts diff --git a/packages/agent-bundle/src/config/rendered-skill.ts b/packages/agent-bundle/src/config/rendered-skill.ts index 5d8f5dc08..0e7059762 100644 --- a/packages/agent-bundle/src/config/rendered-skill.ts +++ b/packages/agent-bundle/src/config/rendered-skill.ts @@ -6,6 +6,7 @@ import { stringify as stringifyYaml } from 'yaml'; import type { Diagnostic } from '../core/diagnostics.ts'; import { errorMessage } from '../core/errors.ts'; +import { isPlainRecord } from '../core/strict-json.ts'; import { MarkdownRenderError, renderElementToMarkdown } from './render-markdown.ts'; /** @@ -51,12 +52,6 @@ const failure = (code: string, message: string, sourcePath: string): RenderedSki status: 'failed', }); -const isPlainRecord = (value: unknown): value is Record => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - /** * Loads and compiles one rendered skill source to its Markdown document. The * module executes through the same jiti pipeline that already runs consumer diff --git a/packages/agent-bundle/src/dev/project-service.ts b/packages/agent-bundle/src/dev/project-service.ts index 1d804ef90..4bb3d6d5f 100644 --- a/packages/agent-bundle/src/dev/project-service.ts +++ b/packages/agent-bundle/src/dev/project-service.ts @@ -13,6 +13,7 @@ import { normalizeProject, } from '../config/normalize.ts'; import { validateModel, validateSource } from '../config/validate.ts'; +import { mapConcurrent } from '../core/async.ts'; import { deduplicateDiagnostics, type Diagnostic, withDiagnosticRecovery } from '../core/diagnostics.ts'; import { digest } from '../core/digest.ts'; import { @@ -235,25 +236,29 @@ const resolveOutputRoots = async ( return Object.freeze([...new Set(roots)].sort((left, right) => left.localeCompare(right))); }; +/** Bounds concurrent readdir calls so wide trees cannot exhaust file descriptors. */ +const walkConcurrency = 8; + const sourcePaths = async (root: string, outputRoots: readonly string[]): Promise => { const rules = await readProjectIgnoreRules(root); const paths: string[] = []; - - const visit = async (directory: string): Promise => { - const entries = await readdir(directory, { withFileTypes: true }); - const subdirectories: string[] = []; - for (const entry of entries) { - const source = join(directory, entry.name); - if (isProjectPathIgnored(rules, root, source)) continue; - if (outputRoots.some((outputRoot) => containedPathComponents(outputRoot, source) !== undefined)) continue; - if (entry.isDirectory()) subdirectories.push(source); - else if (entry.isFile()) paths.push(source); - } - // Sibling directories descend concurrently; the final sort restores determinism. - await Promise.all(subdirectories.map(visit)); - }; - - await visit(root); + // Level-by-level walk: each level reads at most walkConcurrency directories + // at once, and the final sort restores deterministic output order. + let frontier: string[] = [root]; + while (frontier.length > 0) { + const next: string[] = []; + await mapConcurrent(frontier, walkConcurrency, async (directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries) { + const source = join(directory, entry.name); + if (isProjectPathIgnored(rules, root, source)) continue; + if (outputRoots.some((outputRoot) => containedPathComponents(outputRoot, source) !== undefined)) continue; + if (entry.isDirectory()) next.push(source); + else if (entry.isFile()) paths.push(source); + } + }); + frontier = next; + } return Object.freeze(paths.sort((left, right) => left.localeCompare(right))); }; @@ -262,22 +267,7 @@ const payloadSourcePaths = async ( payloadRoots: readonly string[], ): Promise => { const paths: string[] = []; - const visit = async (directory: string): Promise => { - let entries; - try { - entries = await readdir(directory, { withFileTypes: true }); - } catch { - // A payload that does not exist yet contributes no source inputs. - return; - } - const subdirectories: string[] = []; - for (const entry of entries) { - const source = join(directory, entry.name); - if (entry.isDirectory()) subdirectories.push(source); - else if (entry.isFile()) paths.push(source); - } - await Promise.all(subdirectories.map(visit)); - }; + const walkRoots: string[] = []; for (const payloadRoot of payloadRoots) { const requested = resolve(root, payloadRoot); if (containedPathComponents(root, requested) === undefined) continue; @@ -288,8 +278,29 @@ const payloadSourcePaths = async ( // A payload that does not exist yet contributes no source inputs. continue; } + // Re-check containment after symlink resolution so a payload root + // symlinked outside the project never contributes source inputs. if (containedPathComponents(root, resolved) === undefined) continue; - await visit(resolved); + walkRoots.push(resolved); + } + let frontier = walkRoots; + while (frontier.length > 0) { + const next: string[] = []; + await mapConcurrent(frontier, walkConcurrency, async (directory) => { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + // A payload that does not exist yet contributes no source inputs. + return; + } + for (const entry of entries) { + const source = join(directory, entry.name); + if (entry.isDirectory()) next.push(source); + else if (entry.isFile()) paths.push(source); + } + }); + frontier = next; } return Object.freeze(paths.sort((left, right) => left.localeCompare(right))); }; diff --git a/packages/agent-bundle/src/routes/graph.ts b/packages/agent-bundle/src/routes/graph.ts index bd2fe26be..e2b1f5fa4 100644 --- a/packages/agent-bundle/src/routes/graph.ts +++ b/packages/agent-bundle/src/routes/graph.ts @@ -364,23 +364,34 @@ const compiledRoute = ( }); /** - * Statically extracts one route module's `config` export from disk. A module - * a racing deletion removed simply has no config; extraction diagnostics - * (AB4805/AB4806) accumulate beside the discovery diagnostics. + * Reads one route module's source text. A racing deletion returns no text so + * extract/validate skip the same way a later snapshot would. + */ +const readRouteModuleText = async (source: string): Promise => { + try { + return await readFile(source, 'utf8'); + } catch { + return undefined; + } +}; + +/** + * Statically extracts one route module's `config` export from already-read + * source text. A module a racing deletion removed simply has no config; + * extraction diagnostics (AB4805/AB4806) accumulate beside the discovery + * diagnostics. */ interface ExtractedModuleMetadata { readonly config: Readonly>; readonly inputSchema?: RouteInputSchema; } -const extractedModuleMetadata = async ( +const extractedModuleMetadata = ( module: DiscoveredRouteModule, + moduleText: string | undefined, diagnostics: Diagnostic[], -): Promise => { - let moduleText: string; - try { - moduleText = await readFile(module.source, 'utf8'); - } catch { +): ExtractedModuleMetadata => { + if (moduleText === undefined) { return { config: emptyRouteConfig }; } const extracted = extractRouteConfig(moduleText, module.relativePath, module.source); @@ -523,6 +534,7 @@ export const compileRouteGraph = async ( const scripts: CompiledAgentRoute[] = []; const cliRoutes: CompiledAgentRoute[] = []; const providers: CompiledProvider[] = []; + const moduleTextBySource = new Map(); for (const module of modules) { if (module.surface === 'provider') { providers.push({ @@ -531,29 +543,28 @@ export const compileRouteGraph = async ( provenance: { kind: 'conventional', relativePath: module.relativePath }, source: module.source, }); - try { + const providerText = await readRouteModuleText(module.source); + if (providerText !== undefined) { diagnostics.push(...validateProviderModuleContract( - await readFile(module.source, 'utf8'), + providerText, module.relativePath, module.source, )); - } catch { - // Racing deletion is handled by the next source snapshot. } continue; } - const metadata = await extractedModuleMetadata(module, diagnostics); + const moduleText = await readRouteModuleText(module.source); + if (moduleText !== undefined) { + moduleTextBySource.set(module.source, moduleText); + } + const metadata = extractedModuleMetadata(module, moduleText, diagnostics); const route = compiledRoute(module, metadata.config, metadata.inputSchema); - if (route.kind === 'event-route') { - try { - diagnostics.push(...validateEventRouteModuleContract( - await readFile(route.source, 'utf8'), - route.provenance.relativePath, - route.source, - )); - } catch { - // Racing deletion is handled by the next source snapshot. - } + if (route.kind === 'event-route' && moduleText !== undefined) { + diagnostics.push(...validateEventRouteModuleContract( + moduleText, + route.provenance.relativePath, + route.source, + )); } switch (route.kind) { case 'tool': @@ -619,14 +630,13 @@ export const compileRouteGraph = async ( } continue; } - try { + const moduleText = moduleTextBySource.get(route.source); + if (moduleText !== undefined) { diagnostics.push(...validateRouteModuleContract( - await readFile(route.source, 'utf8'), + moduleText, route.provenance.relativePath, route.source, )); - } catch { - // Racing deletion is handled by the next source snapshot. } } } @@ -664,14 +674,8 @@ export const compileRouteGraph = async ( )); } if (mode === 'generated') { - const compiled = await compileCliCommands(cliRoutes, async (route) => { - try { - return await readFile(route.source, 'utf8'); - } catch { - // Racing deletion is handled by the next source snapshot. - return undefined; - } - }, projected); + const compiled = await compileCliCommands(cliRoutes, async (route) => + moduleTextBySource.get(route.source), projected); diagnostics.push(...compiled.diagnostics); cli = { commands: compiled.commands, diff --git a/packages/agent-bundle/src/schemas/agent-skills/contract.ts b/packages/agent-bundle/src/schemas/agent-skills/contract.ts index 9707f31cb..67ed006dd 100644 --- a/packages/agent-bundle/src/schemas/agent-skills/contract.ts +++ b/packages/agent-bundle/src/schemas/agent-skills/contract.ts @@ -1,14 +1,8 @@ -import { Ajv2020, type ErrorObject } from 'ajv/dist/2020.js'; - +import { createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; import provenance from './PROVENANCE.json' with { type: 'json' }; import schema from './frontmatter.schema.json' with { type: 'json' }; -export interface AgentSkillsFrontmatterIssue { - readonly field?: string; - readonly instancePath: string; - readonly keyword: string; - readonly message: string; -} +export type AgentSkillsFrontmatterIssue = SchemaIssue; interface AgentSkillsProvenance { readonly derivedSchema: { readonly sha256: string }; @@ -17,28 +11,7 @@ interface AgentSkillsProvenance { } const schemaProvenance = provenance as AgentSkillsProvenance; -const validator = new Ajv2020({ allErrors: true, strict: true }); -const validate = validator.compile(schema); - -const parameter = (error: ErrorObject, name: string): string | undefined => { - const value = (error.params as Record)[name]; - return typeof value === 'string' ? value : undefined; -}; - -const fieldFor = (error: ErrorObject): string | undefined => { - if (error.keyword === 'additionalProperties') { - return parameter(error, 'additionalProperty'); - } - if (error.keyword === 'required') { - return parameter(error, 'missingProperty'); - } - - const [field] = error.instancePath - .split('/') - .filter((segment) => segment.length > 0) - .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); - return field; -}; +const validate = createSchemaValidator().compile(schema); const compareIssues = ( left: AgentSkillsFrontmatterIssue, @@ -56,16 +29,6 @@ const compareIssues = ( return 0; }; -const toIssue = (error: ErrorObject): AgentSkillsFrontmatterIssue => { - const field = fieldFor(error); - return Object.freeze({ - ...(field === undefined ? {} : { field }), - instancePath: error.instancePath, - keyword: error.keyword, - message: error.message ?? 'schema validation failed', - }); -}; - export const agentSkillsSchemaRevision = Object.freeze({ schemaSha256: schemaProvenance.derivedSchema.sha256, sourceRevision: schemaProvenance.sourceRevision, diff --git a/packages/agent-bundle/src/schemas/ajv-issues.ts b/packages/agent-bundle/src/schemas/ajv-issues.ts new file mode 100644 index 000000000..9f8d5f4b1 --- /dev/null +++ b/packages/agent-bundle/src/schemas/ajv-issues.ts @@ -0,0 +1,37 @@ +import { Ajv2020, type ErrorObject } from 'ajv/dist/2020.js'; + +/** Issue shape shared by every Ajv-backed schema contract. */ +export interface SchemaIssue { + readonly field?: string; + readonly instancePath: string; + readonly keyword: string; + readonly message: string; +} + +/** Each contract keeps its own instance so schema `$id` registration cannot collide. */ +export const createSchemaValidator = (): Ajv2020 => new Ajv2020({ allErrors: true, strict: true }); + +const parameter = (error: ErrorObject, name: string): string | undefined => { + const value = (error.params as Record)[name]; + return typeof value === 'string' ? value : undefined; +}; + +const fieldFor = (error: ErrorObject): string | undefined => { + if (error.keyword === 'additionalProperties') return parameter(error, 'additionalProperty'); + if (error.keyword === 'required') return parameter(error, 'missingProperty'); + const [field] = error.instancePath + .split('/') + .filter((segment) => segment.length > 0) + .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); + return field; +}; + +export const toIssue = (error: ErrorObject): SchemaIssue => { + const field = fieldFor(error); + return Object.freeze({ + ...(field === undefined ? {} : { field }), + instancePath: error.instancePath, + keyword: error.keyword, + message: error.message ?? 'schema validation failed', + }); +}; diff --git a/packages/agent-bundle/src/schemas/skill-hosts/contract.ts b/packages/agent-bundle/src/schemas/skill-hosts/contract.ts index 05bf92998..45839ec88 100644 --- a/packages/agent-bundle/src/schemas/skill-hosts/contract.ts +++ b/packages/agent-bundle/src/schemas/skill-hosts/contract.ts @@ -1,17 +1,13 @@ -import { Ajv2020, type ErrorObject } from 'ajv/dist/2020.js'; +import type { ErrorObject } from 'ajv/dist/2020.js'; import { validateAgentSkillsFrontmatter } from '../agent-skills/contract.ts'; +import { createSchemaValidator, toIssue, type SchemaIssue } from '../ajv-issues.ts'; import claudeSchema from './claude-skill-frontmatter.schema.json' with { type: 'json' }; import codexSchema from './codex-openai-yaml.schema.json' with { type: 'json' }; import cursorSchema from './cursor-skill-frontmatter.schema.json' with { type: 'json' }; import provenance from './PROVENANCE.json' with { type: 'json' }; -export interface SkillHostDocumentIssue { - readonly field?: string; - readonly instancePath: string; - readonly keyword: string; - readonly message: string; -} +export type SkillHostDocumentIssue = SchemaIssue; interface SkillHostProvenance { readonly derivedSchemas: Readonly>; @@ -19,36 +15,11 @@ interface SkillHostProvenance { } const schemaProvenance = provenance as SkillHostProvenance; -const validator = new Ajv2020({ allErrors: true, strict: true }); +const validator = createSchemaValidator(); const validateClaude = validator.compile(claudeSchema); const validateCursor = validator.compile(cursorSchema); const validateCodex = validator.compile(codexSchema); -const parameter = (error: ErrorObject, name: string): string | undefined => { - const value = (error.params as Record)[name]; - return typeof value === 'string' ? value : undefined; -}; - -const fieldFor = (error: ErrorObject): string | undefined => { - if (error.keyword === 'additionalProperties') return parameter(error, 'additionalProperty'); - if (error.keyword === 'required') return parameter(error, 'missingProperty'); - const [field] = error.instancePath - .split('/') - .filter((segment) => segment.length > 0) - .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); - return field; -}; - -const toIssue = (error: ErrorObject): SkillHostDocumentIssue => { - const field = fieldFor(error); - return Object.freeze({ - ...(field === undefined ? {} : { field }), - instancePath: error.instancePath, - keyword: error.keyword, - message: error.message ?? 'schema validation failed', - }); -}; - const issuesFrom = ( valid: boolean, errors: readonly ErrorObject[] | null | undefined, diff --git a/packages/rsc-runtime/src/agent-document.ts b/packages/rsc-runtime/src/agent-document.ts index 491c03c67..f9654b69e 100644 --- a/packages/rsc-runtime/src/agent-document.ts +++ b/packages/rsc-runtime/src/agent-document.ts @@ -200,6 +200,27 @@ export const elapsedTimeExceeded = (maxElapsedMs: number): AgentContractError => `Agent render elapsed time exceeds ${String(maxElapsedMs)}ms`, ); +/** Single source for the depth-limit contract shared by snapshotting and Flight decode. */ +export const expectDocumentDepth = (depth: number, limits: AgentRenderLimits): void => { + if (depth > limits.maxDocumentDepth) { + throw new AgentContractError( + 'document-depth-exceeded', + `Agent Document depth exceeds ${String(limits.maxDocumentDepth)}`, + ); + } +}; + +/** Single source for the node-count contract shared by snapshotting and Flight decode. */ +export const admitDocumentNode = (state: { readonly limits: AgentRenderLimits; nodes: number }): void => { + state.nodes += 1; + if (state.nodes > state.limits.maxDocumentNodes) { + throw new AgentContractError( + 'document-node-count-exceeded', + `Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`, + ); + } +}; + const jsonBudget = (state: NodeSnapshotState): JsonSnapshotBudget => ({ addBytes(n) { state.bytes += n; @@ -211,21 +232,10 @@ const jsonBudget = (state: NodeSnapshotState): JsonSnapshotBudget => ({ } }, addNode() { - state.nodes += 1; - if (state.nodes > state.limits.maxDocumentNodes) { - throw new AgentContractError( - 'document-node-count-exceeded', - `Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`, - ); - } + admitDocumentNode(state); }, checkDepth(depth) { - if (depth > state.limits.maxDocumentDepth) { - throw new AgentContractError( - 'document-depth-exceeded', - `Agent Document depth exceeds ${String(state.limits.maxDocumentDepth)}`, - ); - } + expectDocumentDepth(depth, state.limits); }, }); @@ -264,19 +274,8 @@ const snapshotNode = (node: AgentDocumentNode, depth: number, state: NodeSnapsho if (state.ancestors.has(node)) { throw new AgentContractError('invalid-document', 'Agent Document node tree must not be cyclic'); } - if (depth > state.limits.maxDocumentDepth) { - throw new AgentContractError( - 'document-depth-exceeded', - `Agent Document depth exceeds ${String(state.limits.maxDocumentDepth)}`, - ); - } - state.nodes += 1; - if (state.nodes > state.limits.maxDocumentNodes) { - throw new AgentContractError( - 'document-node-count-exceeded', - `Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`, - ); - } + expectDocumentDepth(depth, state.limits); + admitDocumentNode(state); state.ancestors.add(node); try { diff --git a/packages/rsc-runtime/src/application.ts b/packages/rsc-runtime/src/application.ts index 0d29e0ead..a2e1edbce 100644 --- a/packages/rsc-runtime/src/application.ts +++ b/packages/rsc-runtime/src/application.ts @@ -13,12 +13,8 @@ export interface RscApplicationOptions { readonly version: string; } -export interface RscApplication { - readonly description?: string; - readonly name: string; - readonly operations: readonly Readonly[]; - readonly version: string; -} +/** Structurally identical to its options: validation freezes but never reshapes. */ +export type RscApplication = RscApplicationOptions; const canonicalName = /^[a-z][a-z0-9._-]{0,63}$/u; diff --git a/packages/rsc-runtime/src/decode-document.ts b/packages/rsc-runtime/src/decode-document.ts index 2deffded6..cbb48f68f 100644 --- a/packages/rsc-runtime/src/decode-document.ts +++ b/packages/rsc-runtime/src/decode-document.ts @@ -2,7 +2,9 @@ import { Children, isValidElement, type ReactNode } from 'react'; import { AgentContractError, + admitDocumentNode, createAgentDocument, + expectDocumentDepth, resolveAgentRenderLimits, type AgentDocument, type AgentDocumentNode, @@ -61,24 +63,9 @@ interface DecodeState { representedError: boolean; } -const enterDecodeNode = (depth: number, state: DecodeState): void => { - if (depth > state.limits.maxDocumentDepth) { - throw new AgentContractError( - 'document-depth-exceeded', - `Agent Document depth exceeds ${String(state.limits.maxDocumentDepth)}`, - ); - } - state.nodes += 1; - if (state.nodes > state.limits.maxDocumentNodes) { - throw new AgentContractError( - 'document-node-count-exceeded', - `Agent Document node count exceeds ${String(state.limits.maxDocumentNodes)}`, - ); - } -}; - const decodeNode = (node: ReactNode, depth: number, state: DecodeState): AgentDocumentNode => { - enterDecodeNode(depth, state); + expectDocumentDepth(depth, state.limits); + admitDocumentNode(state); const element = protocolElement(node); const { props } = element; switch (element.type) { diff --git a/packages/rsc-runtime/src/lower-mcp.ts b/packages/rsc-runtime/src/lower-mcp.ts index 8dc2a205d..34fd3c8b2 100644 --- a/packages/rsc-runtime/src/lower-mcp.ts +++ b/packages/rsc-runtime/src/lower-mcp.ts @@ -147,11 +147,8 @@ const jsonRecord = (value: unknown, message: string): JsonObject => { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error('not a plain object'); } - const clone = cloneJsonValue(value, new Set(), ''); - if (Array.isArray(clone) || clone === null || typeof clone !== 'object') { - throw new Error('not a plain object'); - } - return clone as JsonObject; + // cloneJsonValue either throws or returns a clone of the guarded plain object. + return cloneJsonValue(value, new Set(), '') as JsonObject; } catch (error) { throw new Error(`${message} (${error instanceof Error ? error.message : String(error)})`, { cause: error }); } diff --git a/packages/rsc-runtime/src/mount/index.ts b/packages/rsc-runtime/src/mount/index.ts index feab1cd95..cf1ff110d 100644 --- a/packages/rsc-runtime/src/mount/index.ts +++ b/packages/rsc-runtime/src/mount/index.ts @@ -97,71 +97,57 @@ export const createGeneratedRuntimeState = < const noticeDefinition = agentNoticeStateDefinition(definition.lifetime); const shared = definition.lifetime !== 'request'; const liveStores = new Set(); - let projectFailure: AgentStateError | undefined; - let noticeFailure: AgentStateError | undefined; - let projectOpen: Promise>> | undefined; - let noticeOpen: Promise> | undefined; let closing: Promise | undefined; let closed = false; - const open = async ( - stateDefinition: AgentStateDefinition, - cachedFailure: () => AgentStateError | undefined, - rememberFailure: (failure: AgentStateError) => void, - ): Promise>> => { - const failure = cachedFailure(); - if (failure !== undefined) return { error: failure, kind: 'failed' }; - if (closed) { - const closedFailure = new AgentStateError( - 'store-closed', - `State '${stateDefinition.id}' cannot open on a closed generated runtime`, - ); - rememberFailure(closedFailure); - return { error: closedFailure, kind: 'failed' }; - } - try { - const store = await driver.open(stateDefinition); + /** One lazily-opened store slot owning its cached failure and, when shared, its single open. */ + const createSlot = ( + slotDefinition: AgentStateDefinition, + ) => { + let failure: AgentStateError | undefined; + let pending: Promise>> | undefined; + + const openOnce = async (): Promise>> => { + if (failure !== undefined) return { error: failure, kind: 'failed' }; if (closed) { - await store.close(); - const closedFailure = new AgentStateError( + failure = new AgentStateError( 'store-closed', - `State '${stateDefinition.id}' opened after its generated runtime closed`, + `State '${slotDefinition.id}' cannot open on a closed generated runtime`, ); - rememberFailure(closedFailure); - return { error: closedFailure, kind: 'failed' }; + return { error: failure, kind: 'failed' }; } - liveStores.add(store); - return { kind: 'opened', value: store }; - } catch (error) { - const typed = asStateError(error, stateDefinition.id); - rememberFailure(typed); - return { error: typed, kind: 'failed' }; - } - }; + try { + const store = await driver.open(slotDefinition); + if (closed) { + await store.close(); + failure = new AgentStateError( + 'store-closed', + `State '${slotDefinition.id}' opened after its generated runtime closed`, + ); + return { error: failure, kind: 'failed' }; + } + liveStores.add(store); + return { kind: 'opened', value: store }; + } catch (error) { + failure = asStateError(error, slotDefinition.id); + return { error: failure, kind: 'failed' }; + } + }; - const openProject = (): Promise>> => { - if (!shared) { - return open(definition, () => projectFailure, (failure) => { - projectFailure = failure; - }); - } - projectOpen ??= open(definition, () => projectFailure, (failure) => { - projectFailure = failure; - }); - return projectOpen; + return { + open(): Promise>> { + if (!shared) return openOnce(); + pending ??= openOnce(); + return pending; + }, + get pending() { + return pending; + }, + }; }; - const openNotices = (): Promise> => { - if (!shared) { - return open(noticeDefinition, () => noticeFailure, (failure) => { - noticeFailure = failure; - }); - } - noticeOpen ??= open(noticeDefinition, () => noticeFailure, (failure) => { - noticeFailure = failure; - }); - return noticeOpen; - }; + const projectSlot = createSlot(definition); + const noticeSlot = createSlot(noticeDefinition); const closeStore = async (store: ClosableStore): Promise => { if (!liveStores.delete(store)) return; @@ -174,8 +160,8 @@ export const createGeneratedRuntimeState = < closed = true; closing = (async () => { await Promise.allSettled([ - ...(projectOpen === undefined ? [] : [projectOpen]), - ...(noticeOpen === undefined ? [] : [noticeOpen]), + ...(projectSlot.pending === undefined ? [] : [projectSlot.pending]), + ...(noticeSlot.pending === undefined ? [] : [noticeSlot.pending]), ]); const storeClosures = await Promise.allSettled([...liveStores].map((store) => closeStore(store))); let driverFailure: unknown; @@ -194,7 +180,7 @@ export const createGeneratedRuntimeState = < async requestBindings( bindingOptions: { readonly signal?: AbortSignal } = {}, ): Promise> { - const [project, notices] = await Promise.all([openProject(), openNotices()]); + const [project, notices] = await Promise.all([projectSlot.open(), noticeSlot.open()]); const requestStores = shared ? [] : [project, notices] diff --git a/packages/workbench/src/mcp/mcp-page.tsx b/packages/workbench/src/mcp/mcp-page.tsx index b204f8e16..c56c679b5 100644 --- a/packages/workbench/src/mcp/mcp-page.tsx +++ b/packages/workbench/src/mcp/mcp-page.tsx @@ -612,8 +612,19 @@ const catalogItems = (catalog: readonly unknown[], fallback: string): readonly C }; }); +const formattedJson = new WeakMap(); + +const prettyJson = (value: object): string => { + const cached = formattedJson.get(value); + if (cached !== undefined) return cached; + const formatted = JSON.stringify(value, null, 2) ?? String(value); + formattedJson.set(value, formatted); + return formatted; +}; + const display = (value: unknown): string => { try { + if (value !== null && typeof value === 'object') return prettyJson(value); return JSON.stringify(value, null, 2) ?? String(value); } catch { return '[Unserializable protocol value]'; diff --git a/packages/workbench/src/playground/playground-page.tsx b/packages/workbench/src/playground/playground-page.tsx index 35f60d945..b2b96d502 100644 --- a/packages/workbench/src/playground/playground-page.tsx +++ b/packages/workbench/src/playground/playground-page.tsx @@ -7,6 +7,7 @@ import type { PlaygroundEpochIdentity, PlaygroundExport, PlaygroundJsonObject, + PlaygroundJsonValue, PlaygroundReplay, PlaygroundSession, PlaygroundTarget, @@ -635,39 +636,53 @@ const DetailRows = ({ label, rows }: { ; +const formattedJson = new WeakMap(); + +const prettyJson = (value: PlaygroundJsonValue): string => { + if (value === null || typeof value !== 'object') return JSON.stringify(value, undefined, 2); + const cached = formattedJson.get(value); + if (cached !== undefined) return cached; + const formatted = JSON.stringify(value, undefined, 2); + formattedJson.set(value, formatted); + return formatted; +}; + /** The ordered server trace, where every selected assertion remains a persisted raw event reference. */ -export const PlaygroundTraceView = ({ onToggle, view }: PlaygroundTraceViewProps) =>
-

{view.summary}

- {view.promotionBlocker === undefined - ? undefined - :

{view.promotionBlocker}

} - {view.identity.length === 0 ? undefined : } - {view.outcome.length === 0 ? undefined : } - {view.workspace === undefined ? undefined :
-

Recorded workspace

-
{formatPlaygroundJson(view.workspace)}
-
} -
-

Ordered trace

- {view.rows.length === 0 - ?

This session has recorded no trace events yet.

- :
- {view.rows.map((entry) =>
- #{entry.sequence}{entry.kind}{entry.summary} -
- {entry.timestamp}{entry.source}Build {entry.epochId}{entry.rawEventRef} - -
-
{formatPlaygroundJson(entry.raw)}
-
)} -
} -
-
; +export const PlaygroundTraceView = ({ onToggle, view }: PlaygroundTraceViewProps) => { + const selectedRefSet = new Set(view.selectedRefs); + return
+

{view.summary}

+ {view.promotionBlocker === undefined + ? undefined + :

{view.promotionBlocker}

} + {view.identity.length === 0 ? undefined : } + {view.outcome.length === 0 ? undefined : } + {view.workspace === undefined ? undefined :
+

Recorded workspace

+
{formatPlaygroundJson(view.workspace)}
+
} +
+

Ordered trace

+ {view.rows.length === 0 + ?

This session has recorded no trace events yet.

+ :
+ {view.rows.map((entry) =>
+ #{entry.sequence}{entry.kind}{entry.summary} +
+ {entry.timestamp}{entry.source}Build {entry.epochId}{entry.rawEventRef} + +
+
{prettyJson(entry.raw)}
+
)} +
} +
+
; +}; /** * Starts typed server-owned operations, then observes their durable session by diff --git a/scripts/eslint-plugin-effect-boundary.ts b/scripts/eslint-plugin-effect-boundary.ts index 44181748a..69e6db446 100644 --- a/scripts/eslint-plugin-effect-boundary.ts +++ b/scripts/eslint-plugin-effect-boundary.ts @@ -18,7 +18,6 @@ const RUN_NAMES = new Set([ 'runSyncWith', ]); -const EFFECT_MODULES = new Set(['effect']); const EFFECT_NAMESPACES = new Set(['Effect', 'Runtime']); const EFFECT_RUNNER_NAMESPACE_MODULES = new Set(['effect/Effect', 'effect/Runtime']); @@ -27,13 +26,8 @@ const posixPath = (filename: string): string => filename.replaceAll('\\', '/'); export const isEffectBoundaryFile = (filename: string): boolean => posixPath(filename).endsWith('/src/effect/boundary.ts'); -const importedName = (node: { - readonly imported?: { readonly name?: string; readonly type?: string }; -}): string | undefined => { - const imported = node.imported; - if (imported === undefined) return undefined; - return imported.name; -}; +const importedName = (node: { readonly imported?: { readonly name?: string } }): string | undefined => + node.imported?.name; const localName = (node: { readonly local?: { readonly name?: string } }): string | undefined => node.local?.name; @@ -42,7 +36,7 @@ const moduleName = (node: { readonly source?: { readonly value?: unknown } }): s typeof node.source?.value === 'string' ? node.source.value : undefined; const isEffectModule = (source: string | undefined): boolean => - source !== undefined && (EFFECT_MODULES.has(source) || source.startsWith('effect/')); + source !== undefined && (source === 'effect' || source.startsWith('effect/')); type MemberObject = { readonly computed?: boolean; From 6984d011f59ebc6b2dcaf21f6fba9eaad21fc7b6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 17:55:20 +0000 Subject: [PATCH 4/6] refactor: adopt Fable guard catalog and land final canonical swaps Ten residual record guards move onto the strict-json canonicals per the verified semantics catalog: plain C1 guards (eval-routes, codex-events, claude adapter, skills parse-ir) onto isRecord, plain-or-null-prototype C3b guards (eval config, config validate, build manifest and inspect-bundler, host-profiles config extensions, workbench runtime-model) onto isPlainRecord/isJsonRecord, with retyped aliases where JSON-narrowing call sites depend on it. Prototype-only C3a hardening guards and package-boundary copies stay by design. --- packages/agent-bundle/src/adapters/claude.ts | 4 +- .../agent-bundle/src/build/inspect-bundler.ts | 6 +-- packages/agent-bundle/src/build/manifest.ts | 9 ++--- packages/agent-bundle/src/config/validate.ts | 7 +--- .../agent-bundle/src/contracts/strict-json.ts | 1 + .../agent-bundle/src/dev/eval/eval-routes.ts | 6 +-- .../src/dev/mcp-apps/mcp-app-host-profiles.ts | 8 ++-- .../agent-bundle/src/eval/codex-events.ts | Bin 7354 -> 7261 bytes packages/agent-bundle/src/eval/config.ts | 5 +-- packages/agent-bundle/src/skills/parse-ir.ts | 38 +++++++++--------- packages/workbench/src/runtime-model.ts | 8 +--- packages/workbench/src/strict-json.ts | 1 + 12 files changed, 37 insertions(+), 56 deletions(-) diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 400faa51b..d3d8ef4a9 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -2,6 +2,7 @@ import { createTargetDiagnostics } from './diagnostics.ts'; import { hasErrors, type Diagnostic } from '../core/diagnostics.ts'; import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; import { isValidPackageName } from '../core/project-context.ts'; +import { isRecord } from '../core/strict-json.ts'; import { pathTokens, type AgentBundleConfig, @@ -590,8 +591,7 @@ const lspServerFields: ReadonlySet = new Set([ ]); /** Normalized config extension values are already strict JSON, so a plain shape test is enough. */ -const isDataRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); +const isDataRecord: (value: unknown) => value is Readonly> = isRecord; const isPlainDataRecord = (value: unknown): value is Readonly> => isDataRecord(value) && [null, Object.prototype].includes(Object.getPrototypeOf(value)); diff --git a/packages/agent-bundle/src/build/inspect-bundler.ts b/packages/agent-bundle/src/build/inspect-bundler.ts index 8106c6b52..33c151ea5 100644 --- a/packages/agent-bundle/src/build/inspect-bundler.ts +++ b/packages/agent-bundle/src/build/inspect-bundler.ts @@ -1,4 +1,5 @@ import type { TargetHookEntry } from '../adapters/types.ts'; +import { isPlainRecord } from '../core/strict-json.ts'; import type { AgentBundleToolsConfig, NormalizedPlugin } from '../core/types.ts'; import { scanEntryExports } from './entry-exports.ts'; import { @@ -62,10 +63,7 @@ export const generatedDtsTsconfigToken = ''; const artifactOutputToken = (target: string): string => `/${target}`; -const isPlainObject = (value: object): boolean => { - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; +const isPlainObject: (value: object) => boolean = isPlainRecord; /** * Renders a composed bundler config as JSON-safe data without dropping the diff --git a/packages/agent-bundle/src/build/manifest.ts b/packages/agent-bundle/src/build/manifest.ts index 25a390dd1..5b91c530a 100644 --- a/packages/agent-bundle/src/build/manifest.ts +++ b/packages/agent-bundle/src/build/manifest.ts @@ -5,7 +5,7 @@ import { satisfiesGeneratedRuntimeFloor, } from '../core/runtime.ts'; import { isValidPackageName, isValidPackageVersion } from '../core/project-context.ts'; -import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; +import { isPlainRecord, parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts'; export type ArtifactManifestFileKind = 'bundle' | 'copy' | 'generated' | 'prebuilt'; export type ArtifactManifestValidationStatus = 'passed'; @@ -104,11 +104,8 @@ const fail = (message: string): never => { throw new TypeError(`Artifact manifest ${message}`); }; -const isPlainObject = (value: unknown): value is JsonRecord => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; +// Inputs are parsed JSON, so the canonical guard's narrowing is retyped to JsonRecord. +const isPlainObject = isPlainRecord as (value: unknown) => value is JsonRecord; const requireRecord = (value: unknown, location: string): JsonRecord => isPlainObject(value) ? value : fail(`${location} must be a plain object.`); diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 05425ab64..41f780e84 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -3,7 +3,7 @@ import { basename, extname, isAbsolute, join, posix, relative, resolve, sep } fr import { scanEntryExportsSource } from '../build/entry-exports.ts'; import { toPosixRelative } from '../core/paths.ts'; -import { isRecord } from '../core/strict-json.ts'; +import { isPlainRecord, isRecord } from '../core/strict-json.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; import { unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; @@ -208,11 +208,6 @@ const validateHooks = ( return diagnostics; }; -const isPlainRecord = (value: object): value is Record => { - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; - const isProtocolJsonValue = (value: unknown, ancestors = new Set()): boolean => { if (value === null || typeof value === 'boolean' || typeof value === 'string') return true; if (typeof value === 'number') return Number.isFinite(value); diff --git a/packages/agent-bundle/src/contracts/strict-json.ts b/packages/agent-bundle/src/contracts/strict-json.ts index c95c915b8..da4cbac08 100644 --- a/packages/agent-bundle/src/contracts/strict-json.ts +++ b/packages/agent-bundle/src/contracts/strict-json.ts @@ -4,6 +4,7 @@ */ export { hasOnlyOwnKeys, + isJsonRecord, isPlainRecord, mapStrictJsonReason, parseJsonWithoutDuplicateKeys, diff --git a/packages/agent-bundle/src/dev/eval/eval-routes.ts b/packages/agent-bundle/src/dev/eval/eval-routes.ts index 856d508e1..7701ba5f9 100644 --- a/packages/agent-bundle/src/dev/eval/eval-routes.ts +++ b/packages/agent-bundle/src/dev/eval/eval-routes.ts @@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { Readable } from 'node:stream'; -import { hasOnlyOwnKeys, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; +import { hasOnlyOwnKeys, isRecord as coreIsRecord, parseJsonWithoutDuplicateKeys } from '../../core/strict-json.ts'; import { EvalConfigError, EvalDefinitionError, @@ -240,8 +240,8 @@ const route = (requestTarget: string | undefined): Route | undefined => { return Object.freeze({ kind: 'run', runId: segments[1] ?? pathError() }); }; -const isRecord = (value: unknown): value is JsonObject => - typeof value === 'object' && value !== null && !Array.isArray(value); +// 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; 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 8c8e3a669..7beb87646 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,8 @@ import { createHash } from 'node:crypto'; import { relative, resolve } from 'node:path'; +import { isPlainRecord } from '../../core/strict-json.ts'; + import { MCP_APP_PROFILE_DESCRIPTORS, type McpAppHostProfile, @@ -360,11 +362,7 @@ const capabilities = new Set(['camera', 'clipboardWrite', 'geo 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 => { - if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; +const isConfigExtensionRecord: (value: unknown) => value is Record = isPlainRecord; const cloneRecord = (value: unknown, label: string): { readonly [key: string]: McpAppJsonValue } => { const cloned = cloneMcpAppFiniteJson(value, label); diff --git a/packages/agent-bundle/src/eval/codex-events.ts b/packages/agent-bundle/src/eval/codex-events.ts index ed5562e367c9514b1dffd30376ccf836c62d0779..90189f4d0bd67b5185e8c06d0c0f5ea0a2b3bd8a 100644 GIT binary patch delta 64 zcmdmGdDp@?Gq)hWs6?S!A+tCrH95a1MWI$9ttdZNL0wNzAIMMDFD@y{OfJ#QD$dW- SD=AjD=GsuqvYDB6yEp&>Y8IIQ delta 131 zcmca>vCDF!aWRW(W^qtza(+>Yf~|r^Sz=CUs+B@%UUpu7d7h?~0$4yHvv_hKtAw|L ztsR$wLP=#oYJM72#@5zWK|MbyD>b=9T|rF^Dyj$+%`45x0f{I&78NB{>SY##X^0J) L)|==y?BLB=Z; diff --git a/packages/agent-bundle/src/eval/config.ts b/packages/agent-bundle/src/eval/config.ts index 6c61a6c68..9f9f12d7f 100644 --- a/packages/agent-bundle/src/eval/config.ts +++ b/packages/agent-bundle/src/eval/config.ts @@ -1,6 +1,6 @@ import type { Diagnostic } from '../core/diagnostics.ts'; import { isContainedRelativePath } from '../core/paths.ts'; -import { isRecord, snapshotStrictJsonValue } from '../core/strict-json.ts'; +import { isPlainRecord, snapshotStrictJsonValue } from '../core/strict-json.ts'; import { findCredentialConfiguration } from './credentials.ts'; import { EvalConfigError } from './errors.ts'; @@ -33,9 +33,6 @@ export const defaultEvalRunsDir = '.agent-bundle/runs'; const configKeys = Object.freeze(['include', 'runsDir', 'semanticGrader']); const semanticModel = /^[A-Za-z][A-Za-z0-9._:-]{0,127}$/u; -const isPlainRecord = (value: unknown): value is Record => - isRecord(value) && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); - const configError = ( code: ConstructorParameters[0], message: string, diff --git a/packages/agent-bundle/src/skills/parse-ir.ts b/packages/agent-bundle/src/skills/parse-ir.ts index 1913a3621..5c96c7897 100644 --- a/packages/agent-bundle/src/skills/parse-ir.ts +++ b/packages/agent-bundle/src/skills/parse-ir.ts @@ -1,6 +1,7 @@ import type { SkillDocument } from '../config/skill.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { deepFreeze } from '../core/freeze.ts'; +import { isRecord } from '../core/strict-json.ts'; import type { ClaudeSkillExtension, CodexSkillExtension, @@ -66,9 +67,6 @@ const codexPolicyKeys = new Set(['allowImplicitInvocation', 'allow_implicit_invo const codexDependenciesKeys = new Set(['tools']); const codexToolKeys = new Set(['description', 'transport', 'type', 'url', 'value']); -const isPlainRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const asString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; @@ -92,7 +90,7 @@ const asStringOrList = (value: unknown): string | readonly string[] | undefined }; const metadataRecord = (value: unknown): Readonly> | undefined => { - if (!isPlainRecord(value)) return undefined; + if (!isRecord(value)) return undefined; const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'); return entries.length === 0 ? undefined : Object.freeze(Object.fromEntries(entries)); }; @@ -132,7 +130,7 @@ const claudeFrom = (fields: Readonly>): ClaudeSkillExten || fields.effort === 'xhigh' || fields.effort === 'max' ? { effort: fields.effort } : {}), - ...(isPlainRecord(fields.hooks) ? { hooks: Object.freeze({ ...fields.hooks }) } : {}), + ...(isRecord(fields.hooks) ? { hooks: Object.freeze({ ...fields.hooks }) } : {}), ...(asString(fields.model) === undefined ? {} : { model: asString(fields.model) }), ...(asStringList(fields.paths) === undefined ? {} : { paths: asStringList(fields.paths) }), ...(fields.shell === 'bash' || fields.shell === 'powershell' ? { shell: fields.shell } : {}), @@ -162,12 +160,12 @@ const pickBoolean = (record: Readonly>, camel: string, s asBoolean(record[camel]) ?? asBoolean(record[snake]); const codexFrom = (value: unknown): CodexSkillExtension | undefined => { - if (!isPlainRecord(value)) return undefined; - const iface = isPlainRecord(value.interface) ? value.interface : undefined; - const policy = isPlainRecord(value.policy) ? value.policy : undefined; - const dependencies = isPlainRecord(value.dependencies) ? value.dependencies : undefined; + if (!isRecord(value)) return undefined; + const iface = isRecord(value.interface) ? value.interface : undefined; + const policy = isRecord(value.policy) ? value.policy : undefined; + const dependencies = isRecord(value.dependencies) ? value.dependencies : undefined; const tools = Array.isArray(dependencies?.tools) - ? dependencies.tools.filter(isPlainRecord).map((tool) => Object.freeze({ + ? dependencies.tools.filter(isRecord).map((tool) => Object.freeze({ ...(asString(tool.description) === undefined ? {} : { description: asString(tool.description) }), ...(asString(tool.transport) === undefined ? {} : { transport: asString(tool.transport) }), ...(asString(tool.type) === undefined ? {} : { type: asString(tool.type) }), @@ -263,7 +261,7 @@ const peelTargets = ( diagnostics: Diagnostic[], ): SkillIrExtensions => { if (value === undefined) return {}; - if (!isPlainRecord(value)) { + if (!isRecord(value)) { diagnostics.push({ code: 'AB3006', message: 'Skill `targets` must be an object with optional `claude`, `cursor`, and `codex` keys.', @@ -275,15 +273,15 @@ const peelTargets = ( } const unknown = Object.keys(value).filter((key) => key !== 'claude' && key !== 'codex' && key !== 'cursor'); for (const key of unknown) diagnostics.push(unknownField(source, `targets.${key}`)); - if (isPlainRecord(value.claude)) { + if (isRecord(value.claude)) { reportUnknownFields(value.claude, claudeTargetKeys, 'targets.claude', source, diagnostics); } - if (isPlainRecord(value.cursor)) { + if (isRecord(value.cursor)) { reportUnknownFields(value.cursor, cursorTargetKeys, 'targets.cursor', source, diagnostics); } - if (isPlainRecord(value.codex)) { + if (isRecord(value.codex)) { reportUnknownFields(value.codex, codexTargetKeys, 'targets.codex', source, diagnostics); - if (isPlainRecord(value.codex.interface)) { + if (isRecord(value.codex.interface)) { reportUnknownFields( value.codex.interface, codexInterfaceKeys, @@ -292,7 +290,7 @@ const peelTargets = ( diagnostics, ); } - if (isPlainRecord(value.codex.policy)) { + if (isRecord(value.codex.policy)) { reportUnknownFields( value.codex.policy, codexPolicyKeys, @@ -301,7 +299,7 @@ const peelTargets = ( diagnostics, ); } - if (isPlainRecord(value.codex.dependencies)) { + if (isRecord(value.codex.dependencies)) { reportUnknownFields( value.codex.dependencies, codexDependenciesKeys, @@ -311,7 +309,7 @@ const peelTargets = ( ); if (Array.isArray(value.codex.dependencies.tools)) { value.codex.dependencies.tools.forEach((tool, index) => { - if (isPlainRecord(tool)) { + if (isRecord(tool)) { reportUnknownFields( tool, codexToolKeys, @@ -324,7 +322,7 @@ const peelTargets = ( } } } - const claude = isPlainRecord(value.claude) + const claude = isRecord(value.claude) ? claudeFrom({ ...value.claude, 'argument-hint': value.claude.argumentHint ?? value.claude['argument-hint'], @@ -335,7 +333,7 @@ const peelTargets = ( 'allowed-tools': value.claude.allowedTools ?? value.claude['allowed-tools'], }) : undefined; - const cursor = isPlainRecord(value.cursor) + const cursor = isRecord(value.cursor) ? cursorFrom({ ...value.cursor, 'disable-model-invocation': value.cursor.disableModelInvocation ?? value.cursor['disable-model-invocation'], diff --git a/packages/workbench/src/runtime-model.ts b/packages/workbench/src/runtime-model.ts index 28e509e0b..40be11e83 100644 --- a/packages/workbench/src/runtime-model.ts +++ b/packages/workbench/src/runtime-model.ts @@ -11,6 +11,7 @@ import type { } from '../../agent-bundle/src/contracts/runtime.ts'; import type { JsonValue, ProjectEventMessage, ProjectReplayGap } from '../../agent-bundle/src/contracts/runtime.ts'; import type { RuntimeBootstrap } from './runtime-client.ts'; +import { isJsonRecord, isPlainRecord as sharedIsPlainRecord } from './strict-json.ts'; export type RuntimeInspectorTab = 'tree' | 'result' | 'document' | 'flight' | 'protocol' | 'state' | 'diagnostics'; @@ -162,10 +163,7 @@ const emptyProfiles = Object.freeze([]) as readonly RuntimeProfileOption[]; const emptyCounts = Object.freeze(Object.create(null)) as Readonly>; const runtimeTabs = new Set(['tree', 'result', 'document', 'flight', 'protocol', 'state', 'diagnostics']); -const isPlainRecord = (value: object): value is Record => { - const prototype = Object.getPrototypeOf(value); - return prototype === Object.prototype || prototype === null; -}; +const isPlainRecord: (value: object) => value is Record = sharedIsPlainRecord; const nonemptyString = (value: unknown): value is string => typeof value === 'string' && value.length > 0; @@ -272,8 +270,6 @@ const sameStableVector = (left: RuntimeVector, right: RuntimeVector): boolean => left.sourceRevision === right.sourceRevision && left.stateStoreId === right.stateStoreId; -const isJsonRecord = (value: JsonValue): value is Readonly> => - value !== null && typeof value === 'object' && !Array.isArray(value); const sameJson = (left: JsonValue, right: JsonValue): boolean => { if (left === right) return true; diff --git a/packages/workbench/src/strict-json.ts b/packages/workbench/src/strict-json.ts index 80df1ca93..ea06a1ddc 100644 --- a/packages/workbench/src/strict-json.ts +++ b/packages/workbench/src/strict-json.ts @@ -1,5 +1,6 @@ export { hasOnlyOwnKeys, + isJsonRecord, isPlainRecord, mapStrictJsonReason, snapshotStrictJsonValue, From c6e1c7e873514a87bb8fc45eca13f7c89737751a Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:40:02 +0000 Subject: [PATCH 5/6] fix(contracts): keep consent capability guard browser-safe The guard-catalog swap re-exported isMcpAppConsentCapability through contracts/mcp-apps.ts from mcp-app-sandbox.ts, dragging the sandbox's node:crypto/http/net imports into the Workbench browser bundle. Move the capability vocabulary into the browser-safe mcp-app-consent.ts module and re-export it from the sandbox for its server-side consumers. --- packages/agent-bundle/src/contracts/mcp-apps.ts | 4 ++-- .../src/dev/mcp-apps/mcp-app-consent.ts | 13 ++++++++++++- .../src/dev/mcp-apps/mcp-app-sandbox.ts | 8 ++------ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/agent-bundle/src/contracts/mcp-apps.ts b/packages/agent-bundle/src/contracts/mcp-apps.ts index e56de2fbc..04009e552 100644 --- a/packages/agent-bundle/src/contracts/mcp-apps.ts +++ b/packages/agent-bundle/src/contracts/mcp-apps.ts @@ -27,9 +27,9 @@ export type { McpAppPreviewSnapshot, McpAppRuntimeInvalidationDetails, } from '../dev/mcp-app-runtime-preview-service.ts'; -export { isMcpAppConsentCapability } from '../dev/mcp-apps/mcp-app-sandbox.ts'; +export { isMcpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; +export type { McpAppConsentCapability } from '../dev/mcp-apps/mcp-app-consent.ts'; export type { - McpAppConsentCapability, McpAppConsentChallenge, McpAppConsentRequest, McpAppDocumentPolicySnapshot, diff --git a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts index 768027d42..cf35fc574 100644 --- a/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts +++ b/packages/agent-bundle/src/dev/mcp-apps/mcp-app-consent.ts @@ -1,5 +1,16 @@ import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; -import type { McpAppConsentCapability } from './mcp-app-sandbox.ts'; + +/** + * Consent capability vocabulary lives here, outside the sandbox module, so + * the browser-safe contract surface never drags the sandbox's node:* imports + * into a Workbench bundle. + */ +const mcpAppConsentCapabilities = ['call-tool', 'download-file', 'open-external-link', 'clipboard-write', 'camera', 'microphone', 'geolocation', 'request-display-mode'] as const; + +export type McpAppConsentCapability = (typeof mcpAppConsentCapabilities)[number]; + +export const isMcpAppConsentCapability = (value: unknown): value is McpAppConsentCapability => + (mcpAppConsentCapabilities as readonly unknown[]).includes(value); export const createMcpAppConsentActionDigest = ( capability: McpAppConsentCapability, 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 b3a7270c7..f602d8dd0 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 @@ -5,6 +5,7 @@ import { isIP, type Socket } from 'node:net'; import { isRecord } from '../../core/strict-json.ts'; import type { McpAppJsonValue } from './mcp-app-binding-service.ts'; +import type { McpAppConsentCapability } from './mcp-app-consent.ts'; import { deepFreeze } from '../../core/freeze.ts'; @@ -145,12 +146,7 @@ export interface McpAppSandboxPolicy { readonly warnings: readonly McpAppSandboxWarning[]; } -const mcpAppConsentCapabilities = ['call-tool', 'download-file', 'open-external-link', 'clipboard-write', 'camera', 'microphone', 'geolocation', 'request-display-mode'] as const; - -export type McpAppConsentCapability = (typeof mcpAppConsentCapabilities)[number]; - -export const isMcpAppConsentCapability = (value: unknown): value is McpAppConsentCapability => - (mcpAppConsentCapabilities as readonly unknown[]).includes(value); +export { isMcpAppConsentCapability, type McpAppConsentCapability } from './mcp-app-consent.ts'; export interface McpAppConsentGrant { readonly authorizationId: string; From a6afc91ca367b832cbb9d423b73f17af9d2d9eb4 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Thu, 3 Sep 2026 00:42:17 +0000 Subject: [PATCH 6/6] fix(build): route bin artifact path through canonical toPosixRelative Main's bin-entry emission (landed after this branch diverged) called the node:path relative directly; the round-2 consolidation removed that import in favor of the canonical helper. Swap the new call site onto the helper. --- packages/agent-bundle/src/build/package-build.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/src/build/package-build.ts b/packages/agent-bundle/src/build/package-build.ts index f2b1b6f64..18f3e11a3 100644 --- a/packages/agent-bundle/src/build/package-build.ts +++ b/packages/agent-bundle/src/build/package-build.ts @@ -200,7 +200,7 @@ export const planPackageEntries = async ( } const outputRelativePath = `bin/${name}.js`; const emittedBinDirectory = dirname(resolve(options.packageOutputRoot, outputRelativePath)); - const relativeArtifact = relative(emittedBinDirectory, options.artifactRoot).replaceAll('\\', '/'); + const relativeArtifact = toPosixRelative(emittedBinDirectory, options.artifactRoot); const source = packageBuild.bins[0]?.source ?? packageBuild.lib!.source; entries.push({ aliases: { [installEntryRuntimeSpecifier]: installEntryRuntimePath() },