diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index be1365b538..5ab33aa2ca 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -42,8 +42,8 @@ en: cfd3e311400a9c9c zh: f7e12639f067e07b /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map: - en: 9a981c5c93673a25 - zh: f8e295089807ab2a + en: d49ad6b735b65e38 + zh: 904e1460d141539e /deepseek-ai-dsh-ptc-runtime-node/further-exploration: en: ac2e2ae13d3f1394 zh: 5d08b9c658fcf83a diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 9340fe9fcf..56dced3752 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -150,7 +150,7 @@ Source containing `await` is checked for top-level await before native compilati | [`src/output-stream.ts`](src/output-stream.ts) | Bounded stream capture and process-output draining | | [`src/launch.ts`](src/launch.ts) | Executable/bootstrap arguments and execution-world asset mapping | | [`src/process.ts`](src/process.ts) | Common child handshake, evaluator lifetime and binding RPC | -| [`src/bootstrap.ts`](src/bootstrap.ts) | Program evaluation, binding proxies and output capture | +| [`src/bootstrap.ts`](src/bootstrap.ts) | Binding proxies, JSON validation and output capture | | [`src/rpc.ts`](src/rpc.ts) | Typed birpc transport, message limits and connection lifetime | | [`src/protocol.ts`](src/protocol.ts) | Host and program methods, and validation of untrusted program results | | [`src/output-ledger.ts`](src/output-ledger.ts) | Host accounting for the outer result | diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index b7b9012d12..921171d746 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -150,7 +150,7 @@ Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host | [`src/output-stream.ts`](src/output-stream.ts) | 有界流捕获与进程输出排空 | | [`src/launch.ts`](src/launch.ts) | 可执行文件/bootstrap 参数与执行世界资源映射 | | [`src/process.ts`](src/process.ts) | 共用的子进程握手、求值器生命周期与绑定 RPC | -| [`src/bootstrap.ts`](src/bootstrap.ts) | 程序求值、绑定代理与输出捕获 | +| [`src/bootstrap.ts`](src/bootstrap.ts) | 绑定代理、JSON 校验与输出捕获 | | [`src/rpc.ts`](src/rpc.ts) | 类型化 birpc 传输、消息限制与连接生命周期 | | [`src/protocol.ts`](src/protocol.ts) | Host 与程序方法,以及不可信程序结果的校验 | | [`src/output-ledger.ts`](src/output-ledger.ts) | Host 外层结果计量 | diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index ef020aa3e9..4b1986eaf1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -1,11 +1,9 @@ /** - * Program evaluation, output capture, and host binding proxies over the process channel. + * Output capture, result encoding, and host binding proxies over the process channel. * @module @deepseek-ai/dsh-ptc-runtime-node/src/bootstrap */ import { inspect } from 'node:util' -import { createProgramEvaluator } from './evaluators/index.ts' -import type { ProgramEvaluator } from './evaluators/types.ts' import type { HostMethods, ProgramResult, ProgramBootData } from './protocol.ts' import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts' import { decodePtcJsonWire, encodePtcJsonWire, snapshotPtcJsonValue } from './json-wire.ts' @@ -247,21 +245,6 @@ function bindingFailure(errorClass: BindingErrorConstructor | undefined, memberN return errorClass ? new errorClass(memberName, message) : new CapturedError(message) } -/** - * Build each declared error class once so calls and `instanceof` share constructor identity. - * @param data - binding namespace declarations from the boot payload. - * @returns constructors keyed by their owning namespace global. - */ -export function makeBindingErrorClasses( - data: Pick, -): Map { - const classes = new Map() - for (const namespace of data.namespaces) { - if (namespace.errorClass) classes.set(namespace.global, makeBindingErrorClass(namespace.errorClass)) - } - return classes -} - /** * Build null-prototype binding globals with namespace-specific rejection classes. * @param data - Namespace declarations injected into the program. @@ -272,7 +255,7 @@ export function makeBindingErrorClasses( export function makeNamespaces( data: Pick, host: Pick, - errorClasses: Pick, 'get'> = makeBindingErrorClasses(data), + errorClasses: Pick, 'get'>, ): Record[] { return data.namespaces.map(({ global, names }) => { const namespace = Object.create(null) as Record @@ -300,51 +283,3 @@ export function makeNamespaces( return namespace }) } - -/** - * Run one program through its selected evaluator with captured output and host binding proxies. - * @param host - Evaluation-scoped host methods. - * @param data - the boot payload the host sent. - * @param streams - stdout/stderr objects captured as program logs. - * @returns Completion or a bounded program failure. - */ -export async function runProgram( - host: Omit, - data: ProgramBootData, - streams: { stdout: PatchableStream; stderr: PatchableStream }, -): Promise { - const logs = new LogBuffer( - data.maxOutputBytes, - (text) => { host.log(text) }, - () => { host.outputLimit() }, - ) - captureStreamWrites(logs, streams.stdout) - captureStreamWrites(logs, streams.stderr) - - const errorClasses = makeBindingErrorClasses(data) - const namespaces = makeNamespaces(data, host, errorClasses) - let evaluator: ProgramEvaluator | undefined - try { - const completion = Promise.withResolvers() - evaluator = createProgramEvaluator(data.programFormat ?? 'function-body', (message) => { completion.reject(new CapturedError(message)) }) - const globals = evaluator.globals - const defineGlobal = (name: string, value: unknown): void => { - Object.defineProperty(globals, name, { configurable: true, enumerable: true, writable: true, value }) - } - for (const [index, namespace] of data.namespaces.entries()) defineGlobal(namespace.global, namespaces[index]) - for (const namespace of data.namespaces) { - if (namespace.errorClass) defineGlobal(namespace.errorClass.name, errorClasses.get(namespace.global)) - } - defineGlobal('console', makeConsoleShim(logs)) - evaluator.evaluate(data.code, (result) => { - if (result.ok) completion.resolve(result.value) - else completion.reject(result.error) - }) - const value = await completion.promise - return prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes) - } catch (error: unknown) { - return prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) - } finally { - evaluator?.close() - } -} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/launch.ts b/packages/ptc-runtime/ptc-runtime-node/src/launch.ts index f327f09126..6ee457375e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/launch.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/launch.ts @@ -41,7 +41,7 @@ export function resolveLaunch(config?: LaunchConfig): LaunchConfig { * @param persistent - Whether the selected mode requires retained-process launch support. * @returns Arguments following the resolved Node executable. */ -export function bootstrapArgs(fs: Pick, config: LaunchConfig, maxMessageBytes: number, persistent: boolean = false): string[] { +export function bootstrapArgs(fs: Pick, config: LaunchConfig, maxMessageBytes: number, persistent = false): string[] { switch (config.kind) { case 'embedded': if (persistent) throw new Error('Persistent PTC requires a node-script launch') diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts index 49ffecfe88..1965cc82a3 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process-entry.ts @@ -2,6 +2,4 @@ import { openInheritedControlChannel } from '@deepseek-ai/dsh-subprocess/control' import { runPersistentMain } from './persistent-process.ts' -const format = process.argv[3] ?? 'repl' -if (format !== 'function-body' && format !== 'repl' && format !== 'script') throw new Error(`unsupported persistent PTC format: ${format}`) -void runPersistentMain(openInheritedControlChannel(), Number(process.argv[2]), process, format) +void runPersistentMain(openInheritedControlChannel(), Number(process.argv[2]), process) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts index e7ba85cdfd..e480983c7c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -1,24 +1,17 @@ /** Existing retained bootstrap entry delegates to the shared Node evaluation loop. */ -import type { PtcProgramFormat } from '@deepseek-ai/dsh-ptc-runtime' import type { Duplex } from 'node:stream' import { runNodeMain } from './process.ts' import type { ProgramProcess } from './process.ts' -/** Process state required by retained bootstraps that change cwd between evaluations. */ -export interface PersistentProcess extends ProgramProcess { - chdir(directory: string): void -} - /** * Serve evaluations through the installed retained-bootstrap entry. * @param stream - Inherited private control endpoint. * @param maxMessageBytes - Maximum control frame and queued output bytes. * @param processState - Child environment, output and directory control. - * @param format - Default syntax for hosts that omit it from program data. * @returns After control closure and evaluator cleanup. */ export function runPersistentMain( - stream: Duplex, maxMessageBytes: number, processState: PersistentProcess, format: PtcProgramFormat = 'repl', + stream: Duplex, maxMessageBytes: number, processState: ProgramProcess, ): Promise { - return runNodeMain(stream, maxMessageBytes, processState, format) + return runNodeMain(stream, maxMessageBytes, processState, 'repl') } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 3c23c6d984..04536b4e08 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -16,7 +16,7 @@ export interface ProgramProcess { stdout: PatchableStream stderr: PatchableStream exitCode?: string | number | null | undefined - chdir?(directory: string): void + chdir(directory: string): void } interface Evaluation { @@ -85,7 +85,6 @@ export async function runNodeMain( current = evaluation context.run(evaluation, () => { try { - if (processState.chdir === undefined) throw new Error('Node program process cannot change its working directory') processState.chdir(cwd) if (evaluator === undefined) { evaluator = createProgramEvaluator(format, (error) => { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts index 1d14de5287..752c44a806 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts @@ -1,22 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram } from '../src/bootstrap.ts' -import type { PatchableStream } from '../src/bootstrap.ts' +import { LogBuffer, makeBindingErrorClass, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException } from '../src/bootstrap.ts' +import type { BindingErrorConstructor, PatchableStream } from '../src/bootstrap.ts' import type { HostMethods } from '../src/protocol.ts' -import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' +import { encodePtcJsonWire } from '../src/json-wire.ts' -function host(): Omit & { logs: string[]; limits: number } { - const calls = { - logs: [] as string[], limits: 0, - invoke: vi.fn(async () => encodePtcJsonWire(42)), - log: (text: string) => { calls.logs.push(text) }, - outputLimit: () => { calls.limits += 1 }, - } - return calls +function host(): Pick { + return { invoke: vi.fn(async () => encodePtcJsonWire(42)) } } -function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } { - return { stdout: { write: () => true }, stderr: { write: () => true } } -} -const BOOT = { maxOutputBytes: 65_536, retainsState: false } const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const function toolNamespace(names: string[]) { return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } } @@ -160,7 +150,7 @@ describe('makeNamespaces', () => { it('rejects malformed remote results and keeps prototype-colliding function names callable', async () => { const remote = host() remote.invoke = vi.fn(async (_global, name) => name === 'bad' ? [undefined] as never : encodePtcJsonWire(`${name}-ok`)) - const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['bad', '__proto__', 'constructor', 'toString'] }] }, remote) as [Record Promise>] + const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['bad', '__proto__', 'constructor', 'toString'] }] }, remote, new Map()) as [Record Promise>] expect(Object.getPrototypeOf(tools)).toBeNull() for (const name of ['__proto__', 'constructor', 'toString']) await expect(tools[name]?.({})).resolves.toBe(`${name}-ok`) await expect(tools.bad?.({})).rejects.toThrow('binding resolution must be lossless JSON') @@ -169,7 +159,7 @@ describe('makeNamespaces', () => { it('uses the declared constructor for remote and argument failures', async () => { const remote = host() const data = { namespaces: [toolNamespace(['x'])] } - const classes = makeBindingErrorClasses(data) + const classes = new Map([['tools', makeBindingErrorClass(TOOL_ERROR_CLASS)]]) const [tools] = makeNamespaces(data, remote, classes) as [Record Promise>] const decorated = [1] Object.defineProperty(decorated, 'extra', { value: true }) @@ -189,71 +179,8 @@ describe('makeNamespaces', () => { it('uses ordinary Error when the namespace declares no rejection class', async () => { const remote = host() remote.invoke = async () => { throw new Error('helper denied') } - const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, remote) as [Record Promise>] + const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, remote, new Map()) as [Record Promise>] await expect(helpers.x?.({})).rejects.toMatchObject({ name: 'Error', message: 'helper denied' }) await expect(helpers.x?.(() => 1)).rejects.toThrow('binding arguments must be lossless JSON') }) }) - -describe('runProgram', () => { - it('returns the completion after streaming logs and invoking host functions', async () => { - const remote = host() - remote.invoke = async (_global, _name, wire) => { - const args = decodePtcJsonWire(wire) as { n: number } - return encodePtcJsonWire(args.n * 2) - } - const result = await runProgram(remote, { ...BOOT, code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', namespaces: [{ global: 'tools', names: ['double'] }] }, fakeStreams()) - expect(remote.logs).toEqual(['got 42']) - expect(decodePtcJsonWire(result.value)).toEqual({ doubled: 42 }) - }) - - it('reports captured output overflow before completing', async () => { - const remote = host() - const result = await runProgram(remote, { maxOutputBytes: 4, retainsState: false, code: 'console.log("12345"); return null', namespaces: [] }, fakeStreams()) - expect(remote.logs).toEqual([]) - expect(remote.limits).toBe(1) - expect(result).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' } }) - }) - - it.each([ - ['throw new Error("boom")', 'boom'], - ['throw "raw-throw"', 'raw-throw'], - ['const e = new Error("bare"); e.stack = undefined; throw e', 'bare'], - ])('returns bounded program exceptions for %s', async (code, message) => { - const result = await runProgram(host(), { ...BOOT, code, namespaces: [] }, fakeStreams()) - expect(result.error?.kind).toBe('exception') - expect(result.error?.message).toContain(message) - expect(result.value).toBeUndefined() - }) - - it.each(['throw "x".repeat(1_000_000)', 'throw new Error("x".repeat(1_000_000))'])('bounds large diagnostics for %s', async (code) => { - expect(await runProgram(host(), { maxOutputBytes: 64, retainsState: false, code, namespaces: [] }, fakeStreams())).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } }) - }) - - it.each([ - ['tools', 'ToolCallError', 'toolName'], - ['helpers', 'HelperCallError', 'helperName'], - ])('injects the declared rejection class for %s', async (global, name, memberNameProperty) => { - const remote = host() - remote.invoke = async () => { throw new Error('denied by host') } - const result = await runProgram(remote, { - ...BOOT, - code: `try { await ${global}.x({}) } catch (error) { return { caught: error instanceof ${name}, name: error.name, member: error.${memberNameProperty}, message: error.message } }`, - namespaces: [{ global, names: ['x'], errorClass: { name, memberNameProperty } }], - }, fakeStreams()) - expect(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' }) - }) - - it('leaves the owned output streams connected to their capture sink', async () => { - const remote = host() - const streams = fakeStreams() - await runProgram(remote, { ...BOOT, code: 'return 1', namespaces: [] }, streams) - streams.stdout.write('captured') - expect(remote.logs).toEqual(['captured']) - }) -}) - -it('reports an asynchronous REPL failure through the standalone bootstrap', async () => { - const result = await runProgram(host(), { ...BOOT, programFormat: 'repl', code: 'setImmediate(() => { throw new Error("async bootstrap failure") }); await new Promise(() => {})', namespaces: [] }, fakeStreams()) - expect(result).toHaveProperty('error.message', expect.stringContaining('async bootstrap failure')) -}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts index b328b529ee..6a093bcc0d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-process.spec.ts @@ -2,7 +2,8 @@ import { Duplex, PassThrough } from 'node:stream' import { setImmediate } from 'node:timers/promises' import { expect, it, onTestFinished } from 'vitest' import { createProcessRpc } from '../src/rpc.ts' -import { runPersistentMain, type PersistentProcess } from '../src/persistent-process.ts' +import { runPersistentMain } from '../src/persistent-process.ts' +import type { ProgramProcess } from '../src/process.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' import type { ProgramBootData, HostMethods, ProgramMethods } from '../src/protocol.ts' @@ -13,7 +14,7 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { const host = Duplex.from({ readable: second, writable: first }) child.on('error', () => {}) host.on('error', () => {}) - const state: PersistentProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir } + const state: ProgramProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir } const nativeEnvironment = state.env const ready = Promise.withResolvers() const seen: Record[] = [] diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts index 638fb6cca2..ebf9ff24e4 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/process-main.spec.ts @@ -4,7 +4,7 @@ import { setImmediate as nextTurn } from 'node:timers/promises' import { expect, it, onTestFinished, vi } from 'vitest' import { createProcessRpc } from '../src/rpc.ts' import { runNodeMain, type ProgramProcess } from '../src/process.ts' -import type { HostMethods, ProgramMethods } from '../src/protocol.ts' +import type { HostMethods, ProgramBootData, ProgramMethods } from '../src/protocol.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' function endpoints() { @@ -19,7 +19,7 @@ function endpoints() { } function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined, chdir: () => {} } } -async function setup(limit = 4096) { +async function setup(limit = 4096, namespaces: ProgramBootData['namespaces'] = [{ global: 'tools', names: ['echo'] }]) { const { child, host } = endpoints() const state = processState() const nativeEnvironment = state.env @@ -38,16 +38,20 @@ async function setup(limit = 4096) { await ready.promise const run = (code: string, maxOutputBytes = 1024, programFormat?: PtcProgramFormat) => peer.run('/', { code, ...programFormat === undefined ? {} : { programFormat }, - retainsState: false, namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes, + retainsState: false, namespaces, maxOutputBytes, }) return { child, host, state, nativeEnvironment, methods, peer, main, logs, run } } it('clears the environment and returns a binding result after captured output', async () => { const h = await setup() - const result = await h.run('console.log("ready"); return await tools.echo({});') - expect(decodePtcJsonWire(result.value)).toBe(42) - expect(h.logs).toEqual(['ready']) + h.methods.invoke = async (_global, _name, wire) => { + const args = decodePtcJsonWire(wire) as { n: number } + return encodePtcJsonWire(args.n * 2) + } + const result = await h.run('const doubled = await tools.echo({ n: 21 }); console.log("got", doubled); return { doubled };') + expect(decodePtcJsonWire(result.value)).toEqual({ doubled: 42 }) + expect(h.logs).toEqual(['got 42']) expect(h.state.env).toEqual({}) expect(h.state.env).not.toBe(h.nativeEnvironment) expect(Object.getPrototypeOf(h.state.env)).toBeNull() @@ -91,14 +95,43 @@ it.each(['function-body', 'repl', 'script'] as const)('keeps an unawaited call p expect(h.state.exitCode).toBeUndefined() }) -it('reports exhausted output accounting during the run', async () => { +it.each([4, 64])('reports exhausted output accounting during the run with a %s-byte limit', async (maxOutputBytes) => { const h = await setup() const limited = vi.fn() h.methods.outputLimit = limited - expect((await h.run('console.log("x".repeat(100)); return 1', 64)).error?.kind).toBe('output-limit') + expect(await h.run('console.log("x".repeat(100)); return null', maxOutputBytes)) + .toEqual({ error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } }) + if (maxOutputBytes === 4) expect(h.logs).toEqual([]) await vi.waitFor(() => { expect(limited).toHaveBeenCalledOnce() }) }) +it.each([ + ['throw new Error("boom")', 'boom'], + ['throw "raw-throw"', 'raw-throw'], + ['const e = new Error("bare"); e.stack = undefined; throw e', 'bare'], +])('returns bounded program exceptions for %s', async (code, message) => { + const h = await setup(65_536) + const result = await h.run(code, 65_536) + expect(result.error?.kind).toBe('exception') + expect(result.error?.message).toContain(message) + expect(result.value).toBeUndefined() +}) + +it.each(['throw "x".repeat(1_000_000)', 'throw new Error("x".repeat(1_000_000))'])('bounds large diagnostics for %s', async (code) => { + const h = await setup() + expect(await h.run(code, 64)).toEqual({ error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' } }) +}) + +it.each([ + ['tools', 'ToolCallError', 'toolName'], + ['helpers', 'HelperCallError', 'helperName'], +])('injects the declared rejection class for %s', async (global, name, memberNameProperty) => { + const h = await setup(4096, [{ global, names: ['x'], errorClass: { name, memberNameProperty } }]) + h.methods.invoke = async () => { throw new Error('denied by host') } + const result = await h.run(`try { await ${global}.x({}) } catch (error) { return { caught: error instanceof ${name}, name: error.name, member: error.${memberNameProperty}, message: error.message } }`) + expect(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' }) +}) + it('sends no binding calls or output notifications after returning its result', async () => { const h = await setup() const invoked = vi.fn(async () => encodePtcJsonWire(1)) @@ -150,9 +183,3 @@ it('records malformed host traffic after completion', async () => { await h.main expect(h.state.exitCode).toBe(1) }) - -it('reports a missing directory operation before evaluating the program', async () => { - const h = await setup() - delete h.state.chdir - expect(await h.run('return 42')).toHaveProperty('error.message', expect.stringContaining('cannot change its working directory')) -})