From 534aafce7ae2c2e45c376f58fd475466783bf8d7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 20 Oct 2026 14:40:37 +0800 Subject: [PATCH 1/8] refactor(ptc-runtime-node): carry the process control channel over birpc Replace the length-prefixed JsonChannel and hand-written request/reply correlation with typed birpc methods over newline-delimited JSON. The host calls the child's run(); bindings call the host's invoke(); logs and output-limit reports are notifications. The transport bounds incoming lines and queued writes, validates UTF-8 and exposed methods, and rejects outstanding calls when the pipe closes. Drop logic made obsolete by the new transport, including the 32-bit frame-length bound on maxMessageBytes. --- .../ptc-runtime-node/README.i18n.yaml | 12 +- .../ptc-runtime/ptc-runtime-node/README.md | 9 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 9 +- .../ptc-runtime/ptc-runtime-node/package.json | 3 +- .../ptc-runtime-node/src/bindings.ts | 24 +- .../ptc-runtime-node/src/bootstrap.ts | 158 +++----- .../ptc-runtime-node/src/channel.ts | 140 ------- .../ptc-runtime-node/src/config.ts | 2 +- .../ptc-runtime/ptc-runtime-node/src/index.ts | 114 +++--- .../src/persistent-process.ts | 135 ++++--- .../ptc-runtime-node/src/persistent.ts | 133 +++---- .../ptc-runtime-node/src/process.ts | 91 ++--- .../ptc-runtime-node/src/protocol.ts | 93 +++-- .../ptc-runtime-node/src/result.ts | 25 ++ .../ptc-runtime/ptc-runtime-node/src/rpc.ts | 100 +++++ .../ptc-runtime-node/tests/bootstrap.spec.ts | 359 ++++-------------- .../ptc-runtime-node/tests/channel.spec.ts | 166 -------- .../tests/host-failures.spec.ts | 109 +++--- .../tests/persistent-host.spec.ts | 154 +++++--- .../tests/persistent-process.spec.ts | 63 +-- .../tests/process-main.spec.ts | 207 +++++----- .../ptc-runtime-node/tests/process.spec.ts | 44 ++- .../ptc-runtime-node/tests/raw-peer.ts | 20 + .../ptc-runtime-node/tests/rpc.spec.ts | 177 +++++++++ .../ptc-runtime-node/tests/runtime.spec.ts | 6 +- .../ptc-runtime-node/tsdown.config.ts | 4 +- pnpm-lock.yaml | 8 + scripts/no-unknown-casts.baseline.json | 4 - scripts/ssh-helper/worker-probe.ts | 28 +- 29 files changed, 1088 insertions(+), 1309 deletions(-) delete mode 100644 packages/ptc-runtime/ptc-runtime-node/src/channel.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/result.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/src/rpc.ts delete mode 100644 packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 4fdf2c4c23..ffaf7f5da5 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -36,14 +36,14 @@ en: 9020b491a39e4bf5 zh: bcd1b2b1a3bde7f3 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control: - en: 894cf1c1fdbe0092 - zh: c1e1179391e89a3f + en: ed2b9c49cca1681c + zh: 611504d24cec9d35 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps: - en: e7251a2f7cb8f134 - zh: f68c08c507992969 + en: f9d796233bfb3b79 + zh: f7c5803a6ccdf9c2 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map: - en: 2a702f70cac410d1 - zh: f275136c6019b56d + en: 2246ec005bd30244 + zh: a8aedbdfeedf924e /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 ebf931b141..091cf6a2e1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,11 +109,13 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o For per-call execution, the host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. -Length-framed JSON travels separately from stdout/stderr. The host bounds frames and queued writes, validates call identity and declared binding names before dispatch, and refuses invalid traffic. The child flushes its terminal frame and keeps the control channel open until the host closes it. After submitting that frame, it ignores later binding replies and sends no further program control messages. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. +The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates evaluation identity, binding names and lossless JSON before dispatch. Execution deadlines remain host-owned, including calls that await approval. + +Per-call completion rejects unfinished binding waits and stops captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. ### Source and built bootstraps -Source execution loads an erasable-only bootstrap closure without relying on sibling built exports. Built script execution uses `process.js` for per-call programs or `persistent-process.js` for retained REPL environments. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file. +Source execution loads erasable TypeScript and its installed birpc dependency without relying on sibling built exports. Built process scripts bundle birpc so a preinstalled bootstrap needs no adjacent dependency installation. Built script execution uses `process.js` for per-call programs or `persistent-process.js` for retained REPL environments. Persistent mode requires a `node-script` launch. A remote script deployment supplies the matching `launch.bootstrapPath`. Embedded carriers launch their private worker through `launch.executable`. `resolveLaunch()` selects the local default during construction; an explicit remote invocation never depends on whether the client itself is packaged. A host path is never assumed to name the same remote file. ### Source map @@ -127,7 +129,8 @@ Source execution loads an erasable-only bootstrap closure without relying on sib | [`src/launch.ts`](src/launch.ts) | Executable/bootstrap arguments and execution-world asset mapping | | [`src/process.ts`](src/process.ts) | Child handshake, environment clearing and program lifecycle | | [`src/bootstrap.ts`](src/bootstrap.ts) | Program evaluation, binding proxies and output capture | -| [`src/channel.ts`](src/channel.ts) | Framing, bounded writes and protocol failures | +| [`src/rpc.ts`](src/rpc.ts) | Typed birpc transport, message limits and connection lifetime | +| [`src/result.ts`](src/result.ts) | Validation of untrusted program completions | | [`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 fd45eeda40..2f9e6f486e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,11 +109,13 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 -带长度分帧的 JSON 与 stdout/stderr 分开传输。Host 限制帧与排队写入,在分派前验证调用身份和已声明的绑定名,并拒绝无效通信。子进程刷新终态帧后仍保持控制通道打开,直到 Host 关闭通道。提交终态帧后,子进程忽略后续绑定回复,不再发送程序控制消息。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 +Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验求值身份、绑定名与无损 JSON。执行截止时间仍由 Host 管理,包含等待审批的调用。 + +单次执行完成后会拒绝尚未完成的绑定等待,并停止捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 ### 源代码与构建后 bootstrap -源代码执行加载仅含可擦除语法的 bootstrap 依赖,不依赖同级包的构建后导出。构建后脚本通过 `process.js` 运行逐次程序,通过 `persistent-process.js` 运行保留状态的 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。 +源代码执行加载可擦除的 TypeScript 及已安装的 birpc 依赖,不依赖同级包的构建后导出。构建后的进程脚本将 birpc 打包在内,因此预安装的 bootstrap 无需在旁边安装依赖。构建后脚本通过 `process.js` 运行逐次程序,通过 `persistent-process.js` 运行保留状态的 REPL。保留模式要求 `node-script` 启动方式。远端脚本部署提供对应的 `launch.bootstrapPath`。内嵌载体通过 `launch.executable` 启动私有 worker。`resolveLaunch()` 在构造时选择本地默认值;显式的远端调用不依赖客户端自身是否打包。不会假设 Host 路径对应同一个远程文件。 ### 源码索引 @@ -127,7 +129,8 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 | [`src/launch.ts`](src/launch.ts) | 可执行文件/bootstrap 参数与执行世界资源映射 | | [`src/process.ts`](src/process.ts) | 子进程握手、环境清空与程序生命周期 | | [`src/bootstrap.ts`](src/bootstrap.ts) | 程序求值、绑定代理与输出捕获 | -| [`src/channel.ts`](src/channel.ts) | 分帧、有界写入与协议失败 | +| [`src/rpc.ts`](src/rpc.ts) | 类型化 birpc 传输、消息限制与连接生命周期 | +| [`src/result.ts`](src/result.ts) | 不可信程序完成值的校验 | | [`src/output-ledger.ts`](src/output-ledger.ts) | Host 外层结果计量 | diff --git a/packages/ptc-runtime/ptc-runtime-node/package.json b/packages/ptc-runtime/ptc-runtime-node/package.json index 1b2ee3a234..9d181b7c89 100644 --- a/packages/ptc-runtime/ptc-runtime-node/package.json +++ b/packages/ptc-runtime/ptc-runtime-node/package.json @@ -45,7 +45,8 @@ }, "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*", - "@deepseek-ai/schemastery": "workspace:~" + "@deepseek-ai/schemastery": "workspace:~", + "birpc": "^4.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts b/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts index 5a33c8e4a8..b487858c83 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bindings.ts @@ -2,8 +2,7 @@ import { DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-ptc-runtime' import type { PtcBindingFunction, PtcBindingNamespace, PtcJsonValue, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-util-values' -import { encodePtcJsonWire } from './json-wire.ts' -import type { ReplyMessage } from './protocol.ts' +import { encodePtcJsonWire, type PtcJsonWire } from './json-wire.ts' const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ /** * Reject unusable namespaces before starting a process. @@ -54,19 +53,14 @@ export function validateBindings(request: PtcRunRequest): Map { - try { - if (!binding) throw new Error('binding is unavailable in this evaluation; reacquire the current tool') - const value = snapshotJsonValue(await binding(args)) - if (value === undefined) throw new Error('binding resolution must be lossless JSON') - return { type: 'reply', id, ok: true, value: encodePtcJsonWire(value) } - } catch (error: unknown) { - return { type: 'reply', id, ok: false, message: error instanceof Error ? error.message : String(error) } - } +export async function invokeBinding(binding: PtcBindingFunction | undefined, args: PtcJsonValue): Promise { + if (!binding) throw new Error('binding is unavailable in this evaluation; reacquire the current tool') + const value = snapshotJsonValue(await binding(args)) + if (value === undefined) throw new Error('binding resolution must be lossless JSON') + return encodePtcJsonWire(value) } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index 06a2338a24..8fd45a3190 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -4,7 +4,8 @@ */ import { inspect } from 'node:util' -import type { DoneMessage, ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts' +import type { ProgramResult, ProgramBootData } from './protocol.ts' +import type { PtcJsonWire } from './json-wire.ts' import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts' import { decodePtcJsonWire, encodePtcJsonWire, snapshotPtcJsonValue } from './json-wire.ts' @@ -20,10 +21,23 @@ function defineBindingErrorField(error: Error, key: string, value: string): void capturedObjectDefineProperty(error, key, attributes) } -/** Program messages and host replies transported by the private process channel. */ -export interface BootstrapPort { - postMessage(message: ProgramToHost): void - on(event: 'message', listener: (message: ReplyMessage) => void): void +/** Evaluation-scoped host calls used by injected bindings and output capture. */ +export interface ProgramHost { + /** + * Invoke one declared host binding. + * @param global - Namespace global. + * @param name - Function name. + * @param args - Flat lossless JSON argument. + * @returns Flat lossless result or a rejected binding failure. + */ + invoke(this: void, global: string, name: string, args: PtcJsonWire): Promise + /** + * Stream text to the host output ledger. + * @param text - Captured text. + */ + log(text: string): void + /** Report exhausted output accounting to the host. */ + outputLimit(): void } /** @@ -151,7 +165,7 @@ export function captureStreamWrites(logs: Pick, stream: Patch const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const /** - * Prepare the program's completion value for the done message. Only lossless + * Prepare the program's completion value for the run result. Only lossless * JSON crosses, and a value that does not fit the remaining combined outer * budget reports `output-limit`; the host revalidates hostile traffic and * remains authoritative for native pipe writes the program shim cannot observe. @@ -159,13 +173,13 @@ const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 * @param value - the program's completion value. * @param remainingOutputBytes - exact bytes left after captured logs. * @param maxOutputBytes - the configured cap named in an overflow diagnostic. - * @returns the done-message fragment: `{}` for `undefined`, else a flat wire `{ value }`. + * @returns the result fragment: `{}` for `undefined`, else a flat wire `{ value }`. */ export function prepareCompletion( value: unknown, remainingOutputBytes: number, maxOutputBytes: number = remainingOutputBytes, -): Omit { +): ProgramResult { if (value === undefined) return {} let snapshot: ReturnType try { @@ -188,7 +202,7 @@ export function prepareCompletion( } /** Build the fixed overflow fragment without carrying rejected variable bytes. */ -function outputLimit(maxOutputBytes: number): Omit { +function outputLimit(maxOutputBytes: number): ProgramResult { return { error: { kind: 'output-limit', message: `outer output exceeded ${maxOutputBytes} bytes` } } } @@ -198,7 +212,7 @@ function prepareFailure( message: string, remainingOutputBytes: number, maxOutputBytes: number, -): Omit { +): ProgramResult { if (jsonStringBytesUpTo(message, remainingOutputBytes) === undefined) return outputLimit(maxOutputBytes) return { error: { kind, message } } } @@ -215,7 +229,7 @@ export function prepareException( error: unknown, remainingOutputBytes: number, maxOutputBytes: number = remainingOutputBytes, -): Omit { +): ProgramResult { let message: string try { const detail: unknown = error instanceof CapturedError ? error.stack ?? error.message : error @@ -226,12 +240,6 @@ export function prepareException( return prepareFailure('exception', message, remainingOutputBytes, maxOutputBytes) } -/** One awaited binding call's settlement handles, keyed by call id in the pending map. */ -export interface PendingCall { - resolve(value: unknown): void - reject(error: Error): void -} - /** Constructor type for one program-visible binding rejection class. */ export type BindingErrorConstructor = new (memberName: string, message: string) => Error @@ -273,48 +281,15 @@ export function makeBindingErrorClasses( } /** - * Route host replies into the pending-call map: each reply settles its call - * at most once, and a reply for an unknown id (stray, or a duplicate answer - * to an id already settled) is ignored. Shared wiring between - * {@link runProgram} and the tests that exercise {@link makeNamespaces} - * standalone. - * @param port - the port whose `message` events carry the replies. - * @param pending - the id-keyed map of unsettled binding calls. - */ -export function wireReplies(port: BootstrapPort, pending: Map): void { - port.on('message', (message: ReplyMessage) => { - const entry = pending.get(message.id) - if (!entry) return - pending.delete(message.id) - if (message.ok) { - const value = decodePtcJsonWire(message.value) - if (value === undefined) entry.reject(new CapturedError('binding resolution must be lossless JSON')) - else entry.resolve(value) - } else { - entry.reject(new CapturedError(message.message)) - } - }) -} - -/** - * Build the binding namespace objects the program sees: one null-prototype global per - * namespace, each declared name an own enumerable async function that bridges over the port - * (`__proto__`/`constructor`/`toString` are ordinary keys, never prototype collisions). - * Lossy arguments reject before posting; clone failures and host failure - * replies reject only the corresponding call. - * - * @param data - the boot payload's namespace declarations (globals + names). - * @param port - the port binding calls are posted to. - * @param pending - the id-keyed map each posted call parks its handles in. - * @param nextId - the shared mutable id counter (program-issued correlation ids). - * @param errorClasses - Namespace constructors shared with program globals, resolved for each invocation. - * @returns one namespace object per declaration, in declaration order. + * Build null-prototype binding globals with namespace-specific rejection classes. + * @param data - Namespace declarations injected into the program. + * @param host - Evaluation-scoped remote binding calls. + * @param errorClasses - Constructors shared with program globals, resolved per invocation. + * @returns One namespace object per declaration; arguments and results must be lossless JSON. */ export function makeNamespaces( data: Pick, - port: BootstrapPort, - pending: Map, - nextId: { value: number }, + host: Pick, errorClasses: Pick, 'get'> = makeBindingErrorClasses(data), ): Record[] { return data.namespaces.map(({ global, names }) => { @@ -322,34 +297,22 @@ export function makeNamespaces( for (const name of names) { Object.defineProperty(namespace, name, { enumerable: true, - value: (args: unknown): Promise => { + value: async (args: unknown): Promise => { const errorClass = errorClasses.get(global) - let detached: ReturnType try { - detached = snapshotPtcJsonValue(args) - } catch { - detached = undefined - } - if (detached === undefined) { - return Promise.reject(bindingFailure(errorClass, name, 'binding arguments must be lossless JSON')) - } - return new Promise((resolve, reject) => { - const id = nextId.value - pending.set(id, { - resolve, - reject: (error) => { - reject(bindingFailure(errorClass, name, error.message)) - }, - }) - try { - port.postMessage({ type: 'call', id, global, name, args: encodePtcJsonWire(detached) }) - nextId.value += 1 - } catch (error: unknown) { - pending.delete(id) - const message = `binding call could not be sent: ${error instanceof CapturedError ? error.message : String(error)}` - reject(bindingFailure(errorClass, name, message)) + let detached: ReturnType + try { detached = snapshotPtcJsonValue(args) } catch (error: unknown) { + // Model-defined getters can fail while arguments are detached. + void error + detached = undefined } - }) + if (detached === undefined) throw new CapturedError('binding arguments must be lossless JSON') + const result = decodePtcJsonWire(await host.invoke(global, name, encodePtcJsonWire(detached))) + if (result === undefined) throw new CapturedError('binding resolution must be lossless JSON') + return result + } catch (error: unknown) { + throw bindingFailure(errorClass, name, error instanceof CapturedError ? error.message : String(error)) + } }, }) } @@ -358,32 +321,27 @@ export function makeNamespaces( } /** - * Run one strict async-function body, allowing top-level `await` and `return`, and post exactly - * one terminal {@link DoneMessage}; a thrown program error becomes its `error` field. - * @param port - host message port or test double. + * Run one strict async-function body 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 after posting the done message. + * @returns Completion or a bounded program failure. */ export async function runProgram( - port: BootstrapPort, + host: ProgramHost, data: ProgramBootData, streams: { stdout: PatchableStream; stderr: PatchableStream }, -): Promise { +): Promise { const logs = new LogBuffer( data.maxOutputBytes, - (text) => { port.postMessage({ type: 'log', text }) }, - () => { port.postMessage({ type: 'output-limit' }) }, + (text) => { host.log(text) }, + () => { host.outputLimit() }, ) captureStreamWrites(logs, streams.stdout) captureStreamWrites(logs, streams.stderr) - const pending = new Map() - wireReplies(port, pending) - - const nextId = { value: 1 } const errorClasses = makeBindingErrorClasses(data) - const namespaces = makeNamespaces(data, port, pending, nextId, errorClasses) + const namespaces = makeNamespaces(data, host, errorClasses) const errorClassParameters: string[] = [] const errorClassValues: BindingErrorConstructor[] = [] for (const namespace of data.namespaces) { @@ -396,7 +354,6 @@ export async function runProgram( } const consoleShim = makeConsoleShim(logs) - let done: DoneMessage try { // The async function constructor, reached through an instance because // `AsyncFunction` is not a global. The program body is strict-mode. @@ -409,15 +366,8 @@ export async function runProgram( `'use strict';\n${data.code}`, ) const value = await fn(...namespaces, ...errorClassValues, consoleShim) - done = { - type: 'done', - ...prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes), - } + return prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes) } catch (error: unknown) { - done = { - type: 'done', - ...prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes), - } + return prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) } - port.postMessage(done) } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/channel.ts b/packages/ptc-runtime/ptc-runtime-node/src/channel.ts deleted file mode 100644 index 71e89e7570..0000000000 --- a/packages/ptc-runtime/ptc-runtime-node/src/channel.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** Length-prefixed JSON transport with bounded input and queued writes. */ -import { Buffer } from 'node:buffer' -import type { Duplex } from 'node:stream' -import type { PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' -import { jsonValueBytesUpTo } from './output-json.ts' - -const stringify = JSON.stringify -const parse = JSON.parse - -interface PendingWrite { - promise: Promise - finish(error?: Error): void -} - -/** One co-shipped process channel; the consumer owns frame validation and terminal outcomes. */ -export class JsonChannel { - private readonly stream: Duplex - private readonly maxBytes: number - private readonly receive: (message: unknown, bytes: number) => void - private readonly failure: (error: Error, kind: 'io' | 'protocol') => void - private readonly header = Buffer.alloc(4) - private headerBytes = 0 - private payload: Buffer | undefined - private payloadBytes = 0 - private queuedBytes = 0 - private closed = false - private readonly writes = new Set() - - constructor( - stream: Duplex, - maxBytes: number, - receive: (message: unknown, bytes: number) => void, - failure: (error: Error, kind: 'io' | 'protocol') => void, - ) { - this.stream = stream - this.maxBytes = maxBytes - this.receive = receive - this.failure = failure - stream.on('data', this.onData) - stream.on('error', this.onError) - stream.on('end', this.onEnd) - stream.on('close', this.onClose) - } - - private readonly onError = (error: Error): void => { - this.finishWrites(error) - if (!this.closed) this.failure(error, 'io') - } - private readonly onClose = (): void => { this.finishWrites(new Error('control channel closed during a write')) } - private readonly onEnd = (): void => { if (!this.closed) this.failure(new Error('control channel ended before the program settled'), 'io') } - private readonly onData = (chunk: Buffer): void => { - if (this.closed) return - try { - let offset = 0 - // receive can synchronously close the channel while dispatching a preceding frame. - // oxlint-disable-next-line typescript/no-unnecessary-condition - while (offset < chunk.length && !this.closed) { - if (this.payload === undefined) { - const bytes = Math.min(4 - this.headerBytes, chunk.length - offset) - chunk.copy(this.header, this.headerBytes, offset, offset + bytes) - this.headerBytes += bytes - offset += bytes - if (this.headerBytes !== 4) continue - const length = this.header.readUInt32BE(0) - if (length === 0 || length > this.maxBytes) throw new Error(`control frame exceeds ${this.maxBytes} bytes or is empty`) - this.payload = Buffer.allocUnsafe(length) - this.payloadBytes = 0 - this.headerBytes = 0 - } - const bytes = Math.min(this.payload.length - this.payloadBytes, chunk.length - offset) - chunk.copy(this.payload, this.payloadBytes, offset, offset + bytes) - this.payloadBytes += bytes - offset += bytes - if (this.payloadBytes !== this.payload.length) continue - const frame = this.payload - this.payload = undefined - this.payloadBytes = 0 - this.receive(parse(new TextDecoder('utf-8', { fatal: true }).decode(frame)), frame.length) - } - } catch (error: unknown) { - this.failure(error instanceof Error ? error : new Error(String(error)), 'protocol') - } - } - - /** - * Submit a bounded frame immediately and await the stream's write receipt. - * @param message - JSON-only co-shipped protocol value. - * @returns Resolves when this frame has been written, or rejects after transport failure. - */ - send(message: unknown): Promise { - if (this.closed) return Promise.reject(new Error('control channel is closed')) - const size = jsonValueBytesUpTo(message as PtcJsonValue, this.maxBytes) - if (size === undefined || this.queuedBytes + size > this.maxBytes) { - return Promise.reject(new Error(`control output exceeds ${this.maxBytes} queued bytes`)) - } - const body = Buffer.from(stringify(message), 'utf8') - const header = Buffer.alloc(4) - header.writeUInt32BE(body.length) - this.queuedBytes += body.length - const completion = Promise.withResolvers() - const write: PendingWrite = { - promise: completion.promise, - finish: (error) => { - if (!this.writes.delete(write)) return - this.queuedBytes -= body.length - if (error) completion.reject(error) - else completion.resolve() - }, - } - this.writes.add(write) - try { - // Writable preserves frame order; a Promise queue would delay logs behind a model hot loop. - this.stream.cork() - this.stream.write(header) - this.stream.write(body, (error) => { write.finish(error ?? undefined) }) - this.stream.uncork() - } catch (error: unknown) { - write.finish(error instanceof Error ? error : new Error(String(error))) - } - return completion.promise - } - - private finishWrites(error: Error): void { - for (const write of this.writes) write.finish(error) - } - - /** Stop reads and close the owned endpoint; pending writes reject on closure. */ - close(): void { - if (this.closed) return - this.closed = true - this.payload = undefined - this.finishWrites(new Error('control channel is closed')) - this.stream.off('data', this.onData) - this.stream.off('end', this.onEnd) - this.stream.destroy() - } - - /** Wait for accepted writes to finish or fail. */ - async drain(): Promise { await Promise.allSettled([...this.writes].map(write => write.promise)) } -} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/config.ts b/packages/ptc-runtime/ptc-runtime-node/src/config.ts index 75b1fe3ad8..18bebfe099 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/config.ts @@ -55,7 +55,7 @@ export function validateConfig(config: ResolvedConfig): void { if (config[key] > MAX_TIMER_DELAY_MS) throw new Error(`ptc-runtime-node: ${key} exceeds the supported timer range`) } if (!Number.isSafeInteger(config.maxOutputBytes) || config.maxOutputBytes < 4) throw new Error('ptc-runtime-node: maxOutputBytes must be an integer of at least 4') - if (!Number.isSafeInteger(config.maxMessageBytes) || config.maxMessageBytes > 0xffff_ffff) throw new Error('ptc-runtime-node: maxMessageBytes must fit an unsigned 32-bit frame length') + if (!Number.isSafeInteger(config.maxMessageBytes)) throw new Error('ptc-runtime-node: maxMessageBytes must be an integer') if (!Number.isSafeInteger(config.maxPendingCalls)) throw new Error('ptc-runtime-node: maxPendingCalls must be an integer') if (!Number.isSafeInteger(config.maxOldGenerationSizeMb)) throw new Error('ptc-runtime-node: maxOldGenerationSizeMb must be an integer') } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index 2d31e010c4..c2e342819c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -4,15 +4,17 @@ import { stripTypeScriptTypes } from 'node:module' import { isAbsolute } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' +import type { PtcBindingFunction, PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' import { clampTimeout } from '@deepseek-ai/dsh-timeout' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-sandbox-policy' import type {} from '@deepseek-ai/dsh-fs' -import { replyToBinding, validateBindings } from './bindings.ts' -import { JsonChannel } from './channel.ts' +import type { BirpcReturn } from 'birpc' +import { invokeBinding, validateBindings } from './bindings.ts' +import { createProcessRpc } from './rpc.ts' +import { readProgramResult } from './result.ts' import { bootstrapArgs, resolveLaunch } from './launch.ts' import { NodeConfig, validateConfig } from './config.ts' import { PolicyProcesses } from './policy-processes.ts' @@ -24,14 +26,13 @@ import { OutputLedger } from './output-ledger.ts' import { terminateProcess } from './output-stream.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' import { decodePtcJsonWire } from './json-wire.ts' -import type { ProgramBootData } from './protocol.ts' +import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' interface LiveRun { controller: AbortController; finished: Promise } const STRIP_PREFIX = 'async function __dsh_program__() {\n' const STRIP_SUFFIX = '\n}' function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) } -function record(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } /** Node execution under file policy, with optional owner-scoped retained memory. */ export class NodePtcRuntime extends PtcRuntime { @@ -135,14 +136,14 @@ export class NodePtcRuntime extends PtcRuntime { bindings: Map, controller: AbortController, ): Promise { - const replyBinding = AsyncLocalStorage.bind(replyToBinding) + const callBinding = AsyncLocalStorage.bind(invokeBinding) const output = new OutputLedger(this.config.maxOutputBytes) const logs: string[] = [] const sandbox: PtcRunSandbox = { mode: policy.mode, denied: false } const result = Promise.withResolvers() const signal = spec.signal === undefined ? controller.signal : AbortSignal.any([spec.signal, controller.signal]) let handle: SubprocessHandle | undefined - let channel: JsonChannel | undefined + let channel: BirpcReturn | undefined let confined: ConfinedArgv | undefined let settled = false let timedOut = false @@ -167,7 +168,7 @@ export class NodePtcRuntime extends PtcRuntime { settled = true clearTimeout(wallTimer) signal.removeEventListener('abort', onAbort) - channel?.close() + channel?.$close() void (async () => { try { const drained = await stop() @@ -263,7 +264,6 @@ export class NodePtcRuntime extends PtcRuntime { }) handle.stderr?.on('error', (error: Error) => { finish({ kind: 'worker-exit', message: messageOf(error) }) }) let ready = false - let nextId = 1 let pending = 0 let pendingBytes = 0 const protocolFailure = (message: string): void => { finish({ kind: 'protocol', message }) } @@ -273,59 +273,53 @@ export class NodePtcRuntime extends PtcRuntime { ? { kind: 'sandbox-unavailable', message: stderr } : { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` }) } - const transport: JsonChannel = new JsonChannel(launched.control, this.config.maxMessageBytes, (raw, bytes) => { - if (!record(raw)) { protocolFailure('invalid control frame'); return } - if (!ready) { - if (raw.type !== 'ready') { protocolFailure('program frame arrived before bootstrap readiness'); return } + const transport = createProcessRpc(launched.control, this.config.maxMessageBytes, { + ready: () => { + if (ready) { protocolFailure('duplicate program readiness'); return } ready = true - void transport.send({ type: 'boot', data }).catch((error: unknown) => { protocolFailure(messageOf(error)) }) - return - } - switch (raw.type) { - case 'log': - if (typeof raw.text !== 'string') { protocolFailure('invalid log frame'); return } - admit(raw.text) - return - case 'output-limit': outputOverflow = true; finish(); return - case 'done': { - if (raw.error !== undefined) { - if (!record(raw.error) || typeof raw.error.message !== 'string' || (raw.error.kind !== 'exception' && raw.error.kind !== 'invalid-output' && raw.error.kind !== 'output-limit')) { protocolFailure('invalid terminal error'); return } - const failure = { kind: raw.error.kind, message: raw.error.message } as PtcRunFailure - if (confined !== undefined) { - sandbox.denied = confined.denialSignatures.some(signature => - failure.message.toLowerCase().includes(signature.toLowerCase())) - } - if (failure.kind === 'output-limit') outputOverflow = true - finish(failure) - return + void transport.run(1, spec.cwd, data).then((raw) => { + // A message delivered beside the result can select another outcome first. + if (settled) return + const { error, value } = readProgramResult(raw) + if (error !== undefined) { + if (confined !== undefined) sandbox.denied = confined.denialSignatures.some(signature => + error.message.toLowerCase().includes(signature.toLowerCase())) + if (error.kind === 'output-limit') outputOverflow = true } - const value = raw.value === undefined ? undefined : decodePtcJsonWire(raw.value) - if (raw.value !== undefined && value === undefined) finish({ kind: 'invalid-output', message: 'program completion must be lossless JSON' }) - else finish(undefined, value) - return - } - case 'call': { - if (!Number.isSafeInteger(raw.id) || raw.id !== nextId || typeof raw.global !== 'string' || typeof raw.name !== 'string') { protocolFailure('invalid binding call identity'); return } - nextId += 1 - const functions = bindings.get(raw.global)?.functions - const fn = functions !== undefined && Object.hasOwn(functions, raw.name) ? functions[raw.name] : undefined - if (typeof fn !== 'function') { protocolFailure('program requested an undeclared binding'); return } - const args = decodePtcJsonWire(raw.args) - if (args === undefined) { protocolFailure('binding arguments must be lossless JSON'); return } - if (++pending > this.config.maxPendingCalls || (pendingBytes += bytes) > this.config.maxMessageBytes) { protocolFailure('pending binding calls exceed configured limits'); return } - const id = raw.id - void (async () => { - const reply = await replyBinding(id, fn, args).finally(() => { - pending -= 1 - pendingBytes -= bytes - }) - if (!settled) await transport.send(reply) - })().catch((error: unknown) => { protocolFailure(messageOf(error)) }) - return - } - default: protocolFailure('unknown control message') - } - }, (error, kind) => { + finish(error, value) + }).catch((error: unknown) => { protocolFailure(messageOf(error)) }) + }, + log: (evaluation, text) => { + if (!ready || evaluation !== 1 || typeof text !== 'string') protocolFailure('invalid program log') + else admit(text) + }, + outputLimit: (evaluation) => { + if (!ready || evaluation !== 1) { protocolFailure('invalid program output limit'); return } + outputOverflow = true + finish() + }, + invoke: async (evaluation: unknown, global: unknown, name: unknown, wire: unknown) => { + let fn: PtcBindingFunction | undefined + let args: PtcJsonValue | undefined + let bytes = 0 + try { + if (!ready || evaluation !== 1) throw new Error('call does not belong to the active PTC evaluation') + if (typeof global !== 'string' || typeof name !== 'string') throw new Error('invalid binding call identity') + const functions = bindings.get(global)?.functions + fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined + if (typeof fn !== 'function') throw new Error('program requested an undeclared binding') + args = decodePtcJsonWire(wire) + if (args === undefined) throw new Error('binding arguments must be lossless JSON') + bytes = Buffer.byteLength(JSON.stringify(wire)) + if (pending >= this.config.maxPendingCalls || pendingBytes + bytes > this.config.maxMessageBytes) { + throw new Error('pending binding calls exceed configured limits') + } + } catch (error: unknown) { protocolFailure(messageOf(error)); throw error } + pending += 1 + pendingBytes += bytes + try { return await callBinding(fn, args) } finally { pending -= 1; pendingBytes -= bytes } + }, + }, [], (error, kind) => { if (kind === 'protocol') protocolFailure(messageOf(error)) else if (ready) finish({ kind: 'worker-exit', message: messageOf(error) }) else void launched.done.then(processFinished, (failure: unknown) => { finish({ kind: 'worker-exit', message: messageOf(failure) }) }) 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 27de2e9d1d..e7c67260af 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -2,10 +2,10 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { start } from 'node:repl' import { PassThrough, Writable, type Duplex } from 'node:stream' -import { JsonChannel } from './channel.ts' -import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException, wireReplies } from './bootstrap.ts' -import type { BindingErrorConstructor, PendingCall, PatchableStream } from './bootstrap.ts' -import type { DoneMessage, ProgramBootData, ProgramToHost, ReplyMessage } from './protocol.ts' +import { createProcessRpc } from './rpc.ts' +import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException } from './bootstrap.ts' +import type { BindingErrorConstructor, PatchableStream, ProgramHost } from './bootstrap.ts' +import type { ProgramResult, HostMethods, ProgramMethods } from './protocol.ts' import { clearProgramEnvironment } from './environment.ts' /** Process state owned by the REPL child; tests supply instance-local streams and environment. */ @@ -21,26 +21,23 @@ interface Evaluation { logs: LogBuffer maxOutputBytes: number active: boolean - finish(result: Omit): void + finish(result: ProgramResult): void } /** * Serve sequential evaluations until the owning host closes the control channel. * @param stream - Inherited private control endpoint. - * @param maxMessageBytes - Maximum control frame and queued output bytes. + * @param maxMessageBytes - Maximum control message and queued output bytes. * @param processState - Child environment, output and directory control, bound only in the REPL context. * @returns After the channel closes and the REPL releases its streams. */ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, processState: PersistentProcess): Promise { - if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit') + if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) const context = new AsyncLocalStorage() const closed = Promise.withResolvers() - const pending = new Map() - const nextId = { value: 1 } const errors = new Map() const errorLookup = { get: (name: string) => errors.get(name)?.constructor } - const replies: Array<(message: ReplyMessage) => void> = [] const input = new PassThrough() const output = new Writable({ write(chunk: Buffer, _encoding, callback) { const current = context.getStore() @@ -54,83 +51,79 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, Object.defineProperty(repl.context, name, { configurable: true, enumerable: true, writable: true, value: undefined }) } let current: Evaluation | undefined - const send = (id: number, payload: ProgramToHost): void => { - void channel.send({ id, payload }).catch(() => { channel.close(); closed.resolve() }) - } - const port = { - postMessage(payload: ProgramToHost): void { + const host: Pick = { + invoke: (global, name, args) => { const evaluation = context.getStore() if (!evaluation?.active) throw new Error('PTC evaluation has ended; call this function from a new run_code evaluation') - send(evaluation.id, payload) + return connection.invoke(evaluation.id, global, name, args) }, - on(_event: 'message', listener: (message: ReplyMessage) => void): void { replies.push(listener) }, } - wireReplies(port, pending) const logSink = { push(text: string): void { const evaluation = context.getStore(); if (evaluation?.active) evaluation.logs.push(text) } } repl.context.console = makeConsoleShim(logSink) Object.defineProperty(repl.context, 'process', { configurable: true, enumerable: true, writable: true, value: processState }) captureStreamWrites(logSink, processState.stdout) captureStreamWrites(logSink, processState.stderr) - const channel = new JsonChannel(stream, maxMessageBytes, (raw) => { - // The host owns these frames. Model-written traffic is validated on the host. - const message = raw as { type: 'run'; id: number; cwd: string; data: ProgramBootData } | ReplyMessage - if (message.type === 'reply') { - for (const listener of replies) listener(message) - return - } - if (current?.active) { channel.close(); closed.resolve(); return } - const { id, data } = message - const logs = new LogBuffer(data.maxOutputBytes, (text) => { send(id, { type: 'log', text }) }, () => { send(id, { type: 'output-limit' }) }) - const evaluation: Evaluation = { - id, logs, maxOutputBytes: data.maxOutputBytes, active: true, - finish(result) { - if (!evaluation.active) return - evaluation.active = false - for (const call of pending.values()) call.reject(new Error('PTC evaluation ended before the binding completed')) - pending.clear() - send(id, { type: 'done', ...result }) - }, - } - current = evaluation - context.run(evaluation, () => { - try { - processState.chdir(message.cwd) - for (const namespace of data.namespaces) { - const descriptor = namespace.errorClass - if (!descriptor) { errors.delete(namespace.global); continue } - const previous = errors.get(namespace.global) - if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue - const constructor = makeBindingErrorClass(descriptor) - errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor }) - } - const namespaces = makeNamespaces(data, port, pending, nextId, errorLookup) - for (const [index, namespace] of data.namespaces.entries()) { - Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] }) - if (namespace.errorClass) { - Object.defineProperty(repl.context, namespace.errorClass.name, { configurable: true, value: errorLookup.get(namespace.global) }) - } - } - // Node's REPL treats a leading brace as an object expression before transforming await. - const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code - repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => { - evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) - : prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)) - }) - } catch (error: unknown) { - evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)) + const connection = createProcessRpc(stream, maxMessageBytes, { + async run(id, cwd, data) { + if (current?.active) throw new Error('persistent PTC already has an active evaluation') + const completion = Promise.withResolvers() + const logs = new LogBuffer(data.maxOutputBytes, + (text) => { void connection.log(id, text) }, + () => { void connection.outputLimit(id) }) + const evaluation: Evaluation = { + id, logs, maxOutputBytes: data.maxOutputBytes, active: true, + finish(result) { + if (!evaluation.active) return + evaluation.active = false + void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) }) + completion.resolve(result) + }, } - }) - }, () => { channel.close(); closed.resolve() }) - stream.once('close', () => { closed.resolve() }) + current = evaluation + context.run(evaluation, () => { + try { + processState.chdir(cwd) + for (const namespace of data.namespaces) { + const descriptor = namespace.errorClass + if (!descriptor) { errors.delete(namespace.global); continue } + const previous = errors.get(namespace.global) + if (previous?.name === descriptor.name && previous.memberNameProperty === descriptor.memberNameProperty) continue + const constructor = makeBindingErrorClass(descriptor) + errors.set(namespace.global, { name: descriptor.name, memberNameProperty: descriptor.memberNameProperty, constructor }) + } + const namespaces = makeNamespaces(data, host, errorLookup) + for (const [index, namespace] of data.namespaces.entries()) { + Object.defineProperty(repl.context, namespace.global, { configurable: true, value: namespaces[index] }) + if (namespace.errorClass) { + Object.defineProperty(repl.context, namespace.errorClass.name, { + configurable: true, value: errorLookup.get(namespace.global), + }) + } + } + // Node's REPL treats a leading brace as an object expression before transforming await. + const program = data.code.trimStart().startsWith('{') ? `;${data.code}` : data.code + repl.eval(`${program}\n`, repl.context, 'ptc-repl', (error: Error | null, value: unknown) => { + evaluation.finish(error ? prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes) + : prepareCompletion(value, logs.remainingOutputBytes(), data.maxOutputBytes)) + }) + } catch (error: unknown) { + evaluation.finish(prepareException(error, logs.remainingOutputBytes(), data.maxOutputBytes)) + } + }) + return await completion.promise + }, + }, ['ready', 'log', 'outputLimit'], () => { + // An inactive evaluation sends nothing more over the closed endpoint. + if (current) current.active = false + closed.resolve() + }) try { - await channel.send({ type: 'ready' }) + await connection.ready() await closed.promise } finally { - if (current) current.active = false repl.close() input.destroy() output.destroy() - channel.close() context.disable() } } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index a1fb0c5fc5..e2c205b94a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -8,19 +8,20 @@ import { clampTimeout } from '@deepseek-ai/dsh-timeout' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' +import type { BirpcReturn } from 'birpc' import type { PolicyProcesses } from './policy-processes.ts' import type { ResolvedConfig } from './config.ts' -import { replyToBinding, validateBindings } from './bindings.ts' -import { JsonChannel } from './channel.ts' +import { invokeBinding, validateBindings } from './bindings.ts' +import { createProcessRpc } from './rpc.ts' +import { readProgramResult } from './result.ts' import { bootstrapArgs } from './launch.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' -import { decodePtcJsonWire } from './json-wire.ts' +import { decodePtcJsonWire, type PtcJsonWire } from './json-wire.ts' import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' -import type { ProgramBootData } from './protocol.ts' +import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) -const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) function persistentOutput(maxBytes: number): OutputLedger { return new OutputLedger(maxBytes, `retained variables were cleared; outer output exceeded ${maxBytes} bytes`) @@ -44,7 +45,7 @@ interface Evaluation { data: ProgramBootData spec: PtcRunSpec bindings: Map - reply: typeof replyToBinding + invoke: typeof invokeBinding pending: number pendingBytes: number admit(text: string): void @@ -56,14 +57,13 @@ class Kernel { private readonly unregister: (() => void) | undefined private confined: ConfinedArgv | undefined private handle?: SubprocessHandle - private channel?: JsonChannel + private channel?: BirpcReturn private active: Evaluation | undefined /** Admission remains reserved until the result includes completed process cleanup. */ private running = false private cleanupPending = false private ready = false private sequence = 0 - private nextCall = 1 private stderr = '' private stopped: Promise | undefined private starting?: Promise @@ -121,7 +121,7 @@ class Kernel { data: { code, maxOutputBytes: this.config.maxOutputBytes, namespaces: [...bindings.values()].map(binding => ({ global: binding.global, names: Object.keys(binding.functions), ...binding.errorClass ? { errorClass: binding.errorClass } : {}, })) }, - spec, bindings, reply: AsyncLocalStorage.bind(replyToBinding), pending: 0, pendingBytes: 0, + spec, bindings, invoke: AsyncLocalStorage.bind(invokeBinding), pending: 0, pendingBytes: 0, admit: (text) => { if (!output.admit(text, logs)) { logs.push(text) @@ -164,7 +164,7 @@ class Kernel { this.closed = true this.cleanupPending = true this.controller.abort() - this.channel?.close() + this.channel?.$close() this.stopped = (async () => { await this.starting?.catch(() => {}) const handle = this.handle @@ -187,8 +187,11 @@ class Kernel { } private dispatch(evaluation: Evaluation): void { - void this.channel?.send({ type: 'run', id: evaluation.id, cwd: evaluation.spec.cwd, data: evaluation.data }) - .catch((error: unknown) => { this.fail('protocol', messageOf(error)) }) + void this.channel?.run(evaluation.id, evaluation.spec.cwd, evaluation.data).then((raw) => { + const { error, value } = readProgramResult(raw) + // Raw stdio can be queued beside the RPC result on separate pipes. + setImmediate(() => { evaluation.finish(error, value) }) + }).catch((error: unknown) => { if (this.active === evaluation && !this.channel?.$closed) this.fail('protocol', messageOf(error)) }) } private exitMessage(outcome: SubprocessOutcome): string { @@ -217,11 +220,27 @@ class Kernel { }) stream.on('error', (error: Error) => { this.fail('worker-exit', error.message) }) } - this.channel = new JsonChannel(handle.control, this.config.maxMessageBytes, - (raw, bytes) => { this.receive(raw, bytes) }, (error, kind) => { - if (kind === 'protocol') this.fail('protocol', error.message) - else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() }) - }) + this.channel = createProcessRpc(handle.control, this.config.maxMessageBytes, { + ready: () => { + if (this.ready || !this.active) { this.fail('protocol', 'unexpected persistent PTC readiness'); return } + this.ready = true + this.dispatch(this.active) + }, + log: (id, text) => { + const evaluation = this.evaluation(id) + if (evaluation === undefined || typeof text !== 'string') this.fail('protocol', 'invalid persistent PTC log') + else evaluation.admit(text) + }, + outputLimit: (id) => { + const evaluation = this.evaluation(id) + if (evaluation === undefined) this.fail('protocol', 'invalid persistent PTC output limit') + else evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true) + }, + invoke: (id, global, name, wire) => this.invoke(id, global, name, wire), + }, [], (error, kind) => { + if (kind === 'protocol') this.fail('protocol', error.message) + else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() }) + }) void handle.done.then(async (outcome) => { this.exitOutcome = outcome await Promise.all([drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs)]) @@ -232,62 +251,32 @@ class Kernel { }, (error: unknown) => { this.fail('worker-exit', messageOf(error)) }) } - private receive(raw: unknown, bytes: number): void { - if (!record(raw)) { this.fail('protocol', 'invalid persistent PTC frame'); return } - const evaluation = this.active - if (!evaluation) { this.fail('protocol', 'frame arrived without an active PTC evaluation'); return } - if (!this.ready) { - if (raw.type !== 'ready') { this.fail('protocol', 'expected persistent PTC readiness'); return } - this.ready = true - this.dispatch(evaluation) - return - } - if (raw.id !== evaluation.id || !record(raw.payload)) { this.fail('protocol', 'frame does not belong to the active PTC evaluation'); return } - const payload = raw.payload - switch (payload.type) { - case 'log': - if (typeof payload.text === 'string') evaluation.admit(payload.text) - else this.fail('protocol', 'invalid persistent PTC log') - return - case 'output-limit': evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true); return - case 'done': { - if (payload.error !== undefined) { - const error = payload.error - if (!record(error) || typeof error.message !== 'string' || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { - this.fail('protocol', 'invalid persistent PTC failure'); return - } - const failure = { kind: error.kind, message: error.message } as PtcRunFailure - setImmediate(() => { evaluation.finish(failure, undefined, failure.kind === 'output-limit') }) - } else { - const value = payload.value === undefined ? undefined : decodePtcJsonWire(payload.value) - const failure: PtcRunFailure | undefined = payload.value !== undefined && value === undefined - ? { kind: 'invalid-output', message: 'program completion must be lossless JSON' } : undefined - // Raw stdio may be queued beside the terminal control frame on separate pipes. - setImmediate(() => { evaluation.finish(failure, value) }) - } - return + private evaluation(id: unknown): Evaluation | undefined { + return this.ready && id === this.active?.id ? this.active : undefined + } + + private async invoke(id: unknown, global: unknown, name: unknown, wire: unknown): Promise { + let evaluation: Evaluation | undefined + let args: PtcJsonValue | undefined + let bytes = 0 + try { + evaluation = this.evaluation(id) + if (evaluation === undefined) throw new Error('call does not belong to the active PTC evaluation') + if (typeof global !== 'string' || typeof name !== 'string') throw new Error('invalid persistent PTC binding identity') + args = decodePtcJsonWire(wire) + if (args === undefined) throw new Error('binding arguments must be lossless JSON') + bytes = Buffer.byteLength(JSON.stringify(wire)) + if (evaluation.pending >= this.config.maxPendingCalls || evaluation.pendingBytes + bytes > this.config.maxMessageBytes) { + throw new Error('pending binding calls exceed configured limits') } - case 'call': { - if (payload.id !== this.nextCall || typeof payload.global !== 'string' || typeof payload.name !== 'string') { this.fail('protocol', 'invalid persistent PTC binding identity'); return } - this.nextCall += 1 - const args = decodePtcJsonWire(payload.args) - if (args === undefined) { this.fail('protocol', 'binding arguments must be lossless JSON'); return } - if (++evaluation.pending > this.config.maxPendingCalls || (evaluation.pendingBytes += bytes) > this.config.maxMessageBytes) { - this.fail('protocol', 'pending binding calls exceed configured limits'); return - } - const functions = evaluation.bindings.get(payload.global)?.functions - const fn = functions && Object.hasOwn(functions, payload.name) ? functions[payload.name] : undefined - const id = payload.id - void (async () => { - const reply = await evaluation.reply(id, fn, args).finally(() => { - evaluation.pending -= 1 - evaluation.pendingBytes -= bytes - }) - if (this.active === evaluation) await this.channel?.send(reply) - })().catch((error: unknown) => { this.fail('protocol', messageOf(error)) }) - return - } - default: this.fail('protocol', 'unknown persistent PTC message') + } catch (error: unknown) { this.fail('protocol', messageOf(error)); throw error } + evaluation.pending += 1 + evaluation.pendingBytes += bytes + const functions = evaluation.bindings.get(global)?.functions + const fn = functions && Object.hasOwn(functions, name) ? functions[name] : undefined + try { return await evaluation.invoke(fn, args) } finally { + evaluation.pending -= 1 + evaluation.pendingBytes -= bytes } } } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 928931e6c0..727d38f1da 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -1,10 +1,10 @@ -/** Node child execution over an inherited control channel; no Harness services run here. */ +/** Node child execution through typed host and program RPC methods. */ import type { Duplex } from 'node:stream' -import { JsonChannel } from './channel.ts' +import { createProcessRpc } from './rpc.ts' import { runProgram } from './bootstrap.ts' import { clearProgramEnvironment } from './environment.ts' import type { PatchableStream } from './bootstrap.ts' -import type { ReplyMessage, ProgramBootData, ProgramToHost } from './protocol.ts' +import type { HostMethods, ProgramMethods } from './protocol.ts' /** Process-owned environment and output streams consumed by the child bootstrap. */ export interface ProgramProcess { @@ -15,72 +15,37 @@ export interface ProgramProcess { } /** - * Run one host-supplied program after the control handshake. + * Serve one run and retain the endpoint until the host receives its result and closes it. * @param stream - Inherited, already-adopted control endpoint. - * @param maxMessageBytes - Host-validated maximum frame and queued-write bytes. + * @param maxMessageBytes - Host-validated maximum message and queued-write bytes. * @param processState - Environment, output streams and exit status of this Node child. - * @returns After control output flushes and host shutdown is observed, or transport failure closes the channel. + * @returns After host shutdown or a transport failure closes the endpoint. */ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, processState: ProgramProcess): Promise { - if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0 || maxMessageBytes > 0xffff_ffff) throw new Error('invalid control message limit') + if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) - const boot = Promise.withResolvers() - const listeners: Array<(message: ReplyMessage) => void> = [] - let started = false - let failed = false - let terminalSent = false const hostClosed = Promise.withResolvers() - const onClose = (): void => { hostClosed.resolve() } - stream.once('close', onClose) - const channel = new JsonChannel(stream, maxMessageBytes, (raw) => { - if (!started) { - started = true - if (typeof raw !== 'object' || raw === null || (raw as { type?: unknown }).type !== 'boot') { - boot.reject(new Error('expected program boot frame')) - return - } - boot.resolve((raw as { data: ProgramBootData }).data) - return - } - if (terminalSent) return - for (const listener of listeners) listener(raw as ReplyMessage) - }, (error, kind) => { - if (!terminalSent || kind === 'protocol') { - failed = true - boot.reject(error) - channel.close() - processState.exitCode = 1 - } + // Set once the result is returned or the endpoint closes; the child then sends nothing more. + let ended = false + const connection = createProcessRpc(stream, maxMessageBytes, { + async run(evaluation, _cwd, data) { + const result = await runProgram({ + invoke: (global, name, args) => { + if (ended) throw new Error('PTC evaluation has ended') + return connection.invoke(evaluation, global, name, args) + }, + log: (text) => { if (!ended) void connection.log(evaluation, text) }, + outputLimit: () => { if (!ended) void connection.outputLimit(evaluation) }, + }, data, processState) + ended = true + void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) }) + return result + }, + }, ['ready', 'log', 'outputLimit'], (_error, kind) => { + if (!ended || kind === 'protocol') processState.exitCode = 1 + ended = true hostClosed.resolve() }) - const pending = new Set>() - const send = (message: ProgramToHost): void => { - if (terminalSent) return - if (message.type === 'done') terminalSent = true - const task = channel.send(message).catch(() => { - failed = true - channel.close() - processState.exitCode = 1 - hostClosed.resolve() - }).finally(() => { pending.delete(task) }) - pending.add(task) - } - try { - await channel.send({ type: 'ready' }) - const data = await boot.promise - await runProgram({ - postMessage: send, - on: (_event, listener) => { listeners.push(listener) }, - }, data, { stdout: processState.stdout, stderr: processState.stderr }) - while (pending.size > 0) await Promise.all(pending) - await channel.drain() - // Host binding replies can race the terminal frame; the host owns channel shutdown. - await hostClosed.promise - } finally { - stream.off('close', onClose) - channel.close() - // Transport callbacks can set failed while the awaited program executes. - // oxlint-disable-next-line typescript/no-unnecessary-condition - if (failed) processState.exitCode = 1 - } + await connection.ready() + await hostClosed.promise } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts index 01cd1f5bd5..f20f74afe2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts @@ -1,67 +1,60 @@ -/** - * JSON message vocabulary carried by the bounded process channel. The host validates every - * message because model code can write directly to the inherited descriptor. - * @module @deepseek-ai/dsh-ptc-runtime-node/src/protocol - */ - +/** Typed methods shared by the host and its co-shipped Node execution process. */ import type { PtcJsonWire } from './json-wire.ts' -/** What the host hands the program at spawn, after the readiness handshake. */ +/** Inputs for one program; host functions remain in the parent process. */ export interface ProgramBootData { - /** The type-stripped (plain JS) program body. */ + /** Type-stripped JavaScript source. */ code: string - /** Binding namespaces to materialize; functions themselves stay host-side. */ + /** Globals and callable names injected into the program. */ namespaces: { global: string names: string[] errorClass?: { name: string; memberNameProperty: string } }[] - /** Hard cap for the combined serialized outer logs plus completion value or failure diagnostic. */ + /** Combined serialized log and completion budget. */ maxOutputBytes: number } -/** Program → host: one bridged binding call. */ -interface CallMessage { - type: 'call' - /** Program-issued correlation id; the host answers each id at most once and ignores duplicates. */ - id: number - /** The namespace global the call targets. */ - global: string - /** The function name within the namespace. */ - name: string - /** The single argument as a flat lossless-JSON wire value. */ - args: PtcJsonWire -} - -/** Program → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */ -interface LogMessage { - type: 'log' - text: string -} - -/** Program → host: program-side capture or completion measurement exceeded the outer cap. */ -interface OutputLimitMessage { - type: 'output-limit' -} - -/** - * Program → host: the program settled. `error` carries a program exception, - * invalid completion, or output overflow (budgets, aborts, and substrate death - * are observed host-side). `value` is present only on a clean completion that - * produced one, as a flat wire value already lossless and admitted against - * the remaining combined output cap. Logs are NOT carried here — they streamed - * eagerly as {@link LogMessage}s. - */ -export interface DoneMessage { - type: 'done' +/** Program completion; logs are delivered separately before this result. */ +export interface ProgramResult { value?: PtcJsonWire error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } } -/** Every message the program sends. */ -export type ProgramToHost = CallMessage | LogMessage | OutputLimitMessage | DoneMessage +/** Host operations available only during the identified evaluation. */ +export interface HostMethods { + /** Announce that the process can accept its first evaluation. */ + ready(): void + /** + * Invoke a binding under its originating host execution context. + * @param evaluation - Active evaluation generation, independent of RPC correlation. + * @param global - Namespace global. + * @param name - Function name within the namespace. + * @param args - Flat lossless JSON arguments. + * @returns Flat lossless JSON result; binding failures reject the call. + */ + invoke(evaluation: number, global: string, name: string, args: PtcJsonWire): Promise + /** + * Admit captured output before completion or interruption. + * @param evaluation - Active evaluation generation. + * @param text - Captured text. + */ + log(evaluation: number, text: string): void + /** + * Report exhausted program-side output accounting. + * @param evaluation - Active evaluation generation. + */ + outputLimit(evaluation: number): void +} -/** Host → program: the answer to one {@link CallMessage}. */ -export type ReplyMessage = - | { type: 'reply'; id: number; ok: true; value: PtcJsonWire } - | { type: 'reply'; id: number; ok: false; message: string } +/** Operations implemented by either the per-call child or retained REPL. */ +export interface ProgramMethods { + /** + * Evaluate code with the supplied binding declarations and output budget. + * @param evaluation - Generation carried by all callbacks during this evaluation. + * @param cwd - Execution-world working directory. + * @param data - Program and injected declarations. + * @returns Completion or a bounded program failure, after captured output is sent. + */ + run(evaluation: number, cwd: string, data: ProgramBootData): Promise +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/result.ts b/packages/ptc-runtime/ptc-runtime-node/src/result.ts new file mode 100644 index 0000000000..3377b3f492 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/result.ts @@ -0,0 +1,25 @@ +/** Validate program-controlled completions received through birpc. */ +import type { PtcJsonValue, PtcRunFailure } from '@deepseek-ai/dsh-ptc-runtime' +import { decodePtcJsonWire } from './json-wire.ts' + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) + +/** + * Decode a remote run result without trusting model-written control traffic. + * @param result - Untrusted result returned by the program process. + * @returns Decoded completion or failure; malformed envelopes throw a protocol error. + */ +export function readProgramResult(result: unknown): { value?: PtcJsonValue; error?: PtcRunFailure } { + if (!record(result)) throw new Error('invalid program result') + if (result.error !== undefined) { + const error = result.error + if (!record(error) || typeof error.message !== 'string' + || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { + throw new Error('invalid terminal error') + } + return { error: { kind: error.kind, message: error.message } } + } + if (result.value === undefined) return {} + const value = decodePtcJsonWire(result.value) + return value === undefined ? { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } } : { value } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts new file mode 100644 index 0000000000..b21e225e3c --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts @@ -0,0 +1,100 @@ +/** birpc over newline-delimited JSON on the bounded private process channel. */ +import { Buffer } from 'node:buffer' +import type { Duplex } from 'node:stream' +import type { PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' +import { createBirpc, type BirpcReturn } from 'birpc' +import { jsonValueBytesUpTo } from './output-json.ts' + +const stringify = JSON.stringify +const parse = JSON.parse + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) +const toError = (value: unknown): Error => value instanceof Error ? value : new Error(String(value)) + +/** + * Connect typed methods over one control endpoint; execution owns all deadlines. + * @param stream - Owned control endpoint; `$close()` destroys it and rejects outstanding remote calls. + * @param maxMessageBytes - Maximum incoming JSON line and queued outgoing bytes. + * @param functions - Only these own methods may be called by the peer. + * @param eventNames - Remote methods sent as notifications without a response. + * @param failure - Receives the first I/O or malformed-message failure after it closes the endpoint. + * @returns Typed remote calls and birpc lifetime controls. + */ +export function createProcessRpc( + stream: Duplex, + maxMessageBytes: number, + functions: Local, + eventNames: (keyof Remote)[], + failure: (error: Error, kind: 'io' | 'protocol') => void, +): BirpcReturn { + const fail = (error: unknown, kind: 'io' | 'protocol'): void => { + if (rpc.$closed) return + rpc.$close() + failure(toError(error), kind) + } + let receive: ((line: string) => void) | undefined + const rpc = createBirpc(functions, { + timeout: -1, + eventNames, + on: (listener) => { receive = listener }, + off: () => { receive = undefined; stream.destroy() }, + serialize: (message: Record) => { + // JSON drops Error fields; the peer receives only the diagnostic. + const encoded = message.e instanceof Error ? { t: 's', i: message.i, e: { message: message.e.message } } : message + // Measure before materializing the line; the Writable owns already queued bytes. + if (jsonValueBytesUpTo(encoded as PtcJsonValue, maxMessageBytes - stream.writableLength - 1) !== undefined) return `${stringify(encoded)}\n` + fail(new Error(`control output exceeds ${maxMessageBytes} queued bytes`), 'protocol') + return '' + }, + // A binding can settle after the endpoint closes. + post: (line: string) => { if (!rpc.$closed) stream.write(line) }, + deserialize: (line: string) => { + const message: unknown = parse(line) + if (!record(message)) throw new Error('invalid RPC frame') + if (message.t === 'q') { + if (typeof message.m !== 'string' || !Object.hasOwn(functions, message.m) || !Array.isArray(message.a)) throw new Error('invalid RPC method call') + } else if (message.t !== 's') { + throw new Error('unknown RPC message') + } else if (message.e !== undefined) { + if (!record(message.e) || typeof message.e.message !== 'string') throw new Error('invalid RPC error') + return { ...message, e: new Error(message.e.message) } + } + return message + }, + // Every line of a chunk dispatches before an earlier handler can close the endpoint. + resolver: (_name, fn) => async (...args: unknown[]) => { + if (rpc.$closed) return undefined + // birpc treats a falsy thrown value as success. + try { return await fn.apply(functions, args) } catch (error: unknown) { throw toError(error) } + }, + }) + const decoder = new TextDecoder('utf-8', { fatal: true }) + let parts: Buffer[] = [] + let size = 0 + const buffer = (part: Buffer): void => { + size += part.length + if (size > maxMessageBytes) throw new Error(`RPC message exceeds ${maxMessageBytes} bytes`) + parts.push(part) + } + stream.on('data', (chunk: Buffer) => { + try { + let start = 0 + for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { + buffer(chunk.subarray(start, end)) + const line = decoder.decode(Buffer.concat(parts, size)) + parts = [] + size = 0 + start = end + 1 + void Promise.resolve(receive?.(line)).catch((error: unknown) => { fail(error, 'protocol') }) + } + buffer(chunk.subarray(start)) + } catch (error: unknown) { fail(error, 'protocol') } + }) + stream.on('end', () => { + if (size === 0) fail(new Error('control channel ended before the program settled'), 'io') + else fail(new Error('incomplete RPC message'), 'protocol') + }) + stream.on('error', (error: Error) => { fail(error, 'io') }) + stream.on('close', () => { fail(new Error('control channel closed'), 'io') }) + return rpc +} 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 bd8e333ef0..9e3cff5a3f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts @@ -1,71 +1,23 @@ -import { describe, expect, it } from 'vitest' -import { EventEmitter } from 'node:events' -import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram, wireReplies } from '../src/bootstrap.ts' -import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts' -import type { ReplyMessage, ProgramToHost } from '../src/protocol.ts' +import { describe, expect, it, vi } from 'vitest' +import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram } from '../src/bootstrap.ts' +import type { ProgramHost, PatchableStream } from '../src/bootstrap.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' -/** - * An in-process stand-in for the worker's parentPort: the test plays the - * HOST side — inspect what the bootstrap posted, feed replies back — so - * every line of worker-side logic runs under coverage without spawning an - * isolate (real-worker behavior is pinned by runtime.spec.ts). - */ -class FakePort implements BootstrapPort { - sent: ProgramToHost[] = [] - private readonly emitter = new EventEmitter() - /** Host-scripted responder; return undefined to leave the call pending. */ - respond: (message: ProgramToHost) => ReplyMessage | undefined = () => undefined - - postMessage(message: ProgramToHost): void { - this.sent.push(message) - const reply = this.respond(message) - if (reply) queueMicrotask(() => this.emitter.emit('message', reply)) - } - - on(event: 'message', listener: (message: ReplyMessage) => void): void { - this.emitter.on(event, listener) - } - - deliver(message: ReplyMessage): void { - this.emitter.emit('message', message) - } - - logs(): string[] { - return this.sent.filter(message => message.type === 'log').map(message => message.text) - } - - done(): ProgramToHost | undefined { - return this.sent.find(message => message.type === 'done') - } - - doneValue(): unknown { - const done = this.done() - return done?.type === 'done' && done.value !== undefined ? decodePtcJsonWire(done.value) : undefined +function host(): ProgramHost & { 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 fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } { return { stdout: { write: () => true }, stderr: { write: () => true } } } - -/** Capture one promise rejection without Vitest's intentionally `any` matcher channel. */ -async function rejectionOf(promise: Promise): Promise { - try { - await promise - return undefined - } catch (error: unknown) { - return error - } -} - const BOOT = { maxOutputBytes: 65_536 } const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const - -/** One worker declaration for the PTC mode tools namespace. */ -function toolNamespace(names: string[]) { - return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } -} +function toolNamespace(names: string[]) { return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } } describe('LogBuffer', () => { it('streams entries to the sink until the byte budget, then emits one fitting prefix and reports the limit once', () => { @@ -204,241 +156,98 @@ describe('prepareException', () => { }) describe('makeNamespaces', () => { - it('rejects a malformed success reply instead of resolving a lossy binding value', async () => { - const port = new FakePort() - const pending = new Map() - wireReplies(port, pending) - const result = new Promise((resolve, reject) => { pending.set(1, { resolve, reject }) }) - port.deliver({ type: 'reply', id: 1, ok: true, value: [undefined] as never }) - await expect(result).rejects.toThrow('binding resolution must be lossless JSON') - }) - - it('exposes prototype-colliding names as ordinary own properties', async () => { - const port = new FakePort() - port.respond = message => message.type === 'call' - ? { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(`${message.name}-ok`) } - : undefined - const pending = new Map() - wireReplies(port, pending) - const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record Promise>] + 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>] expect(Object.getPrototypeOf(tools)).toBeNull() - await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok') - await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok') - await expect(tools['toString']?.({})).resolves.toBe('toString-ok') + 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') }) - it('rejects a postMessage clone failure without leaking the pending entry', async () => { - let firstCall = true - const throwingPort: BootstrapPort = { - // First call throws an Error (the real DataCloneError shape), the - // second a bare string — the rejection renders both. - postMessage: () => { - if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') } - throw 'raw-clone-failure' - }, - on: () => {}, - } - const pending = new Map() + it('uses the declared constructor for remote and argument failures', async () => { + const remote = host() const data = { namespaces: [toolNamespace(['x'])] } - const errorClasses = makeBindingErrorClasses(data) - const ToolCallError = errorClasses.get('tools') - const [tools] = makeNamespaces( - data, - throwingPort, - pending, - { value: 1 }, - errorClasses, - ) as [Record Promise>] - const first = await rejectionOf(tools.x?.({ first: true }) ?? Promise.resolve()) - const second = await rejectionOf(tools.x?.({ second: true }) ?? Promise.resolve()) - expect(first).toMatchObject({ name: 'ToolCallError', toolName: 'x' }) - expect(second).toMatchObject({ name: 'ToolCallError', toolName: 'x' }) - expect(first).toBeInstanceOf(ToolCallError) - expect(second).toBeInstanceOf(ToolCallError) - expect((first as Error).message).toMatch(/DataCloneError-ish/) - expect((second as Error).message).toMatch(/raw-clone-failure/) - expect(pending.size).toBe(0) - }) - - it('rejects lossy arguments before posting or allocating a call id', async () => { - let posts = 0 - const port: BootstrapPort = { postMessage: () => { posts += 1 }, on: () => {} } - const pending = new Map() - const nextId = { value: 1 } - const [tools] = makeNamespaces( - { namespaces: [toolNamespace(['x'])] }, port, pending, nextId, - ) as [Record Promise>] + const classes = makeBindingErrorClasses(data) + const [tools] = makeNamespaces(data, remote, classes) as [Record Promise>] const decorated = [1] Object.defineProperty(decorated, 'extra', { value: true }) - const throwing = Object.defineProperty({}, 'value', { - enumerable: true, - get: () => { throw new Error('getter exploded') }, - }) - + const throwing = Object.defineProperty({}, 'value', { enumerable: true, get() { throw new Error('getter failed') } }) for (const value of [() => 1, new Date(), decorated, throwing]) { - const failure = await rejectionOf(tools.x?.(value) ?? Promise.resolve()) - expect(failure).toMatchObject({ - name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON', - }) + await expect(tools.x?.(value)).rejects.toMatchObject({ name: 'ToolCallError', toolName: 'x', message: 'binding arguments must be lossless JSON' }) + } + expect(vi.mocked(remote.invoke).mock.calls).toEqual([]) + for (const failure of [new Error('denied'), 'raw failure']) { + remote.invoke = async () => { throw failure } + const result = tools.x?.({}) + await expect(result).rejects.toBeInstanceOf(classes.get('tools')) + await expect(result).rejects.toMatchObject({ name: 'ToolCallError', toolName: 'x', message: failure instanceof Error ? failure.message : failure }) } - expect(posts).toBe(0) - expect(pending.size).toBe(0) - expect(nextId.value).toBe(1) }) - it('uses ordinary Error for non-tools namespace failures', async () => { - const deniedPort = new FakePort() - deniedPort.respond = message => message.type === 'call' - ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' } - : undefined - const deniedPending = new Map() - wireReplies(deniedPort, deniedPending) - const [helpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, deniedPort, deniedPending, { value: 1 }) as [Record Promise>] - const denied = await rejectionOf(helpers.x?.({}) ?? Promise.resolve()) - expect(denied).toBeInstanceOf(Error) - expect(denied).toMatchObject({ name: 'Error', message: 'helper denied' }) - expect(denied).not.toHaveProperty('toolName') - - const invalid = await rejectionOf(helpers.x?.(() => 1) ?? Promise.resolve()) - expect(invalid).toBeInstanceOf(Error) - expect((invalid as Error).message).toBe('binding arguments must be lossless JSON') - - const clonePort: BootstrapPort = { postMessage: () => { throw new Error('clone failed') }, on: () => {} } - const [cloneHelpers] = makeNamespaces({ namespaces: [{ global: 'helpers', names: ['x'] }] }, clonePort, new Map(), { value: 1 }) as [Record Promise>] - const cloneFailure = await rejectionOf(cloneHelpers.x?.({}) ?? Promise.resolve()) - expect(cloneFailure).toBeInstanceOf(Error) - expect(cloneFailure).not.toHaveProperty('toolName') + 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>] + 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('runs a program end-to-end: bindings, console, return value', async () => { - const port = new FakePort() - port.respond = (message) => { - if (message.type !== 'call') return undefined - const args = decodePtcJsonWire(message.args) as { n: number } - return { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(args.n * 2) } + 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) } - await runProgram(port, { + 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, 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, 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: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };', - namespaces: [{ global: 'tools', names: ['double'] }], + 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(port.logs()).toEqual(['got 42']) - expect(port.doneValue()).toEqual({ doubled: 42 }) + expect(decodePtcJsonWire(result.value)).toEqual({ caught: true, name, member: 'x', message: 'denied by host' }) }) - it('reports worker-side log capture overflow before completing', async () => { - const port = new FakePort() - await runProgram(port, { - maxOutputBytes: 4, - code: 'console.log("12345"); return null', - namespaces: [], - }, fakeStreams()) - expect(port.logs()).toEqual([]) - expect(port.sent).toContainEqual({ type: 'output-limit' }) - expect(port.done()).toEqual({ - type: 'done', - error: { kind: 'output-limit', message: 'outer output exceeded 4 bytes' }, - }) - }) - - it('reports a thrown program error on the done message', async () => { - const port = new FakePort() - await runProgram(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams()) - const done = port.done() - expect(done?.type).toBe('done') - expect(done?.type === 'done' ? done.error?.kind : undefined).toBe('exception') - expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom') - expect(done?.type === 'done' ? done.value : undefined).toBeUndefined() - }) - - it('renders non-Error throws and stack-less Errors on the done message', async () => { - const rawPort = new FakePort() - await runProgram(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams()) - expect(rawPort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'raw-throw' } }) - - const barePort = new FakePort() - await runProgram(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams()) - expect(barePort.done()).toEqual({ type: 'done', error: { kind: 'exception', message: 'bare' } }) - }) - - it('replaces giant thrown strings and Error stacks before posting the done message', async () => { - const rawPort = new FakePort() - await runProgram(rawPort, { - maxOutputBytes: 64, - code: 'throw "x".repeat(1_000_000)', - namespaces: [], - }, fakeStreams()) - expect(rawPort.done()).toEqual({ - type: 'done', - error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, - }) - - const stackPort = new FakePort() - await runProgram(stackPort, { - maxOutputBytes: 64, - code: 'throw new Error("x".repeat(1_000_000))', - namespaces: [], - }, fakeStreams()) - expect(stackPort.done()).toEqual({ - type: 'done', - error: { kind: 'output-limit', message: 'outer output exceeded 64 bytes' }, - }) - }) - - it('surfaces a host failure reply as a program-side rejection it can catch', async () => { - const port = new FakePort() - port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined - await runProgram(port, { - ...BOOT, - code: 'try { await tools.x({}) } catch (error) { return { caught: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message } }', - namespaces: [toolNamespace(['x'])], - }, fakeStreams()) - expect(port.doneValue()).toEqual({ caught: true, name: 'ToolCallError', toolName: 'x', message: 'denied by host' }) - }) - - it('materializes a consumer-declared rejection class without knowing the namespace', async () => { - const port = new FakePort() - port.respond = message => message.type === 'call' - ? { type: 'reply', id: message.id, ok: false, message: 'helper denied' } - : undefined - await runProgram(port, { - ...BOOT, - code: 'try { await helpers.x({}) } catch (error) { return { caught: error instanceof HelperCallError, name: error.name, helperName: error.helperName, message: error.message } }', - namespaces: [{ - global: 'helpers', - names: ['x'], - errorClass: { name: 'HelperCallError', memberNameProperty: 'helperName' }, - }], - }, fakeStreams()) - expect(port.doneValue()).toEqual({ caught: true, name: 'HelperCallError', helperName: 'x', message: 'helper denied' }) - }) - - it('ignores replies for unknown pending ids', async () => { - const port = new FakePort() - port.respond = (message) => { - if (message.type !== 'call') return undefined - // Deliver a stray reply first; the real one follows. - port.deliver({ type: 'reply', id: 9_999, ok: true, value: encodePtcJsonWire('stray') }) - return { type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire('real') } - } - await runProgram(port, { - ...BOOT, - code: 'return await tools.x({})', - namespaces: [{ global: 'tools', names: ['x'] }], - }, fakeStreams()) - expect(port.doneValue()).toBe('real') - }) - - it('captures raw stream writes through the patched process streams', async () => { - const port = new FakePort() + it('leaves the owned output streams connected to their capture sink', async () => { + const remote = host() const streams = fakeStreams() - await runProgram(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams) - streams.stdout.write('never seen — already restored? no: patch persists in worker') - // The patch stays installed for the worker's lifetime; writes during the - // program landed in order. Here the program wrote nothing via streams, so - // only the post-run write above went through the patched slot. - expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker') + await runProgram(remote, { ...BOOT, code: 'return 1', namespaces: [] }, streams) + streams.stdout.write('captured') + expect(remote.logs).toEqual(['captured']) }) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts deleted file mode 100644 index 76a31a9358..0000000000 --- a/packages/ptc-runtime/ptc-runtime-node/tests/channel.spec.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { Duplex, PassThrough } from 'node:stream' -import { describe, expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' - -function pair() { - const left = new PassThrough() - const right = new PassThrough() - const a = Duplex.from({ readable: left, writable: right }) - const b = Duplex.from({ readable: right, writable: left }) - // Duplex.from forwards peer destruction as ABORT_ERR; channel owners observe their own failures. - a.on('error', () => {}) - b.on('error', () => {}) - onTestFinished(() => { a.destroy(); b.destroy() }) - return { a, b } -} - -function frame(value: unknown): Buffer { - const body = Buffer.from(JSON.stringify(value)) - const header = Buffer.alloc(4) - header.writeUInt32BE(body.length) - return Buffer.concat([header, body]) -} - -describe('bounded process frames', () => { - it('reassembles split binary headers and UTF-8 bodies', async () => { - const { a, b } = pair() - const received = Promise.withResolvers() - const channel = new JsonChannel(a, 1000, (value) => { received.resolve(value) }, (error) => { received.reject(error) }) - onTestFinished(() => { channel.close() }) - const bytes = frame({ text: '你好🙂' }) - for (const byte of bytes) b.write(Buffer.from([byte])) - expect(await received.promise).toEqual({ text: '你好🙂' }) - }) - - it('accepts consecutive frames and writes complete responses', async () => { - const { a, b } = pair() - const received: unknown[] = [] - const done = Promise.withResolvers() - const channel = new JsonChannel(a, 1000, (value) => { - received.push(value) - if (received.length === 2) done.resolve(undefined) - }, (error) => { done.reject(error) }) - const peer = new JsonChannel(b, 1000, () => {}, (error) => { done.reject(error) }) - onTestFinished(() => { channel.close(); peer.close() }) - await Promise.all([peer.send({ n: 1 }), peer.send({ n: 2 })]) - await done.promise - expect(received).toEqual([{ n: 1 }, { n: 2 }]) - await peer.drain() - }) - - it.each([0, 65])('rejects a declared %i-byte frame before accepting its body', async (length) => { - const { a, b } = pair() - const failure = Promise.withResolvers() - const channel = new JsonChannel(a, 64, () => { throw new Error('must not dispatch') }, (error) => { failure.resolve(error) }) - onTestFinished(() => { channel.close() }) - const header = Buffer.alloc(4) - header.writeUInt32BE(length) - b.write(header) - expect((await failure.promise).message).toContain('control frame') - }) - - it('contains malformed JSON, invalid UTF-8 and receiver failures', async () => { - for (const payload of [Buffer.from('{'), Buffer.from([0xff]), Buffer.from('{}')]) { - const { a, b } = pair() - const failure = Promise.withResolvers() - const channel = new JsonChannel(a, 64, () => { throw new Error('receiver failed') }, (error) => { failure.resolve(error) }) - const header = Buffer.alloc(4) - header.writeUInt32BE(payload.length) - b.write(Buffer.concat([header, payload])) - expect(await failure.promise).toBeInstanceOf(Error) - channel.close() - } - }) - - it('rejects oversized writes and closed channels', async () => { - const { a } = pair() - const channel = new JsonChannel(a, 20, () => {}, () => {}) - await expect(channel.send({ text: 'x'.repeat(30) })).rejects.toThrow('queued bytes') - channel.close() - channel.close() - await expect(channel.send({})).rejects.toThrow('closed') - }) - - it('reports peer EOF while a program is active', async () => { - const { a, b } = pair() - const failure = Promise.withResolvers() - const channel = new JsonChannel(a, 64, () => {}, (error) => { failure.resolve(error) }) - onTestFinished(() => { channel.close() }) - b.end() - expect((await failure.promise).message).toContain('ended') - }) -}) - -it('retains a partial header and partial payload across distinct stream deliveries', () => { - const { a } = pair() - const values: unknown[] = [] - const channel = new JsonChannel(a, 64, (value) => { values.push(value) }, (error) => { throw error }) - const bytes = frame({ value: 1 }) - a.emit('data', bytes.subarray(0, 2)) - a.emit('data', bytes.subarray(2, 6)) - a.emit('data', bytes.subarray(6)) - expect(values).toEqual([{ value: 1 }]) - channel.close() -}) - -it('contains a receiver throwing a non-Error value', () => { - const { a } = pair() - const failures: string[] = [] - const channel = new JsonChannel(a, 64, () => { throw 'receiver failure' }, (error) => { failures.push(error.message) }) - a.emit('data', frame({})) - expect(failures).toEqual(['receiver failure']) - channel.close() -}) - -it('ignores callbacks already captured by an emission when an earlier listener closes the channel', () => { - for (const event of ['data', 'end']) { - const { a } = pair() - let failures = 0 - const channel = new JsonChannel(a, 64, () => { throw new Error('closed channel dispatched') }, () => { failures += 1 }) - a.prependOnceListener(event, () => { channel.close() }) - a.emit(event, frame({})) - a.emit('error', new Error('late closed stream error')) - expect(failures).toBe(0) - } -}) - -it('rejects pending write receipts when the owner closes', async () => { - const { a } = pair() - const channel = new JsonChannel(a, 64, () => {}, () => {}) - const pending = channel.send({ n: 1 }) - channel.close() - await expect(pending).rejects.toThrow('closed') - await channel.drain() -}) - -it('bounds queued frames while a receiver is not reading', async () => { - const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} }) - const channel = new JsonChannel(stream, 32, () => {}, () => {}) - const pending = channel.send({ value: '1234567890' }) - await expect(channel.send({ value: '1234567890' })).rejects.toThrow('queued bytes') - const draining = channel.drain() - channel.close() - await expect(pending).rejects.toThrow('closed') - await draining -}) - -it.each(['error', 'close'])('settles a blocked write when the stream emits %s', async (event) => { - const entered = Promise.withResolvers() - const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } }) - let failure: Error | undefined - const channel = new JsonChannel(stream, 64, () => {}, (error) => { failure = error }) - const pending = channel.send({ value: 1 }) - await entered.promise - stream.emit(event, new Error('transport failed')) - await expect(pending).rejects.toThrow(event === 'error' ? 'transport failed' : 'closed') - if (event === 'error') expect(failure?.message).toBe('transport failed') - channel.close() -}) - - -it.each([new Error('write failed'), 'write failed'])('rejects a synchronous transport write failure: %s', async (failure) => { - const stream = new Duplex({ read() {}, write() { throw failure } }) - const channel = new JsonChannel(stream, 64, () => {}, () => {}) - onTestFinished(() => { channel.close() }) - await expect(channel.send({ value: 1 })).rejects.toThrow('write failed') -}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 7027604bca..4c3f5cc624 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -11,7 +11,7 @@ import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { Config } from '../src/index.ts' -import { JsonChannel } from '../src/channel.ts' +import { rawPeer } from './raw-peer.ts' import { encodePtcJsonWire } from '../src/json-wire.ts' import { mountRuntime } from './setup.ts' @@ -58,11 +58,14 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces waitForExit, } const writes = new Set>() + let runId = '' let receive: (message: unknown) => void = () => {} - const peer = new JsonChannel(childControl, 1024 * 1024, (message) => { + const peer = rawPeer(childControl, (message) => { + if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' + && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i messages.push(message) receive(message) - }, () => {}) + }) const emit = (message: unknown): void => { const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) writes.add(write) @@ -77,25 +80,26 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces direct.resolve({ exitCode: 0, signal: null }) await Promise.all(writes) }) - const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => { + const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { const result = runtime.run(runtime.resolve(input)) if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) return result } const onBoot = (callback: () => void): void => { receive = (message) => { - if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'boot') callback() + if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() } } return { ctx, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot, + complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, receive: (callback: typeof receive) => { receive = callback }, } } function call(id: number, value: string = '') { - return { type: 'call', id, global: 'tools', name: 'test', args: encodePtcJsonWire(value) } + return { t: 'q', i: String(id), m: 'invoke', a: [1, 'tools', 'test', encodePtcJsonWire(value)] } } function withBinding(fn: PtcBindingFunction): PtcRunRequest { @@ -117,7 +121,6 @@ describe('Node runtime host failures', () => { [{ maxOutputBytes: 3 }, 'maxOutputBytes'], [{ maxOutputBytes: 4.5 }, 'maxOutputBytes'], [{ maxMessageBytes: 1.5 }, 'maxMessageBytes'], - [{ maxMessageBytes: 0x1_0000_0000 }, 'maxMessageBytes'], [{ maxPendingCalls: 1.5 }, 'maxPendingCalls'], [{ maxOldGenerationSizeMb: 1.5 }, 'maxOldGenerationSizeMb'], [{ launch: { kind: 'node-script', executable: '' } }, 'launch.executable'], @@ -149,14 +152,14 @@ describe('Node runtime host failures', () => { it('keeps partial enforcement separate from a successful program', async () => { const h = await setup({}, 'read-only') vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv])) - h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) }) + h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) }) expect(await h.start()).toEqual({ logs: [], value: 42, sandbox: { mode: 'read-only', denied: false, enforcement: 'partial' } }) }) it.each([['EACCES: blocked', true], ['EPERM: unrelated dialect', false]] as const)('uses only the selected denial dialect for %s', async (message, denied) => { const h = await setup({}, 'read-only') vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv])) - h.onBoot(() => { h.emit({ type: 'done', error: { kind: 'exception', message } }) }) + h.onBoot(() => { h.complete({ error: { kind: 'exception', message } }) }) const result = await h.start() expect(result.error).toEqual({ kind: 'exception', message }) expect(result.sandbox).toEqual({ mode: 'read-only', denied, enforcement: 'partial' }) @@ -298,7 +301,7 @@ describe('Node runtime host failures', () => { it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => { const h = await setup() - h.onBoot(() => { h.control.push(Buffer.from([0, 0, 0, 1, 0xff])) }) + h.onBoot(() => { h.control.push(Buffer.from([0xff, 10])) }) expect((await h.start()).error?.kind).toBe('protocol') }) @@ -306,35 +309,36 @@ describe('Node runtime host failures', () => { const h = await setup() const binding = vi.fn(async () => null) const result = await h.start(withBinding(binding), call(1)) - expect(result.error?.message).toContain('before bootstrap readiness') + expect(result.error?.message).toContain('active PTC evaluation') expect(binding).not.toHaveBeenCalled() }) it.each<[unknown, string]>([ - [null, 'invalid control frame'], - [{ type: 'log', text: 1 }, 'invalid log frame'], - [{ type: 'done', error: null }, 'invalid terminal error'], - [{ type: 'done', error: { kind: 'exception', message: 1 } }, 'invalid terminal error'], - [{ type: 'done', error: { kind: 'timeout', message: 'forged' } }, 'invalid terminal error'], - [{ type: 'call', id: 0, global: 'tools', name: 'test' }, 'invalid binding call identity'], - [{ ...call(1), name: 'constructor' }, 'undeclared binding'], - [{ type: 'call', id: 1, global: 'tools', name: 'test' }, 'arguments must be lossless JSON'], - [{ type: 'unknown' }, 'unknown control message'], - ])('rejects malformed program frame %j', async (frame, message) => { + [null, 'invalid RPC frame'], + [{ t: 'q', m: 'ready', a: [] }, 'duplicate program readiness'], + [{ t: 'q', m: 'log', a: [1, 1] }, 'invalid program log'], + [{ t: 'q', m: 'outputLimit', a: [0] }, 'invalid program output limit'], + [{ t: 'q', m: 'invoke', a: [1, null, 'test', []], i: 'call' }, 'invalid binding call identity'], + [{ t: 'q', m: 'invoke', a: [0, 'tools', 'test', []], i: 'call' }, 'active PTC evaluation'], + [{ t: 'q', m: 'invoke', a: [1, 'tools', 'constructor', []], i: 'call' }, 'undeclared binding'], + [{ t: 'q', m: 'invoke', a: [1, 'tools', 'test'], i: 'call' }, 'arguments must be lossless JSON'], + [{ t: 'q', m: 'constructor', a: [] }, 'invalid RPC method call'], + [{ t: 'q', m: 'toString', a: [] }, 'invalid RPC method call'], + [{ t: 'unknown' }, 'unknown RPC message'], + ])('rejects malformed program traffic %j', async (frame, message) => { const h = await setup() const binding = vi.fn(async () => null) h.onBoot(() => { h.emit(frame) }) const result = await h.start(withBinding(binding)) - expect(result.error).toEqual({ kind: 'protocol', message: expect.stringContaining(message) as unknown }) + expect(result.error?.kind).toBe('protocol') + expect(result.error?.message).toContain(message) expect(binding).not.toHaveBeenCalled() }) - it('refuses a repeated call id instead of dispatching it twice', async () => { + it.each([null, { error: null }, { error: { kind: 'exception', message: 1 } }, { error: { kind: 'timeout', message: 'forged' } }])('rejects malformed run results %j', async (raw) => { const h = await setup() - const binding = vi.fn(async () => null) - h.onBoot(() => { h.emit(call(1)); h.emit(call(1)) }) - expect((await h.start(withBinding(binding))).error?.kind).toBe('protocol') - expect(binding).toHaveBeenCalledOnce() + h.onBoot(() => { h.complete(raw) }) + expect((await h.start()).error?.kind).toBe('protocol') }) it.each([{ maxPendingCalls: 1 }, { maxMessageBytes: 512 }])('bounds unresolved host calls with %j', async (config) => { @@ -351,37 +355,37 @@ describe('Node runtime host failures', () => { it('rejects an untransferable completion instead of returning a substituted value', async () => { const h = await setup() - h.onBoot(() => { h.emit({ type: 'done', value: { invalid: true } }) }) + h.onBoot(() => { h.complete({ value: { invalid: true } }) }) expect((await h.start()).error?.kind).toBe('invalid-output') }) it('returns non-lossless binding resolutions as program-visible binding failures', async () => { const h = await setup() h.receive((message) => { - if (typeof message !== 'object' || message === null || !('type' in message)) return - if (message.type === 'boot') h.emit(call(1)) - if (message.type === 'reply') h.emit({ type: 'done' }) + if (typeof message !== 'object' || message === null || !('t' in message)) return + if ('m' in message && message.m === 'run') h.emit(call(1)) + if (message.t === 's') h.complete({}) }) expect((await h.start(withBinding(async () => Number.NaN))).error).toBeUndefined() - expect(h.messages).toContainEqual({ type: 'reply', id: 1, ok: false, message: expect.stringContaining('lossless JSON') as unknown }) + expect(h.messages).toContainEqual({ t: 's', i: '1', e: { message: 'binding resolution must be lossless JSON' } }) }) it('does not publish a late binding reply after the program has settled', async () => { const h = await setup() const release = Promise.withResolvers() onTestFinished(() => { release.resolve(null) }) - h.onBoot(() => { h.emit(call(1)); h.emit({ type: 'done' }) }) + h.onBoot(() => { h.emit(call(1)); h.complete({}) }) expect((await h.start(withBinding(async () => await release.promise))).error).toBeUndefined() release.resolve(null) await setImmediate() - expect(h.messages).not.toContainEqual(expect.objectContaining({ type: 'reply' })) + expect(h.messages).not.toContainEqual(expect.objectContaining({ t: 's' })) expect(h.control.destroyed).toBe(true) }) it('reports failed managed cleanup even when the program returns successfully', async () => { const h = await setup() vi.mocked(h.waitForExit).mockRejectedValue(new Error('range cannot be observed')) - h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) }) + h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) }) expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'managed process cleanup failed: range cannot be observed' }) }) @@ -397,11 +401,26 @@ describe('Node runtime host failures', () => { expect(result.error).toEqual({ kind: 'protocol', message: 'control output exceeds 256 queued bytes' }) }) + it('keeps an output overflow delivered beside the program result', async () => { + const h = await setup({ maxOutputBytes: 64 }, 'read-only') + vi.spyOn(h.ctx.sandbox, 'confine').mockImplementation(async argv => confinement([...argv])) + h.receive((message) => { + const run = message as { m?: string; i?: string } + if (run.m !== 'run') return + const result = { t: 's', i: run.i, r: { error: { kind: 'exception', message: 'EACCES: blocked' } } } + const log = { t: 'q', m: 'log', a: [1, 'x'.repeat(100)] } + h.control.push(Buffer.from(`${JSON.stringify(result)}\n${JSON.stringify(log)}\n`)) + }) + const result = await h.start() + expect(result.error?.kind).toBe('output-limit') + expect(result.sandbox?.denied).toBe(false) + }) + it('retains admitted logs when the program reports its own output limit', async () => { const h = await setup() h.onBoot(() => { - h.emit({ type: 'log', text: 'retained' }) - h.emit({ type: 'done', error: { kind: 'output-limit', message: 'child ledger exhausted' } }) + h.emit({ t: 'q', m: 'log', a: [1, 'retained'] }) + h.complete({ error: { kind: 'output-limit', message: 'child ledger exhausted' } }) }) const result = await h.start() expect(result.error?.kind).toBe('output-limit') @@ -413,7 +432,7 @@ describe('Node runtime host failures', () => { const cleaning = Promise.withResolvers() vi.mocked(h.terminate).mockImplementation(() => { h.direct.resolve({ exitCode: 0, signal: null }) }) vi.mocked(h.waitForExit).mockImplementation(async () => { cleaning.resolve(undefined); return true }) - h.onBoot(() => { h.emit(failure === undefined ? { type: 'done' } : { type: 'done', error: failure }) }) + h.onBoot(() => { h.complete(failure === undefined ? {} : { error: failure }) }) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) try { const pending = h.start() @@ -434,7 +453,7 @@ describe('Node runtime host failures', () => { stream.write(Buffer.from([0x82, 0xac])) stream.end(Buffer.from([0xe2])) } - h.emit({ type: 'done' }) + h.complete({}) }) const result = await h.start() expect(result.error).toBeUndefined() @@ -489,7 +508,7 @@ describe('Node runtime host failures', () => { vi.stubEnv('TMP', 'fixture-tmp-second') vi.stubEnv('ELECTRON_RUN_AS_NODE', '1') vi.stubEnv('DSH_TEST_RUNTIME_SECRET', 'must-not-inherit') - h.onBoot(() => { h.emit({ type: 'done' }) }) + h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() const env = h.spawn.mock.calls[0]?.[0].env ?? {} expect(Object.hasOwn(env, 'TEMP')).toBe(false) @@ -501,7 +520,7 @@ describe('Node runtime host failures', () => { it('selects the remote embedded bootstrap without leaking ambient environment', async () => { const h = await setup({ launch: { kind: 'embedded', executable: process.execPath } }) - h.onBoot(() => { h.emit({ type: 'done' }) }) + h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() const spec = h.spawn.mock.calls[0]?.[0] expect(spec?.env?.DSH_PTC_RUNTIME_NODE).toBe('1') @@ -516,7 +535,7 @@ it('keeps failed per-call cleanup registered until process observation succeeds' const h = await setup() const session = h.ctx.sessions.create(SessionId('per-call-cleanup-retry')) h.waitForExit.mockRejectedValue(new Error('range cannot be observed')) - h.onBoot(() => { h.emit({ type: 'done', value: encodePtcJsonWire(42) }) }) + h.onBoot(() => { h.complete({ value: encodePtcJsonWire(42) }) }) const result = await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }) expect(result.error?.kind).toBe('worker-exit') setSandboxMode(session, 'read-only') @@ -541,9 +560,9 @@ it('preserves execution context for bindings delivered by an external control st const session = h.ctx.sessions.create(SessionId('external-binding-context')) const booted = Promise.withResolvers() h.receive((raw) => { - const message = raw as { type: string; ok?: boolean; value?: unknown } - if (message.type === 'boot') booted.resolve(undefined) - if (message.type === 'reply') h.emit({ type: 'done', value: message.value }) + const message = raw as { t: string; m?: string; r?: unknown } + if ('m' in message && message.m === 'run') booted.resolve(undefined) + if (message.t === 's') h.complete({ value: message.r }) }) const context = new AsyncLocalStorage() const pending = context.run('binding context', () => h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }), diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index d0461f716e..59a180c5ce 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -8,7 +8,7 @@ import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-run import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import type { Config } from '../src/index.ts' import { mountRuntime } from './setup.ts' -import { JsonChannel } from '../src/channel.ts' +import { rawPeer } from './raw-peer.ts' import { encodePtcJsonWire } from '../src/json-wire.ts' const request: PtcRunRequest = { program: '1', bindings: [], stateOwner: {} } @@ -54,11 +54,14 @@ async function setup(config: Config = {}) { waitForExit, } const writes = new Set>() + let runId = '' let receive: (message: unknown) => void = () => {} - const peer = new JsonChannel(childControl, 1024 * 1024, (message) => { + const peer = rawPeer(childControl, (message) => { + if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' + && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i messages.push(message) receive(message) - }, () => {}) + }) const emit = (message: unknown): void => { const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) writes.add(write) @@ -73,25 +76,25 @@ async function setup(config: Config = {}) { direct.resolve({ exitCode: 0, signal: null }) await Promise.all(writes) }) - const start = (input: PtcRunRequest = request, first: unknown = { type: 'ready' }) => { + const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { const result = runtime.run(runtime.resolve(input)) if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) return result } const onBoot = (callback: () => void): void => { receive = (message) => { - if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'run') callback() + if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() } } return { ctx, fiber, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, spawn, resolveExecutable, emit, start, onBoot, + complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, receive: (callback: typeof receive) => { receive = callback }, } } -function frame(payload: unknown, id = 1) { return { id, payload } } -function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return frame({ type: 'call', id, global: 'tools', name: 'test', args }) } +function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return { t: 'q', i: String(id), m: 'invoke', a: [1, 'tools', 'test', args] } } const bound: PtcRunRequest = { ...request, bindings: [{ global: 'tools', functions: { test: async () => 42 } }] } it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when its Session policy changes before process launch', async (executionMode) => { @@ -100,10 +103,9 @@ it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when i const policy = h.ctx.sandboxPolicy.resolve({ session }) expect(policy.mode).toBe('danger-full-access') h.receive((message) => { - if (typeof message !== 'object' || message === null || !('type' in message)) return - const done = { type: 'done', value: encodePtcJsonWire(1) } - if (message.type === 'run') h.emit(frame(done)) - else if (message.type === 'boot') h.emit(done) + if (typeof message !== 'object' || message === null || !('t' in message)) return + const done = { value: encodePtcJsonWire(1) } + if ('m' in message && message.m === 'run') h.complete(done) }) const pending = h.start({ @@ -126,10 +128,9 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps an approved wider setSandboxMode(session, 'read-only') const standing = h.ctx.sandboxPolicy.resolve({ session }) h.receive((message) => { - if (typeof message !== 'object' || message === null || !('type' in message)) return - const done = { type: 'done', value: encodePtcJsonWire(1) } - if (message.type === 'run') h.emit(frame(done)) - else if (message.type === 'boot') h.emit(done) + if (typeof message !== 'object' || message === null || !('t' in message)) return + const done = { value: encodePtcJsonWire(1) } + if ('m' in message && message.m === 'run') h.complete(done) }) const pending = h.start({ program: executionMode === 'per-call' ? 'return 1' : '1', @@ -149,8 +150,8 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled const cleaning = Promise.withResolvers() const cleanup = Promise.withResolvers() h.receive((message) => { - if (typeof message === 'object' && message !== null && 'type' in message - && (message.type === 'run' || message.type === 'boot')) booted.resolve(undefined) + if (typeof message === 'object' && message !== null && 'm' in message + && message.m === 'run') booted.resolve(undefined) }) h.waitForExit.mockImplementation(() => { cleaning.resolve(undefined); return cleanup.promise }) const first = h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }) @@ -177,7 +178,7 @@ it.each(['per-call', 'persistent-in-memory'] as const)('keeps a %s call canceled it('rejects a retained call when its provider unloads during earlier policy cleanup', async () => { const h = await setup() const session = h.ctx.sessions.create(SessionId('unloading-admission')) - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start({ ...request, sandboxPolicy: h.ctx.sandboxPolicy.resolve({ session }) }) const cleaning = Promise.withResolvers() const cleanup = Promise.withResolvers() @@ -208,12 +209,12 @@ it('rejects a retained call when its provider unloads during earlier policy clea describe('persistent PTC host protocol', () => { it.each([ null, - { id: 99, payload: {} }, - frame({ type: 'other' }), - frame({ type: 'log', text: 1 }), - frame({ type: 'done', error: { kind: 'other', message: '' } }), - frame({ type: 'done', error: { kind: 'exception', message: 1 } }), - call(2), + { t: 'q', m: 'log', a: [99, 'stale'] }, + { t: 'q', m: 'outputLimit', a: [99] }, + { t: 'q', m: 'other', a: [] }, + { t: 'q', m: 'log', a: [1, 1] }, + { t: 'q', m: 'invoke', a: [1, null, 'test', []], i: 'call' }, + { t: 'q', m: 'invoke', a: [99, 'tools', 'test', encodePtcJsonWire(null)], i: 'call' }, call(1, 'not a wire value'), ])('rejects invalid process traffic %j', async (raw) => { const h = await setup() @@ -223,9 +224,15 @@ describe('persistent PTC host protocol', () => { expect(h.waitForExit).toHaveBeenCalledTimes(1) }) + it.each([{ error: { kind: 'other', message: '' } }, { error: { kind: 'exception', message: 1 } }])('rejects malformed results %j', async (raw) => { + const h = await setup() + h.onBoot(() => { h.complete(raw) }) + expect((await h.start()).error?.kind).toBe('protocol') + }) + it('requires the readiness handshake and all requested pipes', async () => { const h = await setup() - expect((await h.start(request, { type: 'other' })).error?.kind).toBe('protocol') + expect((await h.start(request, { t: 'q', m: 'other', a: [] })).error?.kind).toBe('protocol') const missing = await setup() missing.spawn.mockReturnValue({ ...missing.handle, control: undefined }) expect((await missing.start(request, NO_INITIAL_FRAME)).error?.message).toContain('did not supply') @@ -233,16 +240,16 @@ describe('persistent PTC host protocol', () => { it.each(['exception', 'invalid-output', 'output-limit'])('accepts a terminal %s failure', async (kind) => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind, message: 'example' } })) }) + h.onBoot(() => { h.complete({ error: { kind, message: 'example' } }) }) expect((await h.start()).error?.kind).toBe(kind) }) it('rejects lossy terminal values and bounds complete results', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done', value: 'bad' })) }) + h.onBoot(() => { h.complete({ value: 'bad' }) }) expect((await h.start()).error?.kind).toBe('invalid-output') const limited = await setup({ maxOutputBytes: 30 }) - limited.onBoot(() => { limited.emit(frame({ type: 'log', text: 'x'.repeat(100) })) }) + limited.onBoot(() => { limited.emit({ t: 'q', m: 'log', a: [1, 'x'.repeat(100)] }) }) expect((await limited.start()).error?.kind).toBe('output-limit') }) @@ -256,14 +263,14 @@ describe('persistent PTC host protocol', () => { const h = await setup() h.receive((raw) => { const message = raw as Record - if (message.type === 'run') h.emit(call()) - else if (message.type === 'reply') h.emit(frame({ type: 'done', value: encodePtcJsonWire(message.ok === true) })) + if (message.m === 'run') h.emit(call()) + else if (message.t === 's') h.complete({ value: encodePtcJsonWire(message.e === undefined) }) }) const result = await h.start({ ...request, bindings: [{ global: 'tools', functions }] }) expect(result.error).toBeUndefined() - const reply = h.messages.find(raw => (raw as { type: string }).type === 'reply') as { ok: boolean; message?: string } - expect(result.value).toBe(reply.ok) - if (!reply.ok) expect(reply.message).toMatch(/denied|unavailable/) + const reply = h.messages.find(raw => (raw as { t: string }).t === 's') as { e?: { message: string } } + expect(result.value).toBe(reply.e === undefined) + if (reply.e) expect(reply.e.message).toMatch(/denied|unavailable/) } }) @@ -275,7 +282,7 @@ describe('persistent PTC host protocol', () => { expect((await h.start({ ...request, bindings: [{ global: 'tools', functions: { test: () => pending.promise } }] })).error?.kind).toBe('protocol') } finally { pending.resolve(null) } await setImmediate() - expect(h.messages.some(raw => (raw as { type: string }).type === 'reply')).toBe(false) + expect(h.messages.some(raw => (raw as { t: string }).t === 's')).toBe(false) }) it('cleans up after launch failure and cancellation during executable resolution', async () => { @@ -298,19 +305,19 @@ describe('persistent PTC host protocol', () => { h.onBoot(() => { h.stdout.write('stdout') h.stderr.write('stderr') - h.emit(frame({ type: 'done' })) + h.complete({}) }) expect(await h.start()).toMatchObject({ logs: ['stdout', 'stderr'] }) h.stdout.write('idle stdout') h.stderr.write('idle stderr') - h.onBoot(() => { h.emit(frame({ type: 'done' }, 2)) }) + h.onBoot(() => { h.complete({}) }) expect(await h.start(request, NO_INITIAL_FRAME)).toMatchObject({ logs: [] }) expect(h.terminate).not.toHaveBeenCalled() }) it('clears retained resources when final diagnostic accounting detects an overflow', async () => { const h = await setup({ maxOutputBytes: 100 }) - h.onBoot(() => { h.emit(frame({ type: 'done', error: { kind: 'exception', message: 'x'.repeat(1000) } })) }) + h.onBoot(() => { h.complete({ error: { kind: 'exception', message: 'x'.repeat(1000) } }) }) const result = await h.start() expect(result.error?.kind).toBe('output-limit') expect(result.error?.message).toContain('retained variables were cleared') @@ -320,7 +327,7 @@ describe('persistent PTC host protocol', () => { it('keeps the first outcome when raw output overflows beside a terminal frame', async () => { const h = await setup({ maxOutputBytes: 100 }) h.onBoot(() => { - h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) })) + h.complete({ value: encodePtcJsonWire(42) }) queueMicrotask(() => { h.stdout.write('x'.repeat(1000)) }) }) expect((await h.start()).error?.kind).toBe('output-limit') @@ -342,6 +349,13 @@ describe('persistent PTC host protocol', () => { expect(result.error?.message).toContain('fatal output') }) + it('terminates the process when the control pipe fails during an evaluation', async () => { + const h = await setup() + h.onBoot(() => { h.control.emit('error', new Error('control transport failed')) }) + expect((await h.start()).error?.kind).toBe('worker-exit') + expect(h.terminate).toHaveBeenCalled() + }) + it('terminates when a binding result exceeds the control write budget', async () => { const h = await setup({ maxMessageBytes: 1024 }) h.onBoot(() => { h.emit(call()) }) @@ -353,7 +367,7 @@ describe('persistent PTC host protocol', () => { it.each(['exit', 'abort', 'cleanup'] as const)('does not dispatch to an exited idle process during %s handling', async (outcome) => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.onBoot(() => { h.stdout.end(); h.stderr.end() }) h.direct.resolve({ exitCode: 9, signal: null }) @@ -364,7 +378,9 @@ describe('persistent PTC host protocol', () => { const result = await h.runtime.run(h.runtime.resolve({ ...request, program: '2', signal: controller.signal })) expect(result.error?.kind).toBe(outcome === 'abort' ? 'abort' : 'worker-exit') expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') - expect(h.messages).toEqual([expect.objectContaining({ type: 'run', id: 1 })]) + expect(h.messages).toHaveLength(1) + expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' }) + expect((h.messages[0] as { a: unknown[] }).a[0]).toBe(1) expect(h.spawn).toHaveBeenCalledTimes(1) expect(h.terminate).toHaveBeenCalledTimes(1) expect(h.waitForExit).toHaveBeenCalledTimes(1) @@ -387,7 +403,7 @@ describe('persistent PTC host protocol', () => { it('reports failed managed cleanup and waits for both exit and pipe drain', async () => { const h = await setup() h.waitForExit.mockRejectedValue(new Error('cleanup failed')) - h.onBoot(() => { h.emit(frame({ type: 'output-limit' })) }) + h.onBoot(() => { h.emit({ t: 'q', m: 'outputLimit', a: [1] }) }) expect((await h.start()).error?.message).toContain('cleanup failed') }) @@ -396,7 +412,7 @@ describe('persistent PTC host protocol', () => { { maxOutputBytes: 4, kind: 'output-limit' }, ])('bounds cleanup failure after parse diagnostics overflow a $maxOutputBytes-byte budget', async ({ maxOutputBytes, kind }) => { const h = await setup({ maxOutputBytes }) - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) expect(await h.start()).toMatchObject({ logs: [] }) h.waitForExit.mockRejectedValue(new Error('x')) const result = await h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' })) @@ -415,7 +431,7 @@ describe('persistent PTC host protocol', () => { await expect(h.runtime.run({ ...request, cwd: '.', timeoutMs: null })).rejects.toThrow('resolved inputs') await expect(h.runtime.run({ ...request, cwd: process.cwd(), timeoutMs: Infinity })).rejects.toThrow('resolved inputs') for (const config of [ - { timeoutMs: 0 }, { maxMessageBytes: 0x1_0000_0000 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 }, + { timeoutMs: 0 }, { maxMessageBytes: 1.5 }, { maxOutputBytes: 3 }, { graceMs: 2_147_483_648 }, { launch: { kind: 'node-script', executable: '' } }, { launch: { kind: 'node-script', executable: process.execPath, bootstrapPath: 'relative' } }, ] satisfies Config[]) { @@ -425,7 +441,7 @@ describe('persistent PTC host protocol', () => { it('waits for another environment to stop before reporting a failed cleanup', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() const entered: PromiseWithResolvers = Promise.withResolvers() const release: PromiseWithResolvers = Promise.withResolvers() @@ -457,15 +473,15 @@ describe('persistent PTC host protocol', () => { it('rejects corrupt framed traffic before it reaches the evaluator', async () => { const h = await setup() - h.onBoot(() => { h.control.emit('data', Buffer.from([0, 0, 0, 1, 255])) }) + h.onBoot(() => { h.control.emit('data', Buffer.from([255, 10])) }) expect((await h.start()).error?.kind).toBe('protocol') }) it('discards unsolicited idle traffic and awaits the retained process exit', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() - h.emit(frame({ type: 'log', text: 'late' })) + h.emit({ t: 'q', m: 'log', a: [1, 'late'] }) await h.direct.promise expect(h.terminate).toHaveBeenCalledTimes(1) }) @@ -475,7 +491,7 @@ it.each(['owner release', 'provider unload'])('keeps idle-crash cleanup tracked const cleanup = Promise.withResolvers() const stopped = Promise.withResolvers() h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise }) - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) let closing: Promise | undefined try { await h.start() @@ -496,11 +512,41 @@ it.each(['owner release', 'provider unload'])('keeps idle-crash cleanup tracked } finally { stopped.resolve(true); await closing } }) +it('logs unexpected idle readiness and its failed cleanup', async () => { + const h = await setup() + const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {}) + try { + h.onBoot(() => { h.complete({}) }) + await h.start() + h.waitForExit.mockRejectedValueOnce(new Error('idle cleanup failed')) + h.emit({ t: 'q', m: 'ready', a: [] }) + await vi.waitFor(() => { expect(reported).toHaveBeenCalledTimes(2) }) + expect(reported.mock.calls).toEqual([['unexpected persistent PTC readiness'], ['idle cleanup failed']]) + } finally { reported.mockRestore() } +}) + +it('does not log a launch failure after cancellation closed the environment', async () => { + const h = await setup() + const entered = Promise.withResolvers() + const lookup = Promise.withResolvers() + h.resolveExecutable.mockImplementation(() => { entered.resolve(undefined); return lookup.promise }) + const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {}) + try { + const controller = new AbortController() + const result = h.start({ ...request, signal: controller.signal }, NO_INITIAL_FRAME) + await entered.promise + controller.abort() + lookup.reject(new Error('lookup aborted')) + expect((await result).error?.kind).toBe('abort') + expect(reported).not.toHaveBeenCalled() + } finally { reported.mockRestore() } +}) + it('logs an idle process failure with its bounded stderr tail', async () => { const h = await setup({ maxOutputBytes: 100 }) const reported = vi.spyOn(h.fiber.ctx.logger, 'warn').mockImplementation(() => {}) try { - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.stdout.end() h.stderr.end('x'.repeat(200) + 'idle fatal diagnostic') @@ -513,7 +559,7 @@ it('logs an idle process failure with its bounded stderr tail', async () => { it('retries process observation before releasing an owner after cleanup fails', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() const failure = new Error('release cleanup failed') h.waitForExit.mockRejectedValue(failure) @@ -533,7 +579,7 @@ it('reserves the owner while parse-output overflow releases its environment', as const h = await setup({ maxOutputBytes: 32 }) const cleanup = Promise.withResolvers() const stopped = Promise.withResolvers() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.waitForExit.mockImplementation(async () => { cleanup.resolve(true); return stopped.promise }) const pending = h.runtime.run(h.runtime.resolve({ ...request, program: 'return 1' })) @@ -582,7 +628,7 @@ it('reports a sandbox runner refusal before the REPL is ready', async () => { it('releases a confirmed exited process even when its output pipe did not drain', async () => { const h = await setup() - h.onBoot(() => { h.emit(frame({ type: 'done' })) }) + h.onBoot(() => { h.complete({}) }) await h.start() h.stdout.destroy() const warn = vi.spyOn(h.ctx.logger, 'warn').mockImplementation(() => {}) @@ -596,8 +642,8 @@ it('releases a confirmed exited process even when its output pipe did not drain' it('preserves logs in a result-level failure when temporary process pipes do not drain', async () => { const h = await setup() h.onBoot(() => { - h.emit(frame({ type: 'log', text: 'captured before cleanup' })) - h.emit(frame({ type: 'done', value: encodePtcJsonWire(42) })) + h.emit({ t: 'q', m: 'log', a: [1, 'captured before cleanup'] }) + h.complete({ value: encodePtcJsonWire(42) }) h.stdout.destroy() }) const result = await h.start({ program: '1', bindings: [] }) 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 882cdfc8b5..2a991aa9ef 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 @@ -1,9 +1,9 @@ import { Duplex, PassThrough } from 'node:stream' import { expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' +import { createProcessRpc } from '../src/rpc.ts' import { runPersistentMain, type PersistentProcess } from '../src/persistent-process.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' -import type { ProgramBootData } from '../src/protocol.ts' +import type { ProgramBootData, HostMethods, ProgramMethods } from '../src/protocol.ts' async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { const first = new PassThrough() @@ -15,31 +15,31 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { const state: PersistentProcess = { env: { FIXTURE_SECRET: 'test', PATH: '/native' }, stdout: { write: () => true }, stderr: { write: () => true }, chdir } const nativeEnvironment = state.env const ready = Promise.withResolvers() - const pending = new Map>>>() const seen: Record[] = [] let sequence = 0 - let onCall: (id: number, payload: Record) => void = (_id, payload) => { - void peer.send({ type: 'reply', id: payload.id, ok: true, value: encodePtcJsonWire(42) }) + const methods: HostMethods = { + ready: () => { ready.resolve(true) }, + invoke: async (id, global, name, args) => { + seen.push({ type: 'call', id, global, name, args }) + return encodePtcJsonWire(42) + }, + log: (id, text) => { seen.push({ type: 'log', id, text }) }, + outputLimit: (id) => { seen.push({ type: 'output-limit', id }) }, } - const peer = new JsonChannel(host, limit, (raw) => { - const message = raw as { type?: string; id: number; payload: Record } - if (message.type === 'ready') { ready.resolve(true); return } - seen.push(message.payload) - if (message.payload.type === 'call') onCall(message.id, message.payload) - if (message.payload.type === 'done') pending.get(message.id)?.resolve(message.payload) - }, () => {}) + const peer = createProcessRpc(host, limit, methods, [], () => {}) const main = runPersistentMain(child, limit, state) - const close = async () => { peer.close(); host.destroy(); child.destroy(); await main } + const close = async () => { peer.$close(); host.destroy(); child.destroy(); await main } onTestFinished(close) await ready.promise const run = (code: string, namespaces: ProgramBootData['namespaces'] = [], maxOutputBytes = 1024) => { const id = ++sequence - const result = Promise.withResolvers>() - pending.set(id, result) - void peer.send({ type: 'run', id, cwd: '/', data: { code, namespaces, maxOutputBytes } }) - return result.promise + return peer.run(id, '/', { code, namespaces, maxOutputBytes }).then((result) => { + seen.push({ type: 'done', id, ...result }) + return result + }) } - return { state, nativeEnvironment, run, seen, peer, main, close, host, setCall: (handler: typeof onCall) => { onCall = handler } } + + return { state, nativeEnvironment, run, seen, peer, main, close, host, setCall: (handler: HostMethods['invoke']) => { methods.invoke = handler } } } it('keeps an injected process local to the REPL while it runs and after it closes', async () => { @@ -60,7 +60,7 @@ it('keeps an injected process local to the REPL while it runs and after it close it('keeps an environment while capturing each evaluation and binding replies separately', async () => { const h = await setup() const declarations = [{ global: 'tools', names: ['echo'], errorClass: { name: 'ToolCallError', memberNameProperty: 'toolName' } }] - expect(await h.run('let count = 1; const saved = tools.echo;', declarations)).toEqual({ type: 'done' }) + expect(await h.run('let count = 1; const saved = tools.echo;', declarations)).toEqual({}) const result = await h.run('count++; process.stdout.write("raw"); console.log("value", count); await saved({})', declarations) expect(decodePtcJsonWire(result.value)).toBe(42) expect(h.seen.filter(item => item.type === 'log').map(item => item.text)).toEqual(['raw', 'value 2']) @@ -107,16 +107,23 @@ it('contains directory failures and output overflow in the active evaluation', a expect(h.seen.some(item => item.type === 'output-limit')).toBe(true) }) -it('refuses overlapping host evaluations and settles transport shutdown', async () => { +it('rejects overlapping host evaluations without replacing the active run', async () => { const h = await setup() - h.setCall(() => { void h.peer.send({ type: 'run', id: 2, cwd: '/', data: { code: '1', namespaces: [], maxOutputBytes: 1000 } }) }) - void h.run('await tools.echo({})', [{ global: 'tools', names: ['echo'] }]) - await h.main + const entered = Promise.withResolvers() + const release = Promise.withResolvers>() + h.setCall(() => { entered.resolve(undefined); return release.promise }) + const running = h.run('await tools.echo({})', [{ global: 'tools', names: ['echo'] }]) + try { + await entered.promise + await expect(h.run('1')).rejects.toThrow('already has an active evaluation') + release.resolve(encodePtcJsonWire(42)) + expect(decodePtcJsonWire((await running).value)).toBe(42) + } finally { release.resolve(encodePtcJsonWire(null)); await running } }) it('closes the process channel when a terminal result cannot fit its write budget', async () => { const h = await setup(1024) - void h.run('"x".repeat(2000)', [], 8000) + await expect(h.run('"x".repeat(2000)', [], 8000)).rejects.toThrow() await h.main }) @@ -128,17 +135,19 @@ it('ignores output and rejects tool calls from a completed evaluation', async () expect(decodePtcJsonWire(result.value)).toContain('evaluation has ended') expect(h.seen.some(item => item.type === 'log' || item.type === 'call')).toBe(false) expect(decodePtcJsonWire((await h.run('await tools.echo({})', declarations)).value)).toBe(42) - expect(h.seen.find(item => item.type === 'call')?.id).toBe(1) + expect(h.seen.find(item => item.type === 'call')?.id).toBe(3) }) it('rejects unfinished binding waits when a program returns', async () => { const h = await setup() - h.setCall(() => {}) + const pending = Promise.withResolvers>() + onTestFinished(() => { pending.resolve(encodePtcJsonWire(null)) }) + h.setCall(() => pending.promise) await h.run('let rejected; void tools.echo({}).catch(error => { rejected = error.message });', [{ global: 'tools', names: ['echo'] }]) expect(decodePtcJsonWire((await h.run('rejected')).value)).toContain('ended before the binding completed') }) -it.each([0, 1.5, 4294967296])('rejects an invalid control limit %s', async (limit) => { +it.each([0, 1.5])('rejects an invalid control limit %s', async (limit) => { const stream = new PassThrough() try { await expect(runPersistentMain(stream, limit, { env: {}, stdout: { write: () => true }, stderr: { write: () => true }, chdir: () => {} })).rejects.toThrow('invalid control message limit') 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 a62e9ca3a3..73e80ab60a 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 @@ -1,9 +1,9 @@ import { Duplex, PassThrough } from 'node:stream' import { setImmediate as nextTurn } from 'node:timers/promises' -import { expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' -import { runNodeMain } from '../src/process.ts' -import type { ProgramProcess } from '../src/process.ts' +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 { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' function endpoints() { @@ -18,126 +18,113 @@ function endpoints() { } function processState(): ProgramProcess { return { env: { FIXTURE_SECRET: 'test' }, stdout: { write: () => true }, stderr: { write: () => true }, exitCode: undefined } } -it('clears process environment, dispatches a binding reply and flushes the terminal frame', async () => { +async function setup(limit = 4096) { const { child, host } = endpoints() const state = processState() const nativeEnvironment = state.env - nativeEnvironment.SystemRoot = 'C:\\Windows' - nativeEnvironment.PATH = '/native/bin' - nativeEnvironment.TMP = 'C:\\sandbox-temp' - nativeEnvironment.TEMP = 'C:\\sandbox-temp' - const messages: Record[] = [] - const peer = new JsonChannel(host, 4096, (raw) => { - const message = raw as Record - messages.push(message) - if (message.type === 'ready') void peer.send({ type: 'boot', data: { code: 'console.log("ready"); return await tools.echo({});', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 1024 } }) - if (message.type === 'call') void peer.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) }) - if (message.type === 'done') host.end() - }, () => {}) - onTestFinished(() => { peer.close() }) - await runNodeMain(child, 4096, state) - expect(state.env).toEqual({}) - expect(state.env).not.toBe(nativeEnvironment) - expect(Object.getPrototypeOf(state.env)).toBeNull() - expect(nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' }) - expect(state.exitCode).toBeUndefined() - expect(decodePtcJsonWire(messages.find(message => message.type === 'done')?.value)).toBe(42) -}) - -it('keeps the control pipe open for a late binding reply until the host closes it', async () => { - const { child, host } = endpoints() - const state = processState() - const terminal = Promise.withResolvers() - const messages: string[] = [] - let callId: unknown - const peer = new JsonChannel(host, 4096, (raw) => { - const message = raw as Record - messages.push(String(message.type)) - if (message.type === 'ready') void peer.send({ type: 'boot', data: { - code: 'void tools.echo({}).then(() => { throw new Error("late reply resumed the program") }); setImmediate(() => console.log("late timer")); return 42;', - namespaces: [{ global: 'tools', names: ['echo'] }], - maxOutputBytes: 1024, - } }) - if (message.type === 'call') callId = message.id - if (message.type === 'done') terminal.resolve(decodePtcJsonWire(message.value)) - }, (error) => { terminal.reject(error) }) - const main = runNodeMain(child, 4096, state) - try { - expect(await terminal.promise).toBe(42) - // Settle the terminal write callbacks while the host still owns the open pipe. - await nextTurn() - expect(child.destroyed).toBe(false) - await peer.send({ type: 'reply', id: callId, ok: true, value: encodePtcJsonWire(42) }) - await nextTurn() - expect(messages).toEqual(['ready', 'call', 'done']) - } finally { - peer.close() - await main + Object.assign(nativeEnvironment, { SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' }) + const ready = Promise.withResolvers() + const logs: string[] = [] + const methods: HostMethods = { + ready: () => { ready.resolve(undefined) }, + invoke: async () => encodePtcJsonWire(42), + log: (_id, text) => { logs.push(text) }, + outputLimit: () => {}, } - expect(state.exitCode).toBeUndefined() + const peer = createProcessRpc(host, limit, methods, [], () => {}) + const main = runNodeMain(child, limit, state) + onTestFinished(async () => { peer.$close(); child.destroy(); await main }) + await ready.promise + const run = (code: string, maxOutputBytes = 1024) => peer.run(1, '/', { + code, namespaces: [{ global: 'tools', names: ['echo'] }], 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']) + expect(h.state.env).toEqual({}) + expect(h.state.env).not.toBe(h.nativeEnvironment) + expect(Object.getPrototypeOf(h.state.env)).toBeNull() + expect(h.nativeEnvironment).toEqual({ SystemRoot: 'C:\\Windows', PATH: '/native/bin', TMP: 'C:\\sandbox-temp', TEMP: 'C:\\sandbox-temp' }) + h.peer.$close() + await h.main + expect(h.state.exitCode).toBeUndefined() }) -it.each([0, -1, 1.5, 4294967296])('rejects an invalid bootstrap frame limit %i', async (limit) => { +it('rejects unfinished calls and suppresses later logs while awaiting host shutdown', async () => { + const h = await setup() + const release = Promise.withResolvers>() + h.methods.invoke = () => release.promise + try { + expect(decodePtcJsonWire((await h.run('void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42) + await nextTurn() + expect(h.child.destroyed).toBe(false) + release.resolve(encodePtcJsonWire(42)) + await nextTurn() + expect(h.logs).toEqual([]) + } finally { release.resolve(encodePtcJsonWire(null)) } +}) + +it('reports exhausted output accounting during the run', async () => { + 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') + await vi.waitFor(() => { expect(limited).toHaveBeenCalledOnce() }) +}) + +it('sends no binding calls or output notifications after returning its result', async () => { + const h = await setup() + const invoked = vi.fn(async () => encodePtcJsonWire(1)) + const limited = vi.fn() + h.methods.invoke = invoked + h.methods.outputLimit = limited + const result = await h.run('setImmediate(() => { void tools.echo({}).catch(() => {}); console.log("x".repeat(2000)) }); return 1', 64) + expect(decodePtcJsonWire(result.value)).toBe(1) + await nextTurn() + await nextTurn() + expect(invoked).not.toHaveBeenCalled() + expect(limited).not.toHaveBeenCalled() + expect(h.logs).toEqual([]) +}) + +it.each([0, -1, 1.5])('rejects an invalid control message limit %s', async (limit) => { const { child } = endpoints() await expect(runNodeMain(child, limit, processState())).rejects.toThrow('invalid control message limit') }) -it('rejects an unexpected first control frame', async () => { - const { child, host } = endpoints() - const peer = new JsonChannel(host, 4096, () => { void peer.send({ type: 'reply' }) }, () => {}) - onTestFinished(() => { peer.close() }) - await expect(runNodeMain(child, 4096, processState())).rejects.toThrow('expected program boot') +it('records malformed host traffic before evaluation as failure', async () => { + const h = await setup() + h.host.write(Buffer.from('null\n')) + await h.main + expect(h.state.exitCode).toBe(1) }) -it('records an I/O failure before the boot frame', async () => { - const { child, host } = endpoints() - const state = processState() - const peer = new JsonChannel(host, 4096, () => { peer.close() }, () => {}) - await expect(runNodeMain(child, 4096, state)).rejects.toBeInstanceOf(Error) - expect(state.exitCode).toBe(1) +it('records I/O failure before evaluation', async () => { + const h = await setup() + h.peer.$close() + await h.main + expect(h.state.exitCode).toBe(1) }) -it('contains program writes that exceed queued control output', async () => { - const { child, host } = endpoints() - const state = processState() - const peer = new JsonChannel(host, 1024, (raw) => { - if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { code: 'for(let i=0;i<30;i++) console.log("x".repeat(100));', namespaces: [], maxOutputBytes: 8000 } }) - }, () => {}) - onTestFinished(() => { peer.close() }) - await runNodeMain(child, 1024, state) - expect(state.exitCode).toBe(1) +it.each([ + 'console.log("x".repeat(2000));', + 'return "x".repeat(2000)', +])('closes on control output overflow: %s', async (code) => { + const h = await setup(1024) + await expect(h.run(code, 8000)).rejects.toThrow() + await h.main + expect(h.state.exitCode).toBe(1) }) -it('records a terminal frame that cannot fit the control write budget as failure', async () => { - const { child, host } = endpoints() - const state = processState() - const peer = new JsonChannel(host, 1024, (raw) => { - if ((raw as { type: string }).type === 'ready') void peer.send({ type: 'boot', data: { - code: 'return "x".repeat(2000)', namespaces: [], maxOutputBytes: 8000, - } }) - }, () => {}) - onTestFinished(() => { peer.close() }) - await runNodeMain(child, 1024, state) - expect(state.exitCode).toBe(1) -}) - -it('retains a malformed host frame failure after the terminal frame', async () => { - const { child, host } = endpoints() - const state = processState() - const terminal = Promise.withResolvers() - const peer = new JsonChannel(host, 1024, (raw) => { - const message = raw as { type: string } - if (message.type === 'ready') void peer.send({ type: 'boot', data: { - code: 'return 42', namespaces: [], maxOutputBytes: 1000, - } }) - if (message.type === 'done') terminal.resolve(undefined) - }, () => {}) - onTestFinished(() => { peer.close() }) - const main = runNodeMain(child, 1024, state) - try { - await terminal.promise - host.write(Buffer.from([0, 0, 0, 1, 0xff])) - await main - expect(state.exitCode).toBe(1) - } finally { peer.close(); await main } +it('records malformed host traffic after completion', async () => { + const h = await setup() + await h.run('return 42') + h.host.write(Buffer.from([0xff, 10])) + await h.main + expect(h.state.exitCode).toBe(1) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts index 8f4b16f38c..97c7398421 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts @@ -1,19 +1,21 @@ import { spawn } from 'node:child_process' -import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { copyFile, cp, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { pathToFileURL } from 'node:url' +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' import { Duplex } from 'node:stream' import { expect, it, onTestFinished } from 'vitest' -import { JsonChannel } from '../src/channel.ts' +import { createProcessRpc } from '../src/rpc.ts' +import type { HostMethods, ProgramMethods } from '../src/protocol.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' it('boots an unbuilt source closure outside the workspace and exchanges tool replies', async () => { const directory = await mkdtemp(join(tmpdir(), 'dsh-node-source-')) onTestFinished(async () => { await rm(directory, { recursive: true, force: true }) }) - for (const file of ['process.ts', 'bootstrap.ts', 'channel.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) { + for (const file of ['process.ts', 'bootstrap.ts', 'rpc.ts', 'json-wire.ts', 'output-json.ts', 'protocol.ts', 'environment.ts']) { await copyFile(new URL(`../src/${file}`, import.meta.url), join(directory, file)) } + await cp(dirname(dirname(fileURLToPath(import.meta.resolve('birpc')))), join(directory, 'node_modules/birpc'), { recursive: true }) const source = `import {Socket} from 'node:net';import {runNodeMain} from ${JSON.stringify(pathToFileURL(join(directory, 'process.ts')).href)};await runNodeMain(new Socket({fd:7,readable:true,writable:true}),100000,process);` const child = spawn(process.execPath, ['--input-type=module', '--eval', source], { env: { PLACEHOLDER_SECRET: 'fixture-only' }, @@ -27,24 +29,26 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep child.once('exit', (code) => { if (code !== 0) completed.reject(new Error(`child exit ${code}: ${stderr}`)) }) const control = Array.from(child.stdio)[7] if (!(control instanceof Duplex)) { child.kill(); await finished; throw new Error('missing child control channel') } - const channel = new JsonChannel(control, 100_000, (raw) => { - const message = raw as { type: string; id?: number; args?: unknown; value?: unknown; error?: unknown } - if (message.type === 'ready') { - void channel.send({ type: 'boot', data: { + const channel = createProcessRpc(control, 100_000, { + ready() { + void channel.run(1, directory, { code: 'const answer = await tools.echo({ n: 21 }); return { answer, env: { ...process.env } }', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 10_000, - } }).catch((error: unknown) => { completed.reject(error) }) - } else if (message.type === 'call') { - expect(decodePtcJsonWire(message.args)).toEqual({ n: 21 }) - void channel.send({ type: 'reply', id: message.id, ok: true, value: encodePtcJsonWire(42) }).catch((error: unknown) => { completed.reject(error) }) - } else if (message.type === 'done') { - if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error))) - else completed.resolve(decodePtcJsonWire(message.value)) - } - }, (error) => { completed.reject(error) }) - onTestFinished(async () => { channel.close(); child.kill(); await finished }) + }).then((result) => { + if (result.error) completed.reject(new Error(result.error.message)) + else completed.resolve(decodePtcJsonWire(result.value)) + }, (error: unknown) => { completed.reject(error) }) + }, + async invoke(_evaluation, _global, _name, args) { + expect(decodePtcJsonWire(args)).toEqual({ n: 21 }) + return encodePtcJsonWire(42) + }, + log() {}, + outputLimit() {}, + }, [], (error) => { completed.reject(error) }) + onTestFinished(async () => { channel.$close(); child.kill(); await finished }) expect(await completed.promise).toEqual({ answer: 42, env: {} }) - channel.close() + channel.$close() await finished }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts b/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts new file mode 100644 index 0000000000..9597508c52 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts @@ -0,0 +1,20 @@ +/** Raw newline JSON peer for testing hostile RPC traffic without birpc validation. */ +import { createInterface } from 'node:readline' +import type { Duplex } from 'node:stream' + +/** + * Observe and forge JSON messages over a test-owned stream. + * @param stream - Test endpoint. + * @param receive - Raw JSON message observer. + * @returns Write receipts and endpoint cleanup. + */ +export function rawPeer(stream: Duplex, receive: (message: unknown) => void) { + const lines = createInterface({ input: stream, crlfDelay: Infinity }) + lines.on('line', (line) => { receive(JSON.parse(line)) }) + return { + send: (message: unknown): Promise => new Promise((resolve, reject) => { + stream.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve() }) + }), + close: (): void => { lines.close(); stream.destroy() }, + } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts new file mode 100644 index 0000000000..fe2fb475e5 --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts @@ -0,0 +1,177 @@ +import { Duplex, PassThrough } from 'node:stream' +import { expect, it, onTestFinished, vi } from 'vitest' +import { createProcessRpc } from '../src/rpc.ts' +import { rawPeer } from './raw-peer.ts' + +function endpoints() { + const inbound = new PassThrough() + const outbound = new PassThrough() + const local = Duplex.from({ readable: inbound, writable: outbound }) + const remote = Duplex.from({ readable: outbound, writable: inbound }) + local.on('error', () => {}) + remote.on('error', () => {}) + onTestFinished(() => { local.destroy(); remote.destroy() }) + return { local, remote } +} + +it('matches concurrent bidirectional results and preserves remote rejection messages', async () => { + const { local, remote } = endpoints() + const release = Promise.withResolvers() + const entered = Promise.withResolvers() + const failures = vi.fn() + const left = createProcessRpc<{ slow(): Promise; fail(): never }, { double(value: number): number }>(local, 4096, + { double: value => value * 2 }, [], failures) + const right = createProcessRpc<{ double(value: number): number }, { slow(): Promise; fail(): never }>(remote, 4096, { + slow: () => { entered.resolve(undefined); return release.promise }, + fail: () => { throw new Error('binding denied') }, + }, [], failures) + onTestFinished(() => { release.resolve('cleanup'); left.$close(); right.$close() }) + const pending = left.slow() + await entered.promise + expect(await right.double(21)).toBe(42) + await expect(left.fail()).rejects.toThrow('binding denied') + release.resolve('completed') + expect(await pending).toBe('completed') + expect(failures).not.toHaveBeenCalled() +}) + +it.each([undefined, null, false, 0, '', 'denied'])('rejects a remote call whose method throws %j', async (thrown) => { + const { local, remote } = endpoints() + const left = createProcessRpc<{ fail(): never }, Record>(local, 4096, {}, [], () => {}) + const right = createProcessRpc, { fail(): never }>(remote, 4096, { fail: () => { throw thrown } }, [], () => {}) + onTestFinished(() => { left.$close(); right.$close() }) + await expect(left.fail()).rejects.toThrow(String(thrown)) +}) + +it('does not invoke a method received beside traffic that closed the endpoint', async () => { + const { local, remote } = endpoints() + const failure = Promise.withResolvers() + const allowed = vi.fn() + const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) }) + onTestFinished(() => { endpoint.$close() }) + remote.write(`null\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`) + expect(await failure.promise).toBe('protocol') + await new Promise(resolve => setImmediate(resolve)) + expect(allowed).not.toHaveBeenCalled() +}) + +it('lets execution own the deadline and rejects outstanding calls when the transport closes', async () => { + const { local, remote } = endpoints() + const received = Promise.withResolvers() + const raw = rawPeer(remote, () => { received.resolve(undefined) }) + const endpoint = createProcessRpc<{ wait(): Promise }, Record>(local, 4096, {}, [], () => {}) + onTestFinished(() => { endpoint.$close(); raw.close() }) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + try { + const pending = endpoint.wait() + const rejected = expect(pending).rejects.toThrow('rpc is closed') + await received.promise + const settled = vi.fn() + void pending.then(settled, settled) + await vi.advanceTimersByTimeAsync(120_000) + expect(settled).not.toHaveBeenCalled() + endpoint.$close() + await rejected + await expect(endpoint.wait()).rejects.toThrow('rpc is closed') + } finally { vi.useRealTimers() } +}) + +it('sends notifications without allocating response identifiers', async () => { + const { local, remote } = endpoints() + const received = Promise.withResolvers() + const raw = rawPeer(remote, (message) => { received.resolve(message) }) + const endpoint = createProcessRpc<{ log(text: string): void }, Record>(local, 4096, {}, ['log'], () => {}) + onTestFinished(() => { endpoint.$close(); raw.close() }) + await endpoint.log('visible') + expect(await received.promise).toEqual({ t: 'q', m: 'log', a: ['visible'] }) +}) + +it('ignores a response for another call and returns the matching result', async () => { + const { local, remote } = endpoints() + const endpoint = createProcessRpc<{ value(): string }, Record>(local, 4096, {}, [], () => {}) + const raw = rawPeer(remote, (request) => { + const { i } = request as { i: string } + void raw.send({ t: 's', i: 'unrelated', r: 'wrong' }) + void raw.send({ t: 's', i, r: 'right' }) + }) + onTestFinished(() => { endpoint.$close(); raw.close() }) + expect(await endpoint.value()).toBe('right') +}) + +it.each([ + null, + { t: 'q', m: 'constructor', a: [] }, + { t: 'q', m: 'allowed', a: null }, + { t: 's', i: 'call', e: 'unstructured error' }, + { t: 'unknown' }, +])('closes malformed RPC traffic before invoking local methods: %j', async (message) => { + const { local, remote } = endpoints() + const failure = Promise.withResolvers<{ kind: string; error: Error }>() + const allowed = vi.fn() + const endpoint = createProcessRpc(local, 4096, { allowed }, [], (error, kind) => { failure.resolve({ error, kind }) }) + const raw = rawPeer(remote, () => {}) + onTestFinished(() => { endpoint.$close(); raw.close() }) + await raw.send(message) + expect((await failure.promise).kind).toBe('protocol') + expect(endpoint.$closed).toBe(true) + expect(allowed).not.toHaveBeenCalled() +}) + +it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => { + const { local, remote } = endpoints() + const received: string[] = [] + const complete = Promise.withResolvers() + const endpoint = createProcessRpc(local, 1024, { log(text: string) { + received.push(text) + if (received.length === 2) complete.resolve(undefined) + } }, [], (error) => { complete.reject(error) }) + onTestFinished(() => { endpoint.$close() }) + const bytes = Buffer.from(['你好🙂\ninside', 'next'].map(text => JSON.stringify({ t: 'q', m: 'log', a: [text] }) + '\n').join('')) + for (const byte of bytes) remote.write(Buffer.from([byte])) + await complete.promise + expect(received).toEqual(['你好🙂\ninside', 'next']) +}) + +it.each([ + [Buffer.from('{\n'), false], + [Buffer.from([0xff, 10]), false], + [Buffer.from('{'), true], + [Buffer.from([0xe2]), true], + [Buffer.from('x'.repeat(65)), false], +])('rejects invalid or oversized JSON lines before dispatch (%j, EOF %s)', async (bytes, eof) => { + const { local, remote } = endpoints() + const failed = Promise.withResolvers() + const endpoint = createProcessRpc(local, 64, {}, [], (_error, kind) => { failed.resolve(kind) }) + onTestFinished(() => { endpoint.$close() }) + if (eof) remote.end(bytes) + else remote.write(bytes) + expect(await failed.promise).toBe('protocol') +}) + +it('bounds the native writable queue and rejects all outstanding calls on overflow', async () => { + const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) {} }) + const failed = Promise.withResolvers() + const endpoint = createProcessRpc<{ invoke(value: string): Promise }, Record>(stream, 256, {}, [], (error) => { + failed.resolve(error) + }) + onTestFinished(() => { endpoint.$close() }) + const calls = Array.from({ length: 4 }, () => endpoint.invoke('x'.repeat(64))) + const results = Promise.allSettled(calls) + expect((await failed.promise).message).toContain('queued bytes') + expect((await results).every(result => result.status === 'rejected')).toBe(true) +}) + +it.each(['error', 'close'] as const)('rejects a pending call on transport %s', async (event) => { + const entered = Promise.withResolvers() + const stream = new Duplex({ read() {}, write(_chunk, _encoding, _callback) { entered.resolve(undefined) } }) + const failed = Promise.withResolvers() + const endpoint = createProcessRpc<{ wait(): Promise }, Record>(stream, 256, {}, [], (_error, kind) => { + failed.resolve(kind) + }) + onTestFinished(() => { endpoint.$close() }) + const rejected = expect(endpoint.wait()).rejects.toThrow('closed') + await entered.promise + stream.emit(event, new Error('transport failed')) + expect(await failed.promise).toBe('io') + await rejected +}) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index a5773b8fd2..5fd5e42c92 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -235,10 +235,10 @@ describe('Node program process', () => { }) it.each([ - 'const fs = await import("node:fs"); const b=Buffer.alloc(4); b.writeUInt32BE(4294967295); fs.writeSync(7,b); await new Promise(()=>{});', - 'const fs = await import("node:fs"); const body=Buffer.from(JSON.stringify({type:"call",id:1,global:"tools",name:"undeclared",args:[]})); const h=Buffer.alloc(4); h.writeUInt32BE(body.length); fs.writeSync(7,Buffer.concat([h,body])); await new Promise(()=>{});', + 'const fs = await import("node:fs"); fs.writeSync(7,"x".repeat(2048)); await new Promise(()=>{});', + 'const fs = await import("node:fs"); fs.writeSync(7,JSON.stringify({t:"q",i:"forged",m:"invoke",a:[1,"tools","undeclared",[]]})+"\\n"); await new Promise(()=>{});', ])('refuses hostile program control traffic', async (program) => { - const { run } = await setup() + const { run } = await setup({ maxMessageBytes: 1024 }) expect((await run({ program, bindings: [] })).error?.kind).toBe('protocol') }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts index 535efdde08..530d76f714 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tsdown.config.ts @@ -3,6 +3,6 @@ import { defineConfig } from 'tsdown' /** The Node provider and its private process bootstraps are published bundles. */ export default defineConfig([ { entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false }, - { entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, - { entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control'] }, + { entry: { process: 'lib/types/process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'birpc'] }, + { entry: { 'persistent-process': 'lib/types/persistent-process-entry.js' }, outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false, noExternal: ['@deepseek-ai/dsh-subprocess/control', 'birpc'] }, ]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02f18e8afd..98d5a74503 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12087,6 +12087,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + birpc: + specifier: ^4.2.0 + version: 4.2.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -20131,6 +20134,9 @@ packages: birpc@4.0.0: resolution: {integrity: sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw==} + birpc@4.2.0: + resolution: {integrity: sha512-KxgKcZPfrtzJDDALHPguGpGJUrzdgpymyiQQgzFjWreHMOpWrnFNVREr5J48x2DBh8ZVioscrV1SBkDipGiX+Q==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -27608,6 +27614,8 @@ snapshots: birpc@4.0.0: {} + birpc@4.2.0: {} + bl@4.1.0: dependencies: buffer: 5.7.1 diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f1673f57e6..ae65e785da 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1448,10 +1448,6 @@ "d2c3345795169400442563b4923671c090a724eacaaae7f0c190297b967c1c4d": 1, "dfa30a521aae411e70eee81fc4c0511178589d0656dea01c1c97131bfe097793": 1 }, - "packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts": { - "aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1, - "be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1 - }, "packages/ptc-runtime/ptc-runtime-node/tests/output-json.spec.ts": { "8c4c486bbc27559714f4e0aaf376c72c49ac9157539b03c6c12853b8d3fc6e74": 1 }, diff --git a/scripts/ssh-helper/worker-probe.ts b/scripts/ssh-helper/worker-probe.ts index f84d598258..99c4519b50 100644 --- a/scripts/ssh-helper/worker-probe.ts +++ b/scripts/ssh-helper/worker-probe.ts @@ -4,7 +4,8 @@ import { spawn } from 'node:child_process' import { Duplex, type Readable, type Writable } from 'node:stream' import { join } from 'node:path' import { z } from 'zod' -import { JsonChannel } from '../../packages/ptc-runtime/ptc-runtime-node/src/channel.ts' +import { createProcessRpc } from '../../packages/ptc-runtime/ptc-runtime-node/src/rpc.ts' +import type { HostMethods, ProgramMethods } from '../../packages/ptc-runtime/ptc-runtime-node/src/protocol.ts' import { decodePtcJsonWire } from '../../packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts' import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '../../packages/subprocess/subprocess/src/control.ts' @@ -40,18 +41,17 @@ export async function probeRestrictedWorker( const streams: ReadonlyArray = child.stdio const control = streams[SUBPROCESS_CONTROL_FD] assert(control instanceof Duplex) - const channel = new JsonChannel(control, 1_048_576, (raw) => { - const message = z.object({ type: z.string(), value: z.unknown().optional(), error: z.unknown().optional() }).parse(raw) - if (message.type === 'ready') { - const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};` - void channel.send({ type: 'boot', data: { code, namespaces: [], maxOutputBytes: 65_536 } }) - .catch((error: unknown) => { completed.reject(error) }) - } else if (message.type === 'done') { - if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error))) - else completed.resolve(decodePtcJsonWire(message.value)) - channel.close() - } else completed.reject(new Error(`Unexpected restricted-worker message: ${message.type}`)) - }, (error) => { completed.reject(error) }) + const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};` + // Accepting only readiness rejects any other worker message as a protocol failure. + const channel = createProcessRpc>(control, 1_048_576, { + ready() { + void channel.run(1, workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => { + if (result.error !== undefined) completed.reject(new Error(JSON.stringify(result.error))) + else completed.resolve(decodePtcJsonWire(result.value)) + channel.$close() + }, (error: unknown) => { completed.reject(error) }) + }, + }, [], (error) => { completed.reject(error) }) const force = setTimeout(() => { child.kill('SIGKILL') }, 20_000) try { const result = z.object({ executable: z.string(), env: z.array(z.string()), denied: z.array(z.enum(['EPERM', 'EACCES'])) }).parse(await completed.promise) @@ -60,7 +60,7 @@ export async function probeRestrictedWorker( assert.equal(result.denied.length, 2) await exited } finally { - channel.close() + channel.$close() child.kill('SIGTERM') try { await exited } finally { clearTimeout(force) } } From 82b23cf4d271f7f07ba8c8c9b0bc2646091c4d7b Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 20 Oct 2026 15:15:04 +0800 Subject: [PATCH 2/8] refactor(ptc-runtime-node): drop evaluation ids from the control protocol One pipe delivers each evaluation's messages before its result, and the child refuses calls from finished evaluations, so the per-message evaluation id protected nothing. Remove it with the checks, counters and helpers it required, measure outgoing messages only when posting, and replace rethrowing validation blocks with early refusals. --- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 2 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- .../ptc-runtime-node/src/bootstrap.ts | 26 +------ .../ptc-runtime/ptc-runtime-node/src/index.ts | 43 +++++------ .../src/persistent-process.ts | 16 ++--- .../ptc-runtime-node/src/persistent.ts | 72 ++++++++----------- .../ptc-runtime-node/src/process.ts | 8 +-- .../ptc-runtime-node/src/protocol.ts | 18 ++--- .../ptc-runtime/ptc-runtime-node/src/rpc.ts | 18 ++--- .../ptc-runtime-node/tests/bootstrap.spec.ts | 7 +- .../tests/host-failures.spec.ts | 24 ++----- .../tests/persistent-host.spec.ts | 23 +++--- .../tests/persistent-process.spec.ts | 30 ++------ .../tests/process-main.spec.ts | 4 +- .../ptc-runtime-node/tests/process.spec.ts | 4 +- scripts/ssh-helper/worker-probe.ts | 2 +- 17 files changed, 112 insertions(+), 191 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index ffaf7f5da5..c55ae36db8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -36,8 +36,8 @@ en: 9020b491a39e4bf5 zh: bcd1b2b1a3bde7f3 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control: - en: ed2b9c49cca1681c - zh: 611504d24cec9d35 + en: a11171255bbe78ec + zh: 101a178062b7eed0 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps: en: f9d796233bfb3b79 zh: f7c5803a6ccdf9c2 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 091cf6a2e1..83d14f3f3a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -109,7 +109,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o For per-call execution, the host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. -The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates evaluation identity, binding names and lossless JSON before dispatch. Execution deadlines remain host-owned, including calls that await approval. +The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval. Per-call completion rejects unfinished binding waits and stops captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 2f9e6f486e..4880ebb73f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -109,7 +109,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 -Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验求值身份、绑定名与无损 JSON。执行截止时间仍由 Host 管理,包含等待审批的调用。 +Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。 单次执行完成后会拒绝尚未完成的绑定等待,并停止捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index 8fd45a3190..30c4425209 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -4,8 +4,7 @@ */ import { inspect } from 'node:util' -import type { ProgramResult, ProgramBootData } from './protocol.ts' -import type { PtcJsonWire } from './json-wire.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' @@ -21,25 +20,6 @@ function defineBindingErrorField(error: Error, key: string, value: string): void capturedObjectDefineProperty(error, key, attributes) } -/** Evaluation-scoped host calls used by injected bindings and output capture. */ -export interface ProgramHost { - /** - * Invoke one declared host binding. - * @param global - Namespace global. - * @param name - Function name. - * @param args - Flat lossless JSON argument. - * @returns Flat lossless result or a rejected binding failure. - */ - invoke(this: void, global: string, name: string, args: PtcJsonWire): Promise - /** - * Stream text to the host output ledger. - * @param text - Captured text. - */ - log(text: string): void - /** Report exhausted output accounting to the host. */ - outputLimit(): void -} - /** * A writable stream's `write` slot, as the bootstrap patches it (see * {@link captureStreamWrites}). Method-typed so the real @@ -289,7 +269,7 @@ export function makeBindingErrorClasses( */ export function makeNamespaces( data: Pick, - host: Pick, + host: Pick, errorClasses: Pick, 'get'> = makeBindingErrorClasses(data), ): Record[] { return data.namespaces.map(({ global, names }) => { @@ -328,7 +308,7 @@ export function makeNamespaces( * @returns Completion or a bounded program failure. */ export async function runProgram( - host: ProgramHost, + host: Omit, data: ProgramBootData, streams: { stdout: PatchableStream; stderr: PatchableStream }, ): Promise { diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index c2e342819c..93b6816b8f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -4,7 +4,7 @@ import { stripTypeScriptTypes } from 'node:module' import { isAbsolute } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { PtcRuntime } from '@deepseek-ai/dsh-ptc-runtime' -import type { PtcBindingFunction, PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' +import type { PtcBindingNamespace, PtcJsonValue, PtcRunFailure, PtcRunRequest, PtcRunResult, PtcRunSandbox, PtcRunSpec } from '@deepseek-ai/dsh-ptc-runtime' import { clampTimeout } from '@deepseek-ai/dsh-timeout' import { SandboxUnavailableError, classifyRunnerFailure, isRunnerSpawnFailure } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' @@ -273,11 +273,13 @@ export class NodePtcRuntime extends PtcRuntime { ? { kind: 'sandbox-unavailable', message: stderr } : { kind: 'worker-exit', message: `Node process exited before completing (${String(outcome.exitCode)})${stderr ? `: ${stderr}` : ''}` }) } + // Report a protocol failure and abandon the handler; the closed endpoint sends no response. + const refuse = (message: string): never => { protocolFailure(message); throw new Error(message) } const transport = createProcessRpc(launched.control, this.config.maxMessageBytes, { ready: () => { if (ready) { protocolFailure('duplicate program readiness'); return } ready = true - void transport.run(1, spec.cwd, data).then((raw) => { + void transport.run(spec.cwd, data).then((raw) => { // A message delivered beside the result can select another outcome first. if (settled) return const { error, value } = readProgramResult(raw) @@ -289,32 +291,25 @@ export class NodePtcRuntime extends PtcRuntime { finish(error, value) }).catch((error: unknown) => { protocolFailure(messageOf(error)) }) }, - log: (evaluation, text) => { - if (!ready || evaluation !== 1 || typeof text !== 'string') protocolFailure('invalid program log') - else admit(text) + log: (text: unknown) => { + if (typeof text === 'string') admit(text) + else protocolFailure('invalid program log') }, - outputLimit: (evaluation) => { - if (!ready || evaluation !== 1) { protocolFailure('invalid program output limit'); return } + outputLimit: () => { outputOverflow = true finish() }, - invoke: async (evaluation: unknown, global: unknown, name: unknown, wire: unknown) => { - let fn: PtcBindingFunction | undefined - let args: PtcJsonValue | undefined - let bytes = 0 - try { - if (!ready || evaluation !== 1) throw new Error('call does not belong to the active PTC evaluation') - if (typeof global !== 'string' || typeof name !== 'string') throw new Error('invalid binding call identity') - const functions = bindings.get(global)?.functions - fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined - if (typeof fn !== 'function') throw new Error('program requested an undeclared binding') - args = decodePtcJsonWire(wire) - if (args === undefined) throw new Error('binding arguments must be lossless JSON') - bytes = Buffer.byteLength(JSON.stringify(wire)) - if (pending >= this.config.maxPendingCalls || pendingBytes + bytes > this.config.maxMessageBytes) { - throw new Error('pending binding calls exceed configured limits') - } - } catch (error: unknown) { protocolFailure(messageOf(error)); throw error } + invoke: async (global, name, wire) => { + // Wire identities that are not strings fail these lookups. + const functions = bindings.get(global)?.functions + const fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined + if (typeof fn !== 'function') return refuse('program requested an undeclared binding') + const args = decodePtcJsonWire(wire) + if (args === undefined) return refuse('binding arguments must be lossless JSON') + const bytes = Buffer.byteLength(JSON.stringify(wire)) + if (pending >= this.config.maxPendingCalls || pendingBytes + bytes > this.config.maxMessageBytes) { + return refuse('pending binding calls exceed configured limits') + } pending += 1 pendingBytes += bytes try { return await callBinding(fn, args) } finally { pending -= 1; pendingBytes -= bytes } 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 e7c67260af..5fb3f06f2b 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent-process.ts @@ -4,7 +4,7 @@ import { start } from 'node:repl' import { PassThrough, Writable, type Duplex } from 'node:stream' import { createProcessRpc } from './rpc.ts' import { captureStreamWrites, LogBuffer, makeConsoleShim, makeBindingErrorClass, makeNamespaces, prepareCompletion, prepareException } from './bootstrap.ts' -import type { BindingErrorConstructor, PatchableStream, ProgramHost } from './bootstrap.ts' +import type { BindingErrorConstructor, PatchableStream } from './bootstrap.ts' import type { ProgramResult, HostMethods, ProgramMethods } from './protocol.ts' import { clearProgramEnvironment } from './environment.ts' @@ -17,7 +17,6 @@ export interface PersistentProcess { } interface Evaluation { - id: number logs: LogBuffer maxOutputBytes: number active: boolean @@ -51,11 +50,11 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, Object.defineProperty(repl.context, name, { configurable: true, enumerable: true, writable: true, value: undefined }) } let current: Evaluation | undefined - const host: Pick = { + const host: Pick = { invoke: (global, name, args) => { const evaluation = context.getStore() if (!evaluation?.active) throw new Error('PTC evaluation has ended; call this function from a new run_code evaluation') - return connection.invoke(evaluation.id, global, name, args) + return connection.invoke(global, name, args) }, } const logSink = { push(text: string): void { const evaluation = context.getStore(); if (evaluation?.active) evaluation.logs.push(text) } } @@ -64,14 +63,11 @@ export async function runPersistentMain(stream: Duplex, maxMessageBytes: number, captureStreamWrites(logSink, processState.stdout) captureStreamWrites(logSink, processState.stderr) const connection = createProcessRpc(stream, maxMessageBytes, { - async run(id, cwd, data) { - if (current?.active) throw new Error('persistent PTC already has an active evaluation') + async run(cwd, data) { const completion = Promise.withResolvers() - const logs = new LogBuffer(data.maxOutputBytes, - (text) => { void connection.log(id, text) }, - () => { void connection.outputLimit(id) }) + const logs = new LogBuffer(data.maxOutputBytes, (text) => { void connection.log(text) }, () => { void connection.outputLimit() }) const evaluation: Evaluation = { - id, logs, maxOutputBytes: data.maxOutputBytes, active: true, + logs, maxOutputBytes: data.maxOutputBytes, active: true, finish(result) { if (!evaluation.active) return evaluation.active = false diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index e2c205b94a..f89b4bfe7c 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -16,7 +16,7 @@ import { createProcessRpc } from './rpc.ts' import { readProgramResult } from './result.ts' import { bootstrapArgs } from './launch.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' -import { decodePtcJsonWire, type PtcJsonWire } from './json-wire.ts' +import { decodePtcJsonWire } from './json-wire.ts' import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' @@ -41,7 +41,6 @@ function sameAdditionalRoots(left: readonly string[] | undefined, right: readonl } interface Evaluation { - id: number data: ProgramBootData spec: PtcRunSpec bindings: Map @@ -63,7 +62,6 @@ class Kernel { private running = false private cleanupPending = false private ready = false - private sequence = 0 private stderr = '' private stopped: Promise | undefined private starting?: Promise @@ -117,7 +115,6 @@ class Kernel { }, spec.timeoutMs) const onAbort = (): void => { evaluation.finish({ kind: 'abort', message: 'execution canceled; retained variables were cleared' }, undefined, true) } const evaluation: Evaluation = { - id: ++this.sequence, data: { code, maxOutputBytes: this.config.maxOutputBytes, namespaces: [...bindings.values()].map(binding => ({ global: binding.global, names: Object.keys(binding.functions), ...binding.errorClass ? { errorClass: binding.errorClass } : {}, })) }, @@ -187,7 +184,7 @@ class Kernel { } private dispatch(evaluation: Evaluation): void { - void this.channel?.run(evaluation.id, evaluation.spec.cwd, evaluation.data).then((raw) => { + void this.channel?.run(evaluation.spec.cwd, evaluation.data).then((raw) => { const { error, value } = readProgramResult(raw) // Raw stdio can be queued beside the RPC result on separate pipes. setImmediate(() => { evaluation.finish(error, value) }) @@ -220,23 +217,41 @@ class Kernel { }) stream.on('error', (error: Error) => { this.fail('worker-exit', error.message) }) } + // Report a protocol failure and abandon the handler; the closed endpoint sends no response. + const refuse = (message: string): never => { this.fail('protocol', message); throw new Error(message) } this.channel = createProcessRpc(handle.control, this.config.maxMessageBytes, { ready: () => { if (this.ready || !this.active) { this.fail('protocol', 'unexpected persistent PTC readiness'); return } this.ready = true this.dispatch(this.active) }, - log: (id, text) => { - const evaluation = this.evaluation(id) - if (evaluation === undefined || typeof text !== 'string') this.fail('protocol', 'invalid persistent PTC log') - else evaluation.admit(text) + log: (text: unknown) => { + if (this.active === undefined || typeof text !== 'string') this.fail('protocol', 'invalid persistent PTC log') + else this.active.admit(text) }, - outputLimit: (id) => { - const evaluation = this.evaluation(id) - if (evaluation === undefined) this.fail('protocol', 'invalid persistent PTC output limit') - else evaluation.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true) + outputLimit: () => { + if (this.active === undefined) this.fail('protocol', 'idle persistent PTC output limit') + else this.active.finish({ kind: 'output-limit', message: 'output limit exceeded' }, undefined, true) + }, + invoke: async (global, name, wire) => { + const evaluation = this.active + if (evaluation === undefined) return refuse('idle persistent PTC binding call') + const args = decodePtcJsonWire(wire) + if (args === undefined) return refuse('binding arguments must be lossless JSON') + const bytes = Buffer.byteLength(JSON.stringify(wire)) + if (evaluation.pending >= this.config.maxPendingCalls || evaluation.pendingBytes + bytes > this.config.maxMessageBytes) { + return refuse('pending binding calls exceed configured limits') + } + // A retained function can name a binding this evaluation no longer declares; invokeBinding rejects it. + const functions = evaluation.bindings.get(global)?.functions + const fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined + evaluation.pending += 1 + evaluation.pendingBytes += bytes + try { return await evaluation.invoke(fn, args) } finally { + evaluation.pending -= 1 + evaluation.pendingBytes -= bytes + } }, - invoke: (id, global, name, wire) => this.invoke(id, global, name, wire), }, [], (error, kind) => { if (kind === 'protocol') this.fail('protocol', error.message) else setImmediate(() => { if (!this.closed && this.exitOutcome === undefined) handle.terminate() }) @@ -250,35 +265,6 @@ class Kernel { }) }, (error: unknown) => { this.fail('worker-exit', messageOf(error)) }) } - - private evaluation(id: unknown): Evaluation | undefined { - return this.ready && id === this.active?.id ? this.active : undefined - } - - private async invoke(id: unknown, global: unknown, name: unknown, wire: unknown): Promise { - let evaluation: Evaluation | undefined - let args: PtcJsonValue | undefined - let bytes = 0 - try { - evaluation = this.evaluation(id) - if (evaluation === undefined) throw new Error('call does not belong to the active PTC evaluation') - if (typeof global !== 'string' || typeof name !== 'string') throw new Error('invalid persistent PTC binding identity') - args = decodePtcJsonWire(wire) - if (args === undefined) throw new Error('binding arguments must be lossless JSON') - bytes = Buffer.byteLength(JSON.stringify(wire)) - if (evaluation.pending >= this.config.maxPendingCalls || evaluation.pendingBytes + bytes > this.config.maxMessageBytes) { - throw new Error('pending binding calls exceed configured limits') - } - } catch (error: unknown) { this.fail('protocol', messageOf(error)); throw error } - evaluation.pending += 1 - evaluation.pendingBytes += bytes - const functions = evaluation.bindings.get(global)?.functions - const fn = functions && Object.hasOwn(functions, name) ? functions[name] : undefined - try { return await evaluation.invoke(fn, args) } finally { - evaluation.pending -= 1 - evaluation.pendingBytes -= bytes - } - } } /** Sandboxed, best-effort retained state; restart, disposal, and interrupted execution clear it. */ diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 727d38f1da..3368a18d08 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -28,14 +28,14 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce // Set once the result is returned or the endpoint closes; the child then sends nothing more. let ended = false const connection = createProcessRpc(stream, maxMessageBytes, { - async run(evaluation, _cwd, data) { + async run(_cwd, data) { const result = await runProgram({ invoke: (global, name, args) => { if (ended) throw new Error('PTC evaluation has ended') - return connection.invoke(evaluation, global, name, args) + return connection.invoke(global, name, args) }, - log: (text) => { if (!ended) void connection.log(evaluation, text) }, - outputLimit: () => { if (!ended) void connection.outputLimit(evaluation) }, + log: (text) => { if (!ended) void connection.log(text) }, + outputLimit: () => { if (!ended) void connection.outputLimit() }, }, data, processState) ended = true void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts index f20f74afe2..2471f99322 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts @@ -21,40 +21,34 @@ export interface ProgramResult { error?: { kind: 'exception' | 'invalid-output' | 'output-limit'; message: string } } -/** Host operations available only during the identified evaluation. */ +/** Host operations for the current evaluation; one pipe delivers its messages before its result. */ export interface HostMethods { /** Announce that the process can accept its first evaluation. */ ready(): void /** * Invoke a binding under its originating host execution context. - * @param evaluation - Active evaluation generation, independent of RPC correlation. * @param global - Namespace global. * @param name - Function name within the namespace. * @param args - Flat lossless JSON arguments. * @returns Flat lossless JSON result; binding failures reject the call. */ - invoke(evaluation: number, global: string, name: string, args: PtcJsonWire): Promise + invoke(global: string, name: string, args: PtcJsonWire): Promise /** * Admit captured output before completion or interruption. - * @param evaluation - Active evaluation generation. * @param text - Captured text. */ - log(evaluation: number, text: string): void - /** - * Report exhausted program-side output accounting. - * @param evaluation - Active evaluation generation. - */ - outputLimit(evaluation: number): void + log(text: string): void + /** Report exhausted program-side output accounting. */ + outputLimit(): void } /** Operations implemented by either the per-call child or retained REPL. */ export interface ProgramMethods { /** * Evaluate code with the supplied binding declarations and output budget. - * @param evaluation - Generation carried by all callbacks during this evaluation. * @param cwd - Execution-world working directory. * @param data - Program and injected declarations. * @returns Completion or a bounded program failure, after captured output is sent. */ - run(evaluation: number, cwd: string, data: ProgramBootData): Promise + run(cwd: string, data: ProgramBootData): Promise } diff --git a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts index b21e225e3c..b36d7e09f8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts @@ -1,9 +1,7 @@ /** birpc over newline-delimited JSON on the bounded private process channel. */ import { Buffer } from 'node:buffer' import type { Duplex } from 'node:stream' -import type { PtcJsonValue } from '@deepseek-ai/dsh-ptc-runtime' import { createBirpc, type BirpcReturn } from 'birpc' -import { jsonValueBytesUpTo } from './output-json.ts' const stringify = JSON.stringify const parse = JSON.parse @@ -38,16 +36,14 @@ export function createProcessRpc( eventNames, on: (listener) => { receive = listener }, off: () => { receive = undefined; stream.destroy() }, - serialize: (message: Record) => { - // JSON drops Error fields; the peer receives only the diagnostic. - const encoded = message.e instanceof Error ? { t: 's', i: message.i, e: { message: message.e.message } } : message - // Measure before materializing the line; the Writable owns already queued bytes. - if (jsonValueBytesUpTo(encoded as PtcJsonValue, maxMessageBytes - stream.writableLength - 1) !== undefined) return `${stringify(encoded)}\n` - fail(new Error(`control output exceeds ${maxMessageBytes} queued bytes`), 'protocol') - return '' + // JSON drops Error fields; the peer receives only the diagnostic. + serialize: (message: Record) => `${stringify(message.e instanceof Error ? { ...message, e: { message: message.e.message } } : message)}\n`, + post: (line: string) => { + // A binding can settle after the endpoint closes; the Writable owns already queued bytes. + if (rpc.$closed) return + if (stream.writableLength + Buffer.byteLength(line) > maxMessageBytes) fail(new Error(`control output exceeds ${maxMessageBytes} queued bytes`), 'protocol') + else stream.write(line) }, - // A binding can settle after the endpoint closes. - post: (line: string) => { if (!rpc.$closed) stream.write(line) }, deserialize: (line: string) => { const message: unknown = parse(line) if (!record(message)) throw new Error('invalid RPC frame') 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 9e3cff5a3f..22bab04e46 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/bootstrap.spec.ts @@ -1,12 +1,13 @@ import { describe, expect, it, vi } from 'vitest' import { LogBuffer, makeBindingErrorClasses, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareCompletion, prepareException, runProgram } from '../src/bootstrap.ts' -import type { ProgramHost, PatchableStream } from '../src/bootstrap.ts' +import type { PatchableStream } from '../src/bootstrap.ts' +import type { HostMethods } from '../src/protocol.ts' import { decodePtcJsonWire, encodePtcJsonWire } from '../src/json-wire.ts' -function host(): ProgramHost & { logs: string[]; limits: number } { +function host(): Omit & { logs: string[]; limits: number } { const calls = { logs: [] as string[], limits: 0, - invoke: vi.fn(async () => encodePtcJsonWire(42)), + invoke: vi.fn(async () => encodePtcJsonWire(42)), log: (text: string) => { calls.logs.push(text) }, outputLimit: () => { calls.limits += 1 }, } diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 4c3f5cc624..15560b413d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -99,7 +99,7 @@ async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-acces } function call(id: number, value: string = '') { - return { t: 'q', i: String(id), m: 'invoke', a: [1, 'tools', 'test', encodePtcJsonWire(value)] } + return { t: 'q', i: String(id), m: 'invoke', a: ['tools', 'test', encodePtcJsonWire(value)] } } function withBinding(fn: PtcBindingFunction): PtcRunRequest { @@ -305,23 +305,13 @@ describe('Node runtime host failures', () => { expect((await h.start()).error?.kind).toBe('protocol') }) - it('rejects traffic before readiness without invoking host bindings', async () => { - const h = await setup() - const binding = vi.fn(async () => null) - const result = await h.start(withBinding(binding), call(1)) - expect(result.error?.message).toContain('active PTC evaluation') - expect(binding).not.toHaveBeenCalled() - }) - it.each<[unknown, string]>([ [null, 'invalid RPC frame'], [{ t: 'q', m: 'ready', a: [] }, 'duplicate program readiness'], - [{ t: 'q', m: 'log', a: [1, 1] }, 'invalid program log'], - [{ t: 'q', m: 'outputLimit', a: [0] }, 'invalid program output limit'], - [{ t: 'q', m: 'invoke', a: [1, null, 'test', []], i: 'call' }, 'invalid binding call identity'], - [{ t: 'q', m: 'invoke', a: [0, 'tools', 'test', []], i: 'call' }, 'active PTC evaluation'], - [{ t: 'q', m: 'invoke', a: [1, 'tools', 'constructor', []], i: 'call' }, 'undeclared binding'], - [{ t: 'q', m: 'invoke', a: [1, 'tools', 'test'], i: 'call' }, 'arguments must be lossless JSON'], + [{ t: 'q', m: 'log', a: [1] }, 'invalid program log'], + [{ t: 'q', m: 'invoke', a: [null, 'test', []], i: 'call' }, 'undeclared binding'], + [{ t: 'q', m: 'invoke', a: ['tools', 'constructor', []], i: 'call' }, 'undeclared binding'], + [{ t: 'q', m: 'invoke', a: ['tools', 'test'], i: 'call' }, 'arguments must be lossless JSON'], [{ t: 'q', m: 'constructor', a: [] }, 'invalid RPC method call'], [{ t: 'q', m: 'toString', a: [] }, 'invalid RPC method call'], [{ t: 'unknown' }, 'unknown RPC message'], @@ -408,7 +398,7 @@ describe('Node runtime host failures', () => { const run = message as { m?: string; i?: string } if (run.m !== 'run') return const result = { t: 's', i: run.i, r: { error: { kind: 'exception', message: 'EACCES: blocked' } } } - const log = { t: 'q', m: 'log', a: [1, 'x'.repeat(100)] } + const log = { t: 'q', m: 'log', a: ['x'.repeat(100)] } h.control.push(Buffer.from(`${JSON.stringify(result)}\n${JSON.stringify(log)}\n`)) }) const result = await h.start() @@ -419,7 +409,7 @@ describe('Node runtime host failures', () => { it('retains admitted logs when the program reports its own output limit', async () => { const h = await setup() h.onBoot(() => { - h.emit({ t: 'q', m: 'log', a: [1, 'retained'] }) + h.emit({ t: 'q', m: 'log', a: ['retained'] }) h.complete({ error: { kind: 'output-limit', message: 'child ledger exhausted' } }) }) const result = await h.start() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 59a180c5ce..ee419a0053 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -94,7 +94,7 @@ async function setup(config: Config = {}) { } } -function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return { t: 'q', i: String(id), m: 'invoke', a: [1, 'tools', 'test', args] } } +function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return { t: 'q', i: String(id), m: 'invoke', a: ['tools', 'test', args] } } const bound: PtcRunRequest = { ...request, bindings: [{ global: 'tools', functions: { test: async () => 42 } }] } it.each(['per-call', 'persistent-in-memory'] as const)('cancels a %s call when its Session policy changes before process launch', async (executionMode) => { @@ -209,12 +209,8 @@ it('rejects a retained call when its provider unloads during earlier policy clea describe('persistent PTC host protocol', () => { it.each([ null, - { t: 'q', m: 'log', a: [99, 'stale'] }, - { t: 'q', m: 'outputLimit', a: [99] }, { t: 'q', m: 'other', a: [] }, - { t: 'q', m: 'log', a: [1, 1] }, - { t: 'q', m: 'invoke', a: [1, null, 'test', []], i: 'call' }, - { t: 'q', m: 'invoke', a: [99, 'tools', 'test', encodePtcJsonWire(null)], i: 'call' }, + { t: 'q', m: 'log', a: [1] }, call(1, 'not a wire value'), ])('rejects invalid process traffic %j', async (raw) => { const h = await setup() @@ -249,7 +245,7 @@ describe('persistent PTC host protocol', () => { h.onBoot(() => { h.complete({ value: 'bad' }) }) expect((await h.start()).error?.kind).toBe('invalid-output') const limited = await setup({ maxOutputBytes: 30 }) - limited.onBoot(() => { limited.emit({ t: 'q', m: 'log', a: [1, 'x'.repeat(100)] }) }) + limited.onBoot(() => { limited.emit({ t: 'q', m: 'log', a: ['x'.repeat(100)] }) }) expect((await limited.start()).error?.kind).toBe('output-limit') }) @@ -380,7 +376,6 @@ describe('persistent PTC host protocol', () => { expect(result.error?.message).toContain(outcome === 'abort' ? 'execution canceled' : outcome === 'cleanup' ? 'exit cleanup failed' : 'exited (9, signal null)') expect(h.messages).toHaveLength(1) expect(h.messages[0]).toMatchObject({ t: 'q', m: 'run' }) - expect((h.messages[0] as { a: unknown[] }).a[0]).toBe(1) expect(h.spawn).toHaveBeenCalledTimes(1) expect(h.terminate).toHaveBeenCalledTimes(1) expect(h.waitForExit).toHaveBeenCalledTimes(1) @@ -403,7 +398,7 @@ describe('persistent PTC host protocol', () => { it('reports failed managed cleanup and waits for both exit and pipe drain', async () => { const h = await setup() h.waitForExit.mockRejectedValue(new Error('cleanup failed')) - h.onBoot(() => { h.emit({ t: 'q', m: 'outputLimit', a: [1] }) }) + h.onBoot(() => { h.emit({ t: 'q', m: 'outputLimit', a: [] }) }) expect((await h.start()).error?.message).toContain('cleanup failed') }) @@ -477,11 +472,15 @@ it('rejects corrupt framed traffic before it reaches the evaluator', async () => expect((await h.start()).error?.kind).toBe('protocol') }) -it('discards unsolicited idle traffic and awaits the retained process exit', async () => { +it.each([ + { t: 'q', m: 'log', a: ['late'] }, + { t: 'q', m: 'outputLimit', a: [] }, + call(), +])('discards unsolicited idle traffic %j and awaits the retained process exit', async (raw) => { const h = await setup() h.onBoot(() => { h.complete({}) }) expect((await h.start()).error).toBeUndefined() - h.emit({ t: 'q', m: 'log', a: [1, 'late'] }) + h.emit(raw) await h.direct.promise expect(h.terminate).toHaveBeenCalledTimes(1) }) @@ -642,7 +641,7 @@ it('releases a confirmed exited process even when its output pipe did not drain' it('preserves logs in a result-level failure when temporary process pipes do not drain', async () => { const h = await setup() h.onBoot(() => { - h.emit({ t: 'q', m: 'log', a: [1, 'captured before cleanup'] }) + h.emit({ t: 'q', m: 'log', a: ['captured before cleanup'] }) h.complete({ value: encodePtcJsonWire(42) }) h.stdout.destroy() }) 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 2a991aa9ef..56c56b71ba 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 @@ -16,15 +16,14 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { const nativeEnvironment = state.env const ready = Promise.withResolvers() const seen: Record[] = [] - let sequence = 0 const methods: HostMethods = { ready: () => { ready.resolve(true) }, - invoke: async (id, global, name, args) => { - seen.push({ type: 'call', id, global, name, args }) + invoke: async (global, name, args) => { + seen.push({ type: 'call', global, name, args }) return encodePtcJsonWire(42) }, - log: (id, text) => { seen.push({ type: 'log', id, text }) }, - outputLimit: (id) => { seen.push({ type: 'output-limit', id }) }, + log: (text) => { seen.push({ type: 'log', text }) }, + outputLimit: () => { seen.push({ type: 'output-limit' }) }, } const peer = createProcessRpc(host, limit, methods, [], () => {}) const main = runPersistentMain(child, limit, state) @@ -32,9 +31,8 @@ async function setup(limit = 4096, chdir: (cwd: string) => void = () => {}) { onTestFinished(close) await ready.promise const run = (code: string, namespaces: ProgramBootData['namespaces'] = [], maxOutputBytes = 1024) => { - const id = ++sequence - return peer.run(id, '/', { code, namespaces, maxOutputBytes }).then((result) => { - seen.push({ type: 'done', id, ...result }) + return peer.run('/', { code, namespaces, maxOutputBytes }).then((result) => { + seen.push({ type: 'done', ...result }) return result }) } @@ -107,20 +105,6 @@ it('contains directory failures and output overflow in the active evaluation', a expect(h.seen.some(item => item.type === 'output-limit')).toBe(true) }) -it('rejects overlapping host evaluations without replacing the active run', async () => { - const h = await setup() - const entered = Promise.withResolvers() - const release = Promise.withResolvers>() - h.setCall(() => { entered.resolve(undefined); return release.promise }) - const running = h.run('await tools.echo({})', [{ global: 'tools', names: ['echo'] }]) - try { - await entered.promise - await expect(h.run('1')).rejects.toThrow('already has an active evaluation') - release.resolve(encodePtcJsonWire(42)) - expect(decodePtcJsonWire((await running).value)).toBe(42) - } finally { release.resolve(encodePtcJsonWire(null)); await running } -}) - it('closes the process channel when a terminal result cannot fit its write budget', async () => { const h = await setup(1024) await expect(h.run('"x".repeat(2000)', [], 8000)).rejects.toThrow() @@ -135,7 +119,7 @@ it('ignores output and rejects tool calls from a completed evaluation', async () expect(decodePtcJsonWire(result.value)).toContain('evaluation has ended') expect(h.seen.some(item => item.type === 'log' || item.type === 'call')).toBe(false) expect(decodePtcJsonWire((await h.run('await tools.echo({})', declarations)).value)).toBe(42) - expect(h.seen.find(item => item.type === 'call')?.id).toBe(3) + expect(h.seen.filter(item => item.type === 'call')).toHaveLength(1) }) it('rejects unfinished binding waits when a program returns', async () => { 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 73e80ab60a..1cdfa42838 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 @@ -28,14 +28,14 @@ async function setup(limit = 4096) { const methods: HostMethods = { ready: () => { ready.resolve(undefined) }, invoke: async () => encodePtcJsonWire(42), - log: (_id, text) => { logs.push(text) }, + log: (text) => { logs.push(text) }, outputLimit: () => {}, } const peer = createProcessRpc(host, limit, methods, [], () => {}) const main = runNodeMain(child, limit, state) onTestFinished(async () => { peer.$close(); child.destroy(); await main }) await ready.promise - const run = (code: string, maxOutputBytes = 1024) => peer.run(1, '/', { + const run = (code: string, maxOutputBytes = 1024) => peer.run('/', { code, namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes, }) return { child, host, state, nativeEnvironment, methods, peer, main, logs, run } diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts index 97c7398421..39130c431d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/process.spec.ts @@ -31,7 +31,7 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep if (!(control instanceof Duplex)) { child.kill(); await finished; throw new Error('missing child control channel') } const channel = createProcessRpc(control, 100_000, { ready() { - void channel.run(1, directory, { + void channel.run(directory, { code: 'const answer = await tools.echo({ n: 21 }); return { answer, env: { ...process.env } }', namespaces: [{ global: 'tools', names: ['echo'] }], maxOutputBytes: 10_000, @@ -40,7 +40,7 @@ it('boots an unbuilt source closure outside the workspace and exchanges tool rep else completed.resolve(decodePtcJsonWire(result.value)) }, (error: unknown) => { completed.reject(error) }) }, - async invoke(_evaluation, _global, _name, args) { + async invoke(_global, _name, args) { expect(decodePtcJsonWire(args)).toEqual({ n: 21 }) return encodePtcJsonWire(42) }, diff --git a/scripts/ssh-helper/worker-probe.ts b/scripts/ssh-helper/worker-probe.ts index 99c4519b50..639564d33f 100644 --- a/scripts/ssh-helper/worker-probe.ts +++ b/scripts/ssh-helper/worker-probe.ts @@ -45,7 +45,7 @@ export async function probeRestrictedWorker( // Accepting only readiness rejects any other worker message as a protocol failure. const channel = createProcessRpc>(control, 1_048_576, { ready() { - void channel.run(1, workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => { + void channel.run(workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => { if (result.error !== undefined) completed.reject(new Error(JSON.stringify(result.error))) else completed.resolve(decodePtcJsonWire(result.value)) channel.$close() From 427d62d4a722e7a9dde28f5138de71a554070295 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 20 Oct 2026 15:47:06 +0800 Subject: [PATCH 3/8] fix(ptc-runtime-node): fail fast on mismatched bootstraps and late replies - Reject a control line that does not start with "{", so another release's length-prefixed frame fails as a protocol error instead of waiting for a newline until the deadline. - Bump SSH_PROTOCOL_VERSION to 3: executable helpers embed the PTC worker, so an older helper is now rejected while connecting. - Send a small error instead of a late binding result from a finished persistent evaluation, so it cannot overflow the pipe shared with the next evaluation. - Leave per-call binding waits pending after the result instead of rejecting them; unawaited calls crashed the child and leaked the trace into the result logs. - Pin birpc to 4.2.0 so the host and the bundled child agree, and regenerate THIRD_PARTY_NOTICES.md. - Update stale frame wording. --- THIRD_PARTY_NOTICES.md | 1 + docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../ptc-runtime-node/README.i18n.yaml | 12 ++++++------ .../ptc-runtime/ptc-runtime-node/README.md | 5 +++-- .../ptc-runtime/ptc-runtime-node/README.zh.md | 5 +++-- .../ptc-runtime/ptc-runtime-node/package.json | 2 +- .../ptc-runtime-node/src/bootstrap.ts | 3 +-- .../ptc-runtime/ptc-runtime-node/src/config.ts | 2 +- .../ptc-runtime/ptc-runtime-node/src/index.ts | 2 +- .../ptc-runtime-node/src/json-wire.ts | 2 +- .../ptc-runtime/ptc-runtime-node/src/launch.ts | 2 +- .../ptc-runtime-node/src/persistent.ts | 9 +++++++-- .../ptc-runtime-node/src/process.ts | 8 ++------ .../ptc-runtime/ptc-runtime-node/src/rpc.ts | 6 ++++-- .../tests/host-failures.spec.ts | 16 +++++++++++++++- .../tests/persistent-host.spec.ts | 18 ++++++++++++++++++ .../ptc-runtime-node/tests/persistent.spec.ts | 8 ++++++++ .../tests/process-main.spec.ts | 4 ++-- .../ptc-runtime-node/tests/raw-peer.ts | 2 ++ .../ptc-runtime-node/tests/rpc.spec.ts | 5 +++-- .../ptc-runtime-node/tests/runtime.spec.ts | 9 +++++++++ packages/ssh/ssh/src/protocol.ts | 4 ++-- .../ssh/ssh/tests/connection-disposal.spec.ts | 2 +- packages/ssh/ssh/tests/fixtures/helper.ts | 2 +- .../ssh/ssh/tests/helper-boundaries.spec.ts | 10 +++++----- .../ssh/ssh/tests/startup-behavior.spec.ts | 8 ++++---- pnpm-lock.yaml | 2 +- 28 files changed, 105 insertions(+), 48 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 43b8d64815..c349501785 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -78,6 +78,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`ajv`](https://github.com/ajv-validator/ajv) | MIT | | [`anser`](https://github.com/IonicaBizau/anser) | MIT | | [`big.js`](https://github.com/MikeMcl/big.js) | MIT | +| [`birpc`](https://github.com/antfu-collective/birpc) | MIT | | [`buffer`](https://github.com/feross/buffer) | MIT | | [`chokidar`](https://github.com/paulmillr/chokidar) | MIT | | [`chrome-devtools-frontend`](https://github.com/ChromeDevTools/devtools-frontend) | BSD-3-Clause | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4a9e624257..2ec39a506c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2604,7 +2604,7 @@ export interface Config { maxOutputBytes?: number /** V8 old-generation heap limit in MiB; native allocations are excluded. */ maxOldGenerationSizeMb?: number - /** Maximum control frame, outstanding argument and queued control-output bytes. */ + /** Maximum control message, outstanding binding-argument and queued control-output bytes. */ maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f77cb50cba..f80c5ce7a0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2606,7 +2606,7 @@ export interface Config { maxOutputBytes?: number /** V8 old-generation heap limit in MiB; native allocations are excluded. */ maxOldGenerationSizeMb?: number - /** Maximum control frame, outstanding argument and queued control-output bytes. */ + /** Maximum control message, outstanding binding-argument and queued control-output bytes. */ maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index c55ae36db8..3f0fbcc386 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -18,8 +18,8 @@ en: 246231cf2a529550 zh: 5d27b8e444ecd0f7 /deepseek-ai-dsh-ptc-runtime-node/use-this-package/configuration: - en: 161f3b32c782bab4 - zh: bd923e3d31261032 + en: 7e332cfe9cf45896 + zh: 82cfb08dce3425ab /deepseek-ai-dsh-ptc-runtime-node/use-this-package/execution-and-results: en: c79baab0c0a49ca7 zh: b90b78d0d75280fb @@ -36,8 +36,8 @@ en: 9020b491a39e4bf5 zh: bcd1b2b1a3bde7f3 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control: - en: a11171255bbe78ec - zh: 101a178062b7eed0 + en: cf48f246710f89d9 + zh: 423c25a365155223 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps: en: f9d796233bfb3b79 zh: f7c5803a6ccdf9c2 @@ -54,8 +54,8 @@ en: 40294c1c31b74824 zh: d91e7b78c0619c7e /deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work: - en: 7b8d67207e35bbb3 - zh: 246cd770e79078ac + en: c99f20872d7694d0 + zh: b3a3193572afd8f3 /deepseek-ai-dsh-ptc-runtime-node/known-limitations-and-deferred-work/dev-note: en: 6373aba4e08f7205 zh: 2a8c33d1f2839df4 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index 83d14f3f3a..bc7e2f56c5 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -51,7 +51,7 @@ Configure the provider row after its required services are available: | `maxTimeoutMs` | `600,000` | Elapsed deadline ceiling applied by the resolver | | `maxOutputBytes` | `67,108,864` | Combined serialized logs and completion or diagnostic budget | | `maxOldGenerationSizeMb` | `512` | V8 old-generation heap limit in MiB | -| `maxMessageBytes` | `134,217,728` | Limit for a control frame, outstanding argument bytes and queued control writes | +| `maxMessageBytes` | `134,217,728` | Limit for a control message, outstanding binding-argument bytes and queued control writes | | `maxPendingCalls` | `128` | Maximum simultaneous host binding calls | | `graceMs` | `3,000` | Managed termination and output-drain grace | | `launch` | Local carrier | Explicit `{ kind: "node-script", executable, bootstrapPath? }` or `{ kind: "embedded", executable }` in the subprocess world | @@ -111,7 +111,7 @@ For per-call execution, the host strips erasable types, resolves the executable The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval. -Per-call completion rejects unfinished binding waits and stops captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. +Per-call completion leaves unfinished binding waits pending and stops binding calls and captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. ### Source and built bootstraps @@ -171,6 +171,7 @@ These limits qualify the execution guarantees and retained output. - **In-memory environments retain one process per live owner** — idle owners keep their process and heap until explicit release, owner disposal or failure. Fan-out can retain many processes; idle expiration remains deferred. - **Output caps reject rather than retain every byte** — spill can preserve only the bounded result delivered by this provider. - **Bindings are bounded at transport admission** — control limits do not bound the memory a host binding allocates while producing its result. +- **The control protocol has no version handshake** — a preinstalled `launch.bootstrapPath` or SSH helper must come from the client's release; another release's bootstrap fails at its first control message. - **The console shim has five methods** — `log`, `info`, `warn`, `error` and `debug`. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 4880ebb73f..9625b07ba2 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -51,7 +51,7 @@ kind: "package-reference" | `maxTimeoutMs` | `600,000` | 解析器应用的经过时间截止上限 | | `maxOutputBytes` | `67,108,864` | 序列化日志与完成值或诊断的合计预算 | | `maxOldGenerationSizeMb` | `512` | V8 老生代堆上限,单位 MiB | -| `maxMessageBytes` | `134,217,728` | 控制帧、未完成参数字节和排队控制写入的上限 | +| `maxMessageBytes` | `134,217,728` | 控制消息、未完成绑定参数字节和排队控制写入的上限 | | `maxPendingCalls` | `128` | 同时进行的 Host 绑定调用数量上限 | | `graceMs` | `3,000` | 受管终止与输出排空宽限时间 | | `launch` | 本地载体 | 子进程环境中显式的 `{ kind: "node-script", executable, bootstrapPath? }` 或 `{ kind: "embedded", executable }` | @@ -111,7 +111,7 @@ Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。 -单次执行完成后会拒绝尚未完成的绑定等待,并停止捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 +单次执行完成后,尚未完成的绑定等待保持挂起,并停止绑定调用和捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 ### 源代码与构建后 bootstrap @@ -171,6 +171,7 @@ Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host - **内存保留环境为每个存活所有者保留一个进程**——空闲所有者保留进程及堆内存,直到显式释放、所有者销毁或执行失败。并行创建多个所有者会保留多个进程;空闲过期机制留待后续处理。 - **输出上限拒绝超量内容,而不保留每个字节**——溢出只能保存本提供方交付的有界结果。 - **绑定在传输接纳时受限**——控制限制不约束 Host 绑定生成结果期间分配的内存。 +- **控制协议没有版本握手**——预装的 `launch.bootstrapPath` 或 SSH helper 必须来自与客户端相同的版本;其他版本的 bootstrap 会在第一条控制消息时失败。 - **console shim 有五个方法**——`log`、`info`、`warn`、`error` 与 `debug`。 diff --git a/packages/ptc-runtime/ptc-runtime-node/package.json b/packages/ptc-runtime/ptc-runtime-node/package.json index 9d181b7c89..8c5dcbd24e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/package.json +++ b/packages/ptc-runtime/ptc-runtime-node/package.json @@ -46,7 +46,7 @@ "dependencies": { "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/schemastery": "workspace:~", - "birpc": "^4.2.0" + "birpc": "4.2.0" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts index 30c4425209..53ba44a73b 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -281,9 +281,8 @@ export function makeNamespaces( const errorClass = errorClasses.get(global) try { let detached: ReturnType - try { detached = snapshotPtcJsonValue(args) } catch (error: unknown) { + try { detached = snapshotPtcJsonValue(args) } catch { // Model-defined getters can fail while arguments are detached. - void error detached = undefined } if (detached === undefined) throw new CapturedError('binding arguments must be lossless JSON') diff --git a/packages/ptc-runtime/ptc-runtime-node/src/config.ts b/packages/ptc-runtime/ptc-runtime-node/src/config.ts index 18bebfe099..cd2fee976e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/config.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/config.ts @@ -17,7 +17,7 @@ export interface Config { maxOutputBytes?: number /** V8 old-generation heap limit in MiB; native allocations are excluded. */ maxOldGenerationSizeMb?: number - /** Maximum control frame, outstanding argument and queued control-output bytes. */ + /** Maximum control message, outstanding binding-argument and queued control-output bytes. */ maxMessageBytes?: number /** Maximum simultaneous host binding calls accepted from a program. */ maxPendingCalls?: number diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index 93b6816b8f..bee2403ef6 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -321,7 +321,7 @@ export class NodePtcRuntime extends PtcRuntime { }) channel = transport void launched.done.then((outcome) => { - // Allow queued control-frame callbacks to run before classifying a command exit. + // Allow queued control messages to run before classifying a command exit. setImmediate(() => { processFinished(outcome) }) }, (error: unknown) => { finish({ kind: confined !== undefined && isRunnerSpawnFailure(error, confined.argv[0], spec.cwd) ? 'sandbox-unavailable' : 'worker-exit', message: messageOf(error) }) }) } catch (error: unknown) { diff --git a/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts b/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts index 2a15d68383..6fc8b29325 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts @@ -1,5 +1,5 @@ /** - * Lossless-JSON snapshots for the dependency-free source bootstrap closure. + * Lossless-JSON snapshots shared by the host and the source bootstrap closure. * @module @deepseek-ai/dsh-ptc-runtime-node/json-wire */ diff --git a/packages/ptc-runtime/ptc-runtime-node/src/launch.ts b/packages/ptc-runtime/ptc-runtime-node/src/launch.ts index ecf3084f70..9e32b13741 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/launch.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/launch.ts @@ -37,7 +37,7 @@ export function resolveLaunch(config?: LaunchConfig): LaunchConfig { * Select explicit arguments without inheriting host loader or inspector flags. * @param fs - Filesystem mapping host bootstrap assets into the process world. * @param config - resolved worker invocation. - * @param maxMessageBytes - Validated frame and queued-write limit. + * @param maxMessageBytes - Validated message and queued-write limit. * @param persistent - Select the in-memory REPL bootstrap instead of the one-shot evaluator. * @returns Arguments following the resolved Node executable. */ diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index f89b4bfe7c..7003f81bf8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -247,7 +247,12 @@ class Kernel { const fn = functions !== undefined && Object.hasOwn(functions, name) ? functions[name] : undefined evaluation.pending += 1 evaluation.pendingBytes += bytes - try { return await evaluation.invoke(fn, args) } finally { + try { + const value = await evaluation.invoke(fn, args) + // The child already rejected this wait; a late result must not fill the pipe shared with the next evaluation. + if (this.active !== evaluation) throw new Error('PTC evaluation ended before the binding completed') + return value + } finally { evaluation.pending -= 1 evaluation.pendingBytes -= bytes } @@ -259,7 +264,7 @@ class Kernel { void handle.done.then(async (outcome) => { this.exitOutcome = outcome await Promise.all([drainOutput(handle.stdout, this.config.graceMs), drainOutput(handle.stderr, this.config.graceMs)]) - // The terminal control frame can already be queued when process exit arrives. + // The run result can already be queued when process exit arrives. setImmediate(() => { if (!this.closed) this.fail(this.confined !== undefined && classifyRunnerFailure(outcome.exitCode, this.stderr, this.confined.runnerFailureRules) !== undefined ? 'sandbox-unavailable' : 'worker-exit', this.exitMessage(outcome)) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 3368a18d08..31ba28f962 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -25,20 +25,16 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) const hostClosed = Promise.withResolvers() - // Set once the result is returned or the endpoint closes; the child then sends nothing more. + // Set once the result is returned or the endpoint closes; later calls stay pending and output is dropped. let ended = false const connection = createProcessRpc(stream, maxMessageBytes, { async run(_cwd, data) { const result = await runProgram({ - invoke: (global, name, args) => { - if (ended) throw new Error('PTC evaluation has ended') - return connection.invoke(global, name, args) - }, + invoke: (global, name, args) => ended ? new Promise(() => {}) : connection.invoke(global, name, args), log: (text) => { if (!ended) void connection.log(text) }, outputLimit: () => { if (!ended) void connection.outputLimit() }, }, data, processState) ended = true - void connection.$rejectPendingCalls(({ reject }) => { reject(new Error('PTC evaluation ended before the binding completed')) }) return result }, }, ['ready', 'log', 'outputLimit'], (_error, kind) => { diff --git a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts index b36d7e09f8..6f0d0e647a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts @@ -45,8 +45,8 @@ export function createProcessRpc( else stream.write(line) }, deserialize: (line: string) => { - const message: unknown = parse(line) - if (!record(message)) throw new Error('invalid RPC frame') + // A line that parses after the leading-brace check is a JSON object. + const message = parse(line) as Record if (message.t === 'q') { if (typeof message.m !== 'string' || !Object.hasOwn(functions, message.m) || !Array.isArray(message.a)) throw new Error('invalid RPC method call') } else if (message.t !== 's') { @@ -68,6 +68,8 @@ export function createProcessRpc( let parts: Buffer[] = [] let size = 0 const buffer = (part: Buffer): void => { + // Every message is a JSON object; another release's length-prefixed frame would otherwise wait for a newline. + if (size === 0 && part.length > 0 && part[0] !== 0x7b) throw new Error('control message is not a JSON object; the Node bootstrap may be from another release') size += part.length if (size > maxMessageBytes) throw new Error(`RPC message exceeds ${maxMessageBytes} bytes`) parts.push(part) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 15560b413d..594a67bb9f 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -299,6 +299,20 @@ describe('Node runtime host failures', () => { expect((await h.start()).error).toEqual({ kind: 'worker-exit', message: 'control transport closed' }) }) + it('fails at once when a bootstrap from another release sends a length-prefixed frame', async () => { + const h = await setup() + const body = Buffer.from(JSON.stringify({ type: 'ready' })) + const header = Buffer.alloc(4) + header.writeUInt32BE(body.length) + h.spawn.mockImplementation(() => { + queueMicrotask(() => { h.control.push(Buffer.concat([header, body])) }) + return h.handle + }) + expect((await h.start(request, NO_INITIAL_FRAME)).error).toEqual({ + kind: 'protocol', message: 'control message is not a JSON object; the Node bootstrap may be from another release', + }) + }) + it('classifies invalid UTF-8 on the live control stream as a protocol failure', async () => { const h = await setup() h.onBoot(() => { h.control.push(Buffer.from([0xff, 10])) }) @@ -306,7 +320,7 @@ describe('Node runtime host failures', () => { }) it.each<[unknown, string]>([ - [null, 'invalid RPC frame'], + [null, 'not a JSON object'], [{ t: 'q', m: 'ready', a: [] }, 'duplicate program readiness'], [{ t: 'q', m: 'log', a: [1] }, 'invalid program log'], [{ t: 'q', m: 'invoke', a: [null, 'test', []], i: 'call' }, 'undeclared binding'], diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index ee419a0053..f231ab9bde 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -345,6 +345,24 @@ describe('persistent PTC host protocol', () => { expect(result.error?.message).toContain('fatal output') }) + it('does not send a late binding result into the next evaluation', async () => { + const h = await setup({ maxMessageBytes: 1024 }) + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve('') }) + const slow = { ...request, bindings: [{ global: 'tools', functions: { test: () => release.promise } }] } + h.onBoot(() => { h.emit(call()); h.complete({}) }) + expect((await h.start(slow)).error).toBeUndefined() + const booted = Promise.withResolvers() + h.onBoot(() => { booted.resolve(undefined) }) + const next = h.start(slow, NO_INITIAL_FRAME) + await booted.promise + release.resolve('x'.repeat(2000)) + await setImmediate() + h.complete({ value: encodePtcJsonWire(2) }) + expect(await next).toMatchObject({ value: 2 }) + expect(h.messages).toContainEqual({ t: 's', i: '1', e: { message: 'PTC evaluation ended before the binding completed' } }) + }) + it('terminates the process when the control pipe fails during an evaluation', async () => { const h = await setup() h.onBoot(() => { h.control.emit('error', new Error('control transport failed')) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts index a0fbe1b587..31765a222e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent.spec.ts @@ -49,6 +49,14 @@ describe('persistent Node REPL', () => { expect(spawn).not.toHaveBeenCalled() }) + it('keeps the environment when an evaluation ends with an unawaited binding call', async () => { + const { run } = await setup() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(null) }) + expect((await run('var kept = 1; void tools.value({}); kept', { bindings: bindings(() => release.promise) })).value).toBe(1) + expect((await run('kept')).value).toBe(1) + }) + it('retains declarations, closures, object identity, and imports across evaluations', async () => { const { run, cwd } = await setup() expect(await run('const box: { count: number } = { count: 1 }; let alias = box; function increment() { return ++box.count }; const path = await import("node:path");')).toMatchObject({ logs: [] }) 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 1cdfa42838..113d549bdb 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 @@ -55,12 +55,12 @@ it('clears the environment and returns a binding result after captured output', expect(h.state.exitCode).toBeUndefined() }) -it('rejects unfinished calls and suppresses later logs while awaiting host shutdown', async () => { +it('keeps unfinished calls pending and suppresses later logs while awaiting host shutdown', async () => { const h = await setup() const release = Promise.withResolvers>() h.methods.invoke = () => release.promise try { - expect(decodePtcJsonWire((await h.run('void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42) + expect(decodePtcJsonWire((await h.run('void tools.echo({}); void tools.echo({}).catch(() => console.log("late rejection")); setImmediate(() => console.log("late timer")); return 42;')).value)).toBe(42) await nextTurn() expect(h.child.destroyed).toBe(false) release.resolve(encodePtcJsonWire(42)) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts b/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts index 9597508c52..6bacb37a42 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/raw-peer.ts @@ -11,6 +11,8 @@ import type { Duplex } from 'node:stream' export function rawPeer(stream: Duplex, receive: (message: unknown) => void) { const lines = createInterface({ input: stream, crlfDelay: Infinity }) lines.on('line', (line) => { receive(JSON.parse(line)) }) + // The endpoint under test can destroy the shared stream pair; it reports that failure itself. + lines.on('error', (error: Error) => { void error }) return { send: (message: unknown): Promise => new Promise((resolve, reject) => { stream.write(`${JSON.stringify(message)}\n`, (error) => { if (error) reject(error); else resolve() }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts index fe2fb475e5..18569fd032 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts @@ -49,7 +49,7 @@ it('does not invoke a method received beside traffic that closed the endpoint', const allowed = vi.fn() const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) }) onTestFinished(() => { endpoint.$close() }) - remote.write(`null\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`) + remote.write(`${JSON.stringify({ t: 'unknown' })}\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`) expect(await failure.promise).toBe('protocol') await new Promise(resolve => setImmediate(resolve)) expect(allowed).not.toHaveBeenCalled() @@ -137,7 +137,8 @@ it.each([ [Buffer.from([0xff, 10]), false], [Buffer.from('{'), true], [Buffer.from([0xe2]), true], - [Buffer.from('x'.repeat(65)), false], + [Buffer.from(`{${'x'.repeat(64)}`), false], + [Buffer.from([0, 0, 0, 2, 123, 125]), false], ])('rejects invalid or oversized JSON lines before dispatch (%j, EOF %s)', async (bytes, eof) => { const { local, remote } = endpoints() const failed = Promise.withResolvers() diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts index 5fd5e42c92..8dabf78713 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/runtime.spec.ts @@ -103,6 +103,15 @@ describe('Node program process', () => { expect(result.value).toEqual({ value: { n: 42 }, rejected: [true, 'ToolCallError', 'fail', 'denied'] }) }) + it('returns cleanly while an unawaited binding call is still running', async () => { + const { run } = await setup() + const release = Promise.withResolvers() + onTestFinished(() => { release.resolve(null) }) + const result = await run({ program: 'void tools.slow({}); return 1', bindings: bindings({ slow: () => release.promise }) }) + expect(result).toMatchObject({ logs: [], value: 1 }) + expect(result.error).toBeUndefined() + }) + it('retains raw native stdout and stderr separately from control frames', async () => { const { run } = await setup() const result = await run({ program: 'const fs = await import("node:fs"); fs.writeSync(1,"native-out你好"); fs.writeSync(2,"native-err🙂"); return 42;', bindings: [] }) diff --git a/packages/ssh/ssh/src/protocol.ts b/packages/ssh/ssh/src/protocol.ts index 500b995084..d4995617c1 100644 --- a/packages/ssh/ssh/src/protocol.ts +++ b/packages/ssh/ssh/src/protocol.ts @@ -6,8 +6,8 @@ import type { Readable, Writable } from 'node:stream' import { z } from 'zod' import type { Branded } from '@deepseek-ai/dsh-brand' -/** Wire version shared by the installed helper and client package. */ -export const SSH_PROTOCOL_VERSION = 2 +/** Wire version shared by the client package, the installed helper and its embedded PTC worker. */ +export const SSH_PROTOCOL_VERSION = 3 /** Maximum prepared or running process handles owned by one helper. */ export const SSH_MAX_PROCESS_HANDLES = 128 diff --git a/packages/ssh/ssh/tests/connection-disposal.spec.ts b/packages/ssh/ssh/tests/connection-disposal.spec.ts index c31b366658..fd8d8edacd 100644 --- a/packages/ssh/ssh/tests/connection-disposal.spec.ts +++ b/packages/ssh/ssh/tests/connection-disposal.spec.ts @@ -68,7 +68,7 @@ async function setup(phase: 'connect' | 'authenticate', pauseControl?: 'forward' if (method === 'heartbeat') { heartbeatCalls++; return holdHeartbeat ? heartbeat.promise : null } if (method === 'ordinary') { ordinaryEntered.resolve(undefined); return ordinary.promise } if (method !== 'hello') throw new Error(`unexpected helper operation: ${method}`) - return { protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' } + return { protocol: 3, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/usr/bin/node', root: '/tmp/remote-helper', workspace: '/workspace' } }) const rawSocket = new PendingSocket() const secureSocket = new PendingSocket() diff --git a/packages/ssh/ssh/tests/fixtures/helper.ts b/packages/ssh/ssh/tests/fixtures/helper.ts index fa69ca63c8..4bfa62b079 100644 --- a/packages/ssh/ssh/tests/fixtures/helper.ts +++ b/packages/ssh/ssh/tests/fixtures/helper.ts @@ -19,7 +19,7 @@ export async function createHelperHarness(handshake = true, leaseMs = 30_000, ki void serving.catch(() => {}) const client = new SshRpcPeer(output, input, 64 * 1024 * 1024, 128) const sockets = new Set() - const hello = () => client.request('hello', { protocol: 2, workspace: root, leaseMs }, helloSchema) + const hello = () => client.request('hello', { protocol: 3, workspace: root, leaseMs }, helloSchema) let closing: Promise | undefined const close = () => { closing ??= (async () => { diff --git a/packages/ssh/ssh/tests/helper-boundaries.spec.ts b/packages/ssh/ssh/tests/helper-boundaries.spec.ts index aebbd5a12a..78417de767 100644 --- a/packages/ssh/ssh/tests/helper-boundaries.spec.ts +++ b/packages/ssh/ssh/tests/helper-boundaries.spec.ts @@ -49,13 +49,13 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou const test = await createHelperHarness(false) try { for (const params of [ - { protocol: 1, workspace: test.root, leaseMs: 3000 }, - { protocol: 2, workspace: 'relative', leaseMs: 3000 }, - { protocol: 2, workspace: test.root, leaseMs: 2999 }, + { protocol: 2, workspace: test.root, leaseMs: 3000 }, + { protocol: 3, workspace: 'relative', leaseMs: 3000 }, + { protocol: 3, workspace: test.root, leaseMs: 2999 }, ]) await expect(test.client.request('hello', params, helloSchema)).rejects.toThrow() const bootstrapPath = `${test.root}/bootstrap.js` await writeFile(bootstrapPath, 'export const identity = "test"\n') - const facts = await test.client.request('hello', { protocol: 2, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema) + const facts = await test.client.request('hello', { protocol: 3, workspace: test.root, leaseMs: 3000, bootstrapPath }, helloSchema) expect(facts.bootstrapHash).toBe(createHash('sha256').update(await readFile(bootstrapPath)).digest('hex')) } finally { await test.close() } }) @@ -63,7 +63,7 @@ describe.skipIf(process.platform === 'win32')('SSH helper wire and lifecycle bou it('refuses an external bootstrap for an executable helper', async () => { const test = await createHelperHarness(false, 30_000, 'executable') try { - await expect(test.client.request('hello', { protocol: 2, workspace: test.root, leaseMs: 3000, bootstrapPath: '/external/process.js' }, helloSchema)) + await expect(test.client.request('hello', { protocol: 3, workspace: test.root, leaseMs: 3000, bootstrapPath: '/external/process.js' }, helloSchema)) .rejects.toThrow('embedded PTC bootstrap') expect((await test.hello()).kind).toBe('executable') } finally { await test.close() } diff --git a/packages/ssh/ssh/tests/startup-behavior.spec.ts b/packages/ssh/ssh/tests/startup-behavior.spec.ts index f02c9c5176..270666f270 100644 --- a/packages/ssh/ssh/tests/startup-behavior.spec.ts +++ b/packages/ssh/ssh/tests/startup-behavior.spec.ts @@ -56,7 +56,7 @@ const config: Config = { requestTimeoutMs: 1000, maxFrameBytes: 4096, maxPending: 8, leaseMs: 30_000, } const hello = { - protocol: 2, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', + protocol: 3, hash: 'a'.repeat(64), platform: 'linux', nodeVersion: 'v24.19.0', kind: 'node-script', executable: '/canonical/node', root: '/tmp/remote-helper', workspace: '/canonical/workspace', } @@ -160,7 +160,7 @@ describe.skipIf(process.platform === 'win32')('SSH connection startup', () => { expect(argv).toContain('StrictHostKeyChecking=yes') expect(argv).toContain('ForwardAgent=no') expect(argv.at(-1)).toBe("'/remote/node' '--disable-sigusr1' '/remote/helper'\\''s file.js'") - expect(test.calls[0]?.params).toEqual({ protocol: 2, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' }) + expect(test.calls[0]?.params).toEqual({ protocol: 3, workspace: '/remote/workspace', leaseMs: 30_000, bootstrapPath: '/remote/process.js' }) }) it('permits filesystem-only deployments and refuses an unconfigured PTC bootstrap getter', async () => { @@ -179,13 +179,13 @@ describe.skipIf(process.platform === 'win32')('SSH connection startup', () => { expect(test.service.ptcLaunch).toEqual({ kind: 'embedded', executable: '/canonical/dsh-ssh-helper' }) const argv = transport.spawn.mock.calls[0]?.[1] as string[] expect(argv.at(-1)).toBe("NODE_OPTIONS='--disable-sigusr1' '/remote/helper with spaces'") - expect(test.calls[0]?.params).toEqual({ protocol: 2, workspace: '/remote/workspace', leaseMs: 30_000 }) + expect(test.calls[0]?.params).toEqual({ protocol: 3, workspace: '/remote/workspace', leaseMs: 30_000 }) }) it.each([ { hello: { hash: 'c'.repeat(64) }, error: 'helper digest' }, { hello: { bootstrapHash: 'c'.repeat(64) }, error: 'bootstrap digest' }, - { hello: { protocol: 1 }, error: 'Invalid' }, + { hello: { protocol: 2 }, error: 'Invalid' }, { hello: { kind: 'executable' }, error: 'launch kind' }, ])('refuses a mismatched helper identity before readiness: $error', async ({ hello, error }) => { const test = setup({ hello }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98d5a74503..10eece2a4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12088,7 +12088,7 @@ importers: specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery birpc: - specifier: ^4.2.0 + specifier: 4.2.0 version: 4.2.0 devDependencies: '@deepseek-ai/cordis': From 7bab95f8dae5295bf0f88cf2806a21342bf2752c Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 20 Oct 2026 23:28:27 +0800 Subject: [PATCH 4/8] refactor(ptc-runtime-node): rely on birpc defaults for message handling Keep the stream side owned here: line bounds, the leading-brace check, the write-queue limit and closure. On the birpc side, drop the strict envelope validation, falsy-rejection normalization and fatal UTF-8 decoding. Calls to methods an endpoint does not own are answered with birpc's error and unknown messages are ignored; the resolver still runs only own methods and none after the endpoint closes. --- .../ptc-runtime-node/README.i18n.yaml | 8 ++--- .../ptc-runtime/ptc-runtime-node/README.md | 4 +-- .../ptc-runtime/ptc-runtime-node/README.zh.md | 4 +-- .../ptc-runtime/ptc-runtime-node/src/rpc.ts | 34 +++++++----------- .../tests/host-failures.spec.ts | 13 +++++-- .../tests/persistent-host.spec.ts | 3 +- .../ptc-runtime-node/tests/rpc.spec.ts | 35 +++++++++++-------- scripts/ssh-helper/worker-probe.ts | 2 +- 8 files changed, 52 insertions(+), 51 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 3f0fbcc386..406ffbfb31 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml +++ b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml @@ -30,14 +30,14 @@ en: ad72b8bdba6a3966 zh: 28a20fb5fb7c622d /deepseek-ai-dsh-ptc-runtime-node/use-this-package/failures: - en: 7682e50f9dcfa25c - zh: ddcb3502e12be06f + en: 0ba661c7030f368c + zh: dc2be3553eda447d /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation: en: 9020b491a39e4bf5 zh: bcd1b2b1a3bde7f3 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/launch-and-control: - en: cf48f246710f89d9 - zh: 423c25a365155223 + en: 94e08458a48b8c16 + zh: 48087aaa4ef028b6 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-and-built-bootstraps: en: f9d796233bfb3b79 zh: f7c5803a6ccdf9c2 diff --git a/packages/ptc-runtime/ptc-runtime-node/README.md b/packages/ptc-runtime/ptc-runtime-node/README.md index bc7e2f56c5..a912f16f2d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -93,7 +93,7 @@ Omitting `timeoutMs` uses the configured elapsed default; numeric requests are v ### Failures -Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; malformed or excessive control traffic is `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit or failed managed cleanup is `worker-exit`. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse. +Program parse errors and thrown exceptions are `exception`; deadline expiry is `timeout`; cancellation is `abort`; unparsable or oversized control traffic and invalid program messages are `protocol`; unavailable confinement is `sandbox-unavailable`; early process exit or failed managed cleanup is `worker-exit`. The substrate-independent failure name remains `worker-exit` for process providers. Lossy completions are `invalid-output`, and an oversized outer result is `output-limit`, retaining the fitting log prefix. Invalid or unsupported options and calls after disposal reject as caller misuse. ----- @@ -109,7 +109,7 @@ The host owns policy, deadlines, binding lookup and process cleanup. The child o For per-call execution, the host strips erasable types, resolves the executable and bootstrap in the configured execution world, awaits argv confinement through `ctx.sandbox`, then spawns through `ctx.subprocess`. Cancellation is checked again after confinement, so a provider returning after cancellation cannot start the program. After adopting the inherited control channel, the child retains only executable-search, Windows system, and temporary paths in its OS environment and replaces the program-visible `process.env` with an empty dictionary. Windows ACL setup receives the parent's distinct `TEMP` and `TMP` values for shared grant locks, then replaces both with its private directory before starting the program. These native paths keep nested process creation and native temporary-file APIs functional. The host preserves `ELECTRON_RUN_AS_NODE` only for child startup so the Desktop executable runs the Node bootstrap; the bootstrap removes the selector before evaluating model code. The heap limit uses Node argv or a provider-created `NODE_OPTIONS` value for packaged executables; ambient loader and inspector flags are discarded. -The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds incomplete messages and the native write queue, validates UTF-8 and exposed methods, and rejects pending calls when the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval. +The host calls the child’s `run()` method through birpc; injected bindings call the host’s `invoke()` method, and captured output uses notifications. birpc owns request correlation, remote results, and rejection delivery. Newline-delimited JSON travels over the inherited control pipe separately from stdout/stderr. The RPC adapter bounds each line and the native write queue and rejects pending calls when the pipe closes; birpc answers calls to methods an endpoint does not own with an error, and runs no method after the pipe closes. The host validates binding names and lossless JSON before dispatch; one pipe delivers each evaluation's messages before its result, so messages carry no evaluation identity. Execution deadlines remain host-owned, including calls that await approval. Per-call completion leaves unfinished binding waits pending and stops binding calls and captured output notifications. The child keeps the control pipe open until the host receives its result and closes the pipe. Output capture meters serialized logs plus the completion or diagnostic; fixed result-envelope fields and sandbox metadata are outside that ledger. diff --git a/packages/ptc-runtime/ptc-runtime-node/README.zh.md b/packages/ptc-runtime/ptc-runtime-node/README.zh.md index 9625b07ba2..5c80003987 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -93,7 +93,7 @@ PTC 消费方按 [dsh-tools](../../core/tools/README.zh.md#ptc-mode) 的说明 ### 失败 -程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;畸形或超量控制通信为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。 +程序解析错误与抛出异常为 `exception`;截止到期为 `timeout`;取消为 `abort`;无法解析或超量的控制通信以及无效的程序消息为 `protocol`;约束不可用为 `sandbox-unavailable`;进程提前退出或受管清理失败为 `worker-exit`。进程提供方保留与执行基底无关的失败名 `worker-exit`。有损完成值为 `invalid-output`,外层结果超限为 `output-limit`,并保留能容纳的日志前缀。无效或不支持的选项,以及资源释放后的调用,以调用方误用拒绝。 ----- @@ -109,7 +109,7 @@ Host 负责策略、截止时间、绑定查找和进程清理。子进程负责 Host 擦除可擦除类型,在配置的执行世界中解析可执行文件与 bootstrap,通过 `ctx.sandbox` 等待 argv 限制准备完成,再通过 `ctx.subprocess` 启动。限制准备完成后会再次检查取消状态,因此提供方在取消后返回也无法启动程序。接管继承的控制通道后,子进程在 OS 环境中只保留可执行文件搜索路径、Windows 系统路径和临时路径,并将程序可见的 `process.env` 替换为空字典。Windows ACL 初始化接收父进程各自的 `TEMP` 和 `TMP` 值以使用共享授权锁,然后在启动程序前将二者替换为私有目录。这些原生路径使嵌套进程创建和原生临时文件 API 仍可正常工作。Host 仅在子进程启动时保留 `ELECTRON_RUN_AS_NODE`,使桌面端可执行文件运行 Node bootstrap;bootstrap 在求值模型代码前删除该选择变量。堆上限通过 Node argv 或为打包可执行文件由提供方构造的 `NODE_OPTIONS` 值传递;环境中的加载器和调试器标志会被丢弃。 -Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制未完成消息和原生写入队列的大小,校验 UTF-8 与公开方法,并在管道关闭时拒绝待完成调用。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。 +Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host 的 `invoke()` 方法,捕获的输出通过通知传递。birpc 负责请求关联、远程结果和异常传递。逐行 JSON 通过继承的控制管道传输,与 stdout/stderr 分离。RPC 适配层限制每行消息和原生写入队列的大小,并在管道关闭时拒绝待完成调用;对端点自身没有的方法,birpc 以错误回复,管道关闭后不再执行任何方法。Host 在分派前校验绑定名与无损 JSON;同一管道保证每次求值的消息先于其结果到达,因此消息不携带求值身份。执行截止时间仍由 Host 管理,包含等待审批的调用。 单次执行完成后,尚未完成的绑定等待保持挂起,并停止绑定调用和捕获输出的通知。子进程保持控制管道打开,直到 Host 收到结果并关闭管道。输出捕获计量序列化日志加完成值或诊断;固定结果信封字段与沙箱元数据不计入该账本。 diff --git a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts index 6f0d0e647a..deb14f199a 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/rpc.ts @@ -5,9 +5,7 @@ import { createBirpc, type BirpcReturn } from 'birpc' const stringify = JSON.stringify const parse = JSON.parse - -const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) -const toError = (value: unknown): Error => value instanceof Error ? value : new Error(String(value)) +const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) /** * Connect typed methods over one control endpoint; execution owns all deadlines. @@ -28,7 +26,8 @@ export function createProcessRpc( const fail = (error: unknown, kind: 'io' | 'protocol'): void => { if (rpc.$closed) return rpc.$close() - failure(toError(error), kind) + // Streams, the JSON parser and the line checks fail only with Error objects. + failure(error as Error, kind) } let receive: ((line: string) => void) | undefined const rpc = createBirpc(functions, { @@ -37,7 +36,7 @@ export function createProcessRpc( on: (listener) => { receive = listener }, off: () => { receive = undefined; stream.destroy() }, // JSON drops Error fields; the peer receives only the diagnostic. - serialize: (message: Record) => `${stringify(message.e instanceof Error ? { ...message, e: { message: message.e.message } } : message)}\n`, + serialize: (message: { e?: unknown }) => `${stringify(message.e === undefined ? message : { ...message, e: { message: messageOf(message.e) } })}\n`, post: (line: string) => { // A binding can settle after the endpoint closes; the Writable owns already queued bytes. if (rpc.$closed) return @@ -46,25 +45,16 @@ export function createProcessRpc( }, deserialize: (line: string) => { // A line that parses after the leading-brace check is a JSON object. - const message = parse(line) as Record - if (message.t === 'q') { - if (typeof message.m !== 'string' || !Object.hasOwn(functions, message.m) || !Array.isArray(message.a)) throw new Error('invalid RPC method call') - } else if (message.t !== 's') { - throw new Error('unknown RPC message') - } else if (message.e !== undefined) { - if (!record(message.e) || typeof message.e.message !== 'string') throw new Error('invalid RPC error') - return { ...message, e: new Error(message.e.message) } - } + const message = parse(line) as { e?: { message?: unknown } } + if (message.e) message.e = new Error(String(message.e.message)) return message }, - // Every line of a chunk dispatches before an earlier handler can close the endpoint. - resolver: (_name, fn) => async (...args: unknown[]) => { - if (rpc.$closed) return undefined - // birpc treats a falsy thrown value as success. - try { return await fn.apply(functions, args) } catch (error: unknown) { throw toError(error) } - }, + // Only own methods are callable, and none runs once the endpoint closes, + // including calls that arrived in the same chunk as the closing message. + resolver: (name, fn) => Object.hasOwn(functions, name) + ? (...args: unknown[]) => rpc.$closed ? undefined : fn.apply(functions, args) + : undefined, }) - const decoder = new TextDecoder('utf-8', { fatal: true }) let parts: Buffer[] = [] let size = 0 const buffer = (part: Buffer): void => { @@ -79,7 +69,7 @@ export function createProcessRpc( let start = 0 for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { buffer(chunk.subarray(start, end)) - const line = decoder.decode(Buffer.concat(parts, size)) + const line = Buffer.concat(parts, size).toString('utf8') parts = [] size = 0 start = end + 1 diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 594a67bb9f..50f07baaba 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -326,9 +326,6 @@ describe('Node runtime host failures', () => { [{ t: 'q', m: 'invoke', a: [null, 'test', []], i: 'call' }, 'undeclared binding'], [{ t: 'q', m: 'invoke', a: ['tools', 'constructor', []], i: 'call' }, 'undeclared binding'], [{ t: 'q', m: 'invoke', a: ['tools', 'test'], i: 'call' }, 'arguments must be lossless JSON'], - [{ t: 'q', m: 'constructor', a: [] }, 'invalid RPC method call'], - [{ t: 'q', m: 'toString', a: [] }, 'invalid RPC method call'], - [{ t: 'unknown' }, 'unknown RPC message'], ])('rejects malformed program traffic %j', async (frame, message) => { const h = await setup() const binding = vi.fn(async () => null) @@ -339,6 +336,16 @@ describe('Node runtime host failures', () => { expect(binding).not.toHaveBeenCalled() }) + it('answers calls to unknown methods without failing the run', async () => { + const h = await setup() + h.onBoot(() => { + h.emit({ t: 'q', i: 'unknown', m: 'constructor', a: [] }) + h.complete({ value: encodePtcJsonWire(42) }) + }) + expect((await h.start()).value).toBe(42) + expect(h.messages).toContainEqual({ t: 's', i: 'unknown', e: { message: '[birpc] function "constructor" not found' } }) + }) + it.each([null, { error: null }, { error: { kind: 'exception', message: 1 } }, { error: { kind: 'timeout', message: 'forged' } }])('rejects malformed run results %j', async (raw) => { const h = await setup() h.onBoot(() => { h.complete(raw) }) diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index f231ab9bde..2de95ce4b1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -209,7 +209,6 @@ it('rejects a retained call when its provider unloads during earlier policy clea describe('persistent PTC host protocol', () => { it.each([ null, - { t: 'q', m: 'other', a: [] }, { t: 'q', m: 'log', a: [1] }, call(1, 'not a wire value'), ])('rejects invalid process traffic %j', async (raw) => { @@ -228,7 +227,7 @@ describe('persistent PTC host protocol', () => { it('requires the readiness handshake and all requested pipes', async () => { const h = await setup() - expect((await h.start(request, { t: 'q', m: 'other', a: [] })).error?.kind).toBe('protocol') + expect((await h.start(request, null)).error?.kind).toBe('protocol') const missing = await setup() missing.spawn.mockReturnValue({ ...missing.handle, control: undefined }) expect((await missing.start(request, NO_INITIAL_FRAME)).error?.message).toContain('did not supply') diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts index 18569fd032..761164e8d1 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/rpc.spec.ts @@ -35,7 +35,8 @@ it('matches concurrent bidirectional results and preserves remote rejection mess expect(failures).not.toHaveBeenCalled() }) -it.each([undefined, null, false, 0, '', 'denied'])('rejects a remote call whose method throws %j', async (thrown) => { +it('delivers a non-Error rejection as its text', async () => { + const thrown: unknown = 'denied' const { local, remote } = endpoints() const left = createProcessRpc<{ fail(): never }, Record>(local, 4096, {}, [], () => {}) const right = createProcessRpc, { fail(): never }>(remote, 4096, { fail: () => { throw thrown } }, [], () => {}) @@ -49,7 +50,7 @@ it('does not invoke a method received beside traffic that closed the endpoint', const allowed = vi.fn() const endpoint = createProcessRpc(local, 4096, { allowed }, [], (_error, kind) => { failure.resolve(kind) }) onTestFinished(() => { endpoint.$close() }) - remote.write(`${JSON.stringify({ t: 'unknown' })}\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`) + remote.write(`{\n${JSON.stringify({ t: 'q', m: 'allowed', a: [] })}\n`) expect(await failure.promise).toBe('protocol') await new Promise(resolve => setImmediate(resolve)) expect(allowed).not.toHaveBeenCalled() @@ -98,23 +99,26 @@ it('ignores a response for another call and returns the matching result', async expect(await endpoint.value()).toBe('right') }) -it.each([ - null, - { t: 'q', m: 'constructor', a: [] }, - { t: 'q', m: 'allowed', a: null }, - { t: 's', i: 'call', e: 'unstructured error' }, - { t: 'unknown' }, -])('closes malformed RPC traffic before invoking local methods: %j', async (message) => { +it('answers calls to methods it does not own and ignores unknown messages', async () => { const { local, remote } = endpoints() - const failure = Promise.withResolvers<{ kind: string; error: Error }>() + const failures = vi.fn() const allowed = vi.fn() - const endpoint = createProcessRpc(local, 4096, { allowed }, [], (error, kind) => { failure.resolve({ error, kind }) }) - const raw = rawPeer(remote, () => {}) + const replies: unknown[] = [] + const answered = Promise.withResolvers() + const endpoint = createProcessRpc(local, 4096, { allowed }, [], failures) + const raw = rawPeer(remote, (message) => { + replies.push(message) + if (replies.length === 2) answered.resolve(undefined) + }) onTestFinished(() => { endpoint.$close(); raw.close() }) - await raw.send(message) - expect((await failure.promise).kind).toBe('protocol') - expect(endpoint.$closed).toBe(true) + for (const message of [{ t: 'unknown' }, { t: 's', i: 'stray', e: 'unstructured' }, { t: 'q', i: 'a', m: 'constructor', a: [] }, { t: 'q', i: 'b', m: 'toString', a: [] }]) { + await raw.send(message) + } + await answered.promise + expect(replies).toEqual(['constructor', 'toString'].map((name, index) => ({ t: 's', i: 'ab'[index], e: { message: `[birpc] function "${name}" not found` } }))) expect(allowed).not.toHaveBeenCalled() + expect(failures).not.toHaveBeenCalled() + expect(endpoint.$closed).toBe(false) }) it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => { @@ -134,6 +138,7 @@ it('reassembles fragmented UTF-8 and consecutive JSON lines', async () => { it.each([ [Buffer.from('{\n'), false], + [Buffer.from('null\n'), false], [Buffer.from([0xff, 10]), false], [Buffer.from('{'), true], [Buffer.from([0xe2]), true], diff --git a/scripts/ssh-helper/worker-probe.ts b/scripts/ssh-helper/worker-probe.ts index 639564d33f..b07b5f7313 100644 --- a/scripts/ssh-helper/worker-probe.ts +++ b/scripts/ssh-helper/worker-probe.ts @@ -42,7 +42,7 @@ export async function probeRestrictedWorker( const control = streams[SUBPROCESS_CONTROL_FD] assert(control instanceof Duplex) const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};` - // Accepting only readiness rejects any other worker message as a protocol failure. + // The probe program calls no bindings and prints nothing, so the host serves only readiness. const channel = createProcessRpc>(control, 1_048_576, { ready() { void channel.run(workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => { From 36820a546204db067d0592ca7623fb619d2d31f7 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 20 Oct 2026 23:55:36 +0800 Subject: [PATCH 5/8] refactor(ptc-runtime-node): share the fake child and fold result validation into the protocol Move the identical in-memory child used by the per-call and persistent host specs into tests/fake-child.ts, and move readProgramResult next to the ProgramResult type it validates in protocol.ts. --- .../ptc-runtime-node/README.i18n.yaml | 4 +- .../ptc-runtime/ptc-runtime-node/README.md | 2 +- .../ptc-runtime/ptc-runtime-node/README.zh.md | 2 +- .../ptc-runtime-node/src/bootstrap.ts | 2 +- .../ptc-runtime/ptc-runtime-node/src/index.ts | 3 +- .../ptc-runtime-node/src/persistent.ts | 3 +- .../ptc-runtime-node/src/protocol.ts | 27 +++++- .../ptc-runtime-node/src/result.ts | 25 ----- .../ptc-runtime-node/tests/fake-child.ts | 96 +++++++++++++++++++ .../tests/host-failures.spec.ts | 81 +--------------- .../tests/persistent-host.spec.ts | 81 +--------------- 11 files changed, 132 insertions(+), 194 deletions(-) delete mode 100644 packages/ptc-runtime/ptc-runtime-node/src/result.ts create mode 100644 packages/ptc-runtime/ptc-runtime-node/tests/fake-child.ts diff --git a/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml b/packages/ptc-runtime/ptc-runtime-node/README.i18n.yaml index 406ffbfb31..9a083baf8e 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: f9d796233bfb3b79 zh: f7c5803a6ccdf9c2 /deepseek-ai-dsh-ptc-runtime-node/understand-the-implementation/source-map: - en: 2246ec005bd30244 - zh: a8aedbdfeedf924e + en: 6534d1c3a1e40c65 + zh: 2aae909d97915ece /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 a912f16f2d..fbb246c02e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.md @@ -130,7 +130,7 @@ Source execution loads erasable TypeScript and its installed birpc dependency wi | [`src/process.ts`](src/process.ts) | Child handshake, environment clearing and program lifecycle | | [`src/bootstrap.ts`](src/bootstrap.ts) | Program evaluation, binding proxies and output capture | | [`src/rpc.ts`](src/rpc.ts) | Typed birpc transport, message limits and connection lifetime | -| [`src/result.ts`](src/result.ts) | Validation of untrusted program completions | +| [`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 5c80003987..714a200b4d 100644 --- a/packages/ptc-runtime/ptc-runtime-node/README.zh.md +++ b/packages/ptc-runtime/ptc-runtime-node/README.zh.md @@ -130,7 +130,7 @@ Host 通过 birpc 调用子进程的 `run()` 方法;注入的绑定调用 Host | [`src/process.ts`](src/process.ts) | 子进程握手、环境清空与程序生命周期 | | [`src/bootstrap.ts`](src/bootstrap.ts) | 程序求值、绑定代理与输出捕获 | | [`src/rpc.ts`](src/rpc.ts) | 类型化 birpc 传输、消息限制与连接生命周期 | -| [`src/result.ts`](src/result.ts) | 不可信程序完成值的校验 | +| [`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 53ba44a73b..e8256b8f5e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/bootstrap.ts @@ -32,7 +32,7 @@ export interface PatchableStream { /** * Ordered text capture under the shared outer JSON-byte budget, delivered to - * a sink as each item lands (the real sink streams text over the port eagerly, + * a sink as each item lands (the real sink sends text to the host eagerly, * so captured output survives a mid-run termination). It includes the log * array syntax and string escaping in its accounting. Once exhausted it emits * the fitting prefix and reports the limit once; the host turns that condition diff --git a/packages/ptc-runtime/ptc-runtime-node/src/index.ts b/packages/ptc-runtime/ptc-runtime-node/src/index.ts index bee2403ef6..fb4efc5d58 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/index.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/index.ts @@ -14,7 +14,6 @@ import type {} from '@deepseek-ai/dsh-fs' import type { BirpcReturn } from 'birpc' import { invokeBinding, validateBindings } from './bindings.ts' import { createProcessRpc } from './rpc.ts' -import { readProgramResult } from './result.ts' import { bootstrapArgs, resolveLaunch } from './launch.ts' import { NodeConfig, validateConfig } from './config.ts' import { PolicyProcesses } from './policy-processes.ts' @@ -26,7 +25,7 @@ import { OutputLedger } from './output-ledger.ts' import { terminateProcess } from './output-stream.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' import { decodePtcJsonWire } from './json-wire.ts' -import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' +import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts' interface LiveRun { controller: AbortController; finished: Promise } const STRIP_PREFIX = 'async function __dsh_program__() {\n' diff --git a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts index 7003f81bf8..079538d830 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/persistent.ts @@ -13,13 +13,12 @@ import type { PolicyProcesses } from './policy-processes.ts' import type { ResolvedConfig } from './config.ts' import { invokeBinding, validateBindings } from './bindings.ts' import { createProcessRpc } from './rpc.ts' -import { readProgramResult } from './result.ts' import { bootstrapArgs } from './launch.ts' import { STARTUP_ENVIRONMENT_NAMES } from './environment.ts' import { decodePtcJsonWire } from './json-wire.ts' import { OutputLedger } from './output-ledger.ts' import { drainOutput, terminateProcess } from './output-stream.ts' -import type { ProgramBootData, ProgramMethods, HostMethods } from './protocol.ts' +import { readProgramResult, type ProgramBootData, type ProgramMethods, type HostMethods } from './protocol.ts' const messageOf = (error: unknown): string => error instanceof Error ? error.message : String(error) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts index 2471f99322..e4bc5dcadf 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/protocol.ts @@ -1,5 +1,6 @@ -/** Typed methods shared by the host and its co-shipped Node execution process. */ -import type { PtcJsonWire } from './json-wire.ts' +/** Typed methods shared by the host and its co-shipped Node execution process, and validation of untrusted program results. */ +import type { PtcJsonValue, PtcRunFailure } from '@deepseek-ai/dsh-ptc-runtime' +import { decodePtcJsonWire, type PtcJsonWire } from './json-wire.ts' /** Inputs for one program; host functions remain in the parent process. */ export interface ProgramBootData { @@ -52,3 +53,25 @@ export interface ProgramMethods { */ run(cwd: string, data: ProgramBootData): Promise } + +const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) + +/** + * Decode a remote run result without trusting model-written control traffic. + * @param result - Untrusted result returned by the program process. + * @returns Decoded completion or failure; malformed envelopes throw a protocol error. + */ +export function readProgramResult(result: unknown): { value?: PtcJsonValue; error?: PtcRunFailure } { + if (!record(result)) throw new Error('invalid program result') + if (result.error !== undefined) { + const error = result.error + if (!record(error) || typeof error.message !== 'string' + || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { + throw new Error('invalid terminal error') + } + return { error: { kind: error.kind, message: error.message } } + } + if (result.value === undefined) return {} + const value = decodePtcJsonWire(result.value) + return value === undefined ? { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } } : { value } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/src/result.ts b/packages/ptc-runtime/ptc-runtime-node/src/result.ts deleted file mode 100644 index 3377b3f492..0000000000 --- a/packages/ptc-runtime/ptc-runtime-node/src/result.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** Validate program-controlled completions received through birpc. */ -import type { PtcJsonValue, PtcRunFailure } from '@deepseek-ai/dsh-ptc-runtime' -import { decodePtcJsonWire } from './json-wire.ts' - -const record = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) - -/** - * Decode a remote run result without trusting model-written control traffic. - * @param result - Untrusted result returned by the program process. - * @returns Decoded completion or failure; malformed envelopes throw a protocol error. - */ -export function readProgramResult(result: unknown): { value?: PtcJsonValue; error?: PtcRunFailure } { - if (!record(result)) throw new Error('invalid program result') - if (result.error !== undefined) { - const error = result.error - if (!record(error) || typeof error.message !== 'string' - || (error.kind !== 'exception' && error.kind !== 'invalid-output' && error.kind !== 'output-limit')) { - throw new Error('invalid terminal error') - } - return { error: { kind: error.kind, message: error.message } } - } - if (result.value === undefined) return {} - const value = decodePtcJsonWire(result.value) - return value === undefined ? { error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' } } : { value } -} diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/fake-child.ts b/packages/ptc-runtime/ptc-runtime-node/tests/fake-child.ts new file mode 100644 index 0000000000..2a5aacda4c --- /dev/null +++ b/packages/ptc-runtime/ptc-runtime-node/tests/fake-child.ts @@ -0,0 +1,96 @@ +/** A scripted Node child whose control traffic tests read and forge without starting a process. */ +import { Duplex, PassThrough } from 'node:stream' +import type { Context } from '@deepseek-ai/cordis' +import type { PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime' +import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' +import { onTestFinished, vi } from 'vitest' +import type NodeRuntime from '../src/index.ts' +import { rawPeer } from './raw-peer.ts' + +/** Pass as `first` to start a run without sending readiness. */ +export const NO_INITIAL_FRAME = Symbol('no initial frame') + +/** + * Replace process launch with an in-memory child whose control pipe the test drives. + * @param ctx - Context whose subprocess service is stubbed. + * @param runtime - Provider under test. + * @param request - Default request for `start`. + * @returns Process handles, recorded messages, and helpers that forge child traffic. + */ +export function fakeChild(ctx: Context, runtime: NodeRuntime, request: PtcRunRequest) { + const control = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding, callback) { + if (childControl.destroyed) { callback(new Error('peer closed')); return } + childControl.push(chunk) + callback() + }, + }) + const childControl = new Duplex({ + read() {}, + write(chunk: Buffer, _encoding, callback) { + if (control.destroyed) { callback(new Error('peer closed')); return } + control.push(chunk) + callback() + }, + }) + const stdout = new PassThrough() + const stderr = new PassThrough() + const direct = Promise.withResolvers() + const messages: unknown[] = [] + const terminate = vi.fn(() => { + stdout.end() + stderr.end() + direct.resolve({ exitCode: 0, signal: null }) + }) + const waitForExit = vi.fn(async () => true) + const handle: SubprocessHandle = { + stdin: undefined, + stdout, + stderr, + control, + collected: {}, + done: direct.promise, + terminate, + waitForExit, + } + const writes = new Set>() + let runId = '' + let receive: (message: unknown) => void = () => {} + const peer = rawPeer(childControl, (message) => { + if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' + && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i + messages.push(message) + receive(message) + }) + const emit = (message: unknown): void => { + const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) + writes.add(write) + } + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath) + const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle) + onTestFinished(async () => { + peer.close() + control.destroy() + stdout.destroy() + stderr.destroy() + direct.resolve({ exitCode: 0, signal: null }) + await Promise.all(writes) + }) + const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { + const result = runtime.run(runtime.resolve(input)) + if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) + return result + } + const onBoot = (callback: () => void): void => { + receive = (message) => { + if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() + } + } + return { + handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, + spawn, resolveExecutable, emit, start, onBoot, + complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, + receive: (callback: typeof receive) => { receive = callback }, + } +} diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts index 50f07baaba..8b09ce24d5 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts @@ -1,101 +1,24 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' -import { Duplex, PassThrough } from 'node:stream' import { setImmediate } from 'node:timers/promises' import { SessionId } from '@deepseek-ai/dsh-session' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime' -import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv } from '@deepseek-ai/dsh-sandbox' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { Config } from '../src/index.ts' -import { rawPeer } from './raw-peer.ts' +import { fakeChild, NO_INITIAL_FRAME } from './fake-child.ts' import { encodePtcJsonWire } from '../src/json-wire.ts' import { mountRuntime } from './setup.ts' const request: PtcRunRequest = { program: 'return 1', bindings: [] } -const NO_INITIAL_FRAME = Symbol('no initial frame') async function setup(config: Config = {}, mode: 'read-only' | 'danger-full-access' = 'danger-full-access') { const ctx = new Context() const { runtime } = await mountRuntime(ctx, config, { mode, workspaceRoot: process.cwd() }) - const control = new Duplex({ - read() {}, - write(chunk: Buffer, _encoding, callback) { - if (childControl.destroyed) { callback(new Error('peer closed')); return } - childControl.push(chunk) - callback() - }, - }) - const childControl = new Duplex({ - read() {}, - write(chunk: Buffer, _encoding, callback) { - if (control.destroyed) { callback(new Error('peer closed')); return } - control.push(chunk) - callback() - }, - }) - const stdout = new PassThrough() - const stderr = new PassThrough() - const direct = Promise.withResolvers() - const messages: unknown[] = [] - const terminate = vi.fn(() => { - stdout.end() - stderr.end() - direct.resolve({ exitCode: 0, signal: null }) - }) - const waitForExit = vi.fn(async () => true) - const handle: SubprocessHandle = { - stdin: undefined, - stdout, - stderr, - control, - collected: {}, - done: direct.promise, - terminate, - waitForExit, - } - const writes = new Set>() - let runId = '' - let receive: (message: unknown) => void = () => {} - const peer = rawPeer(childControl, (message) => { - if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' - && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i - messages.push(message) - receive(message) - }) - const emit = (message: unknown): void => { - const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) - writes.add(write) - } - const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath) - const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle) - onTestFinished(async () => { - peer.close() - control.destroy() - stdout.destroy() - stderr.destroy() - direct.resolve({ exitCode: 0, signal: null }) - await Promise.all(writes) - }) - const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { - const result = runtime.run(runtime.resolve(input)) - if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) - return result - } - const onBoot = (callback: () => void): void => { - receive = (message) => { - if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() - } - } - return { - ctx, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, - spawn, resolveExecutable, emit, start, onBoot, - complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, - receive: (callback: typeof receive) => { receive = callback }, - } + return { ctx, runtime, ...fakeChild(ctx, runtime, request) } } function call(id: number, value: string = '') { diff --git a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts index 2de95ce4b1..4c90a13a6e 100644 --- a/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts +++ b/packages/ptc-runtime/ptc-runtime-node/tests/persistent-host.spec.ts @@ -1,97 +1,20 @@ -import { Duplex, PassThrough } from 'node:stream' import { setImmediate } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { PtcBindingFunction, PtcRunRequest } from '@deepseek-ai/dsh-ptc-runtime' -import type { SubprocessHandle, SubprocessOutcome } from '@deepseek-ai/dsh-subprocess' import type { Config } from '../src/index.ts' import { mountRuntime } from './setup.ts' -import { rawPeer } from './raw-peer.ts' +import { fakeChild, NO_INITIAL_FRAME } from './fake-child.ts' import { encodePtcJsonWire } from '../src/json-wire.ts' const request: PtcRunRequest = { program: '1', bindings: [], stateOwner: {} } -const NO_INITIAL_FRAME = Symbol('no initial frame') async function setup(config: Config = {}) { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) const { runtime, fiber } = await mountRuntime(ctx, { ...config, executionMode: config.executionMode ?? 'persistent-in-memory' }) - const control = new Duplex({ - read() {}, - write(chunk: Buffer, _encoding, callback) { - if (childControl.destroyed) { callback(new Error('peer closed')); return } - childControl.push(chunk) - callback() - }, - }) - const childControl = new Duplex({ - read() {}, - write(chunk: Buffer, _encoding, callback) { - if (control.destroyed) { callback(new Error('peer closed')); return } - control.push(chunk) - callback() - }, - }) - const stdout = new PassThrough() - const stderr = new PassThrough() - const direct = Promise.withResolvers() - const messages: unknown[] = [] - const terminate = vi.fn(() => { - stdout.end() - stderr.end() - direct.resolve({ exitCode: 0, signal: null }) - }) - const waitForExit = vi.fn(async () => true) - const handle: SubprocessHandle = { - stdin: undefined, - stdout, - stderr, - control, - collected: {}, - done: direct.promise, - terminate, - waitForExit, - } - const writes = new Set>() - let runId = '' - let receive: (message: unknown) => void = () => {} - const peer = rawPeer(childControl, (message) => { - if (typeof message === 'object' && message !== null && 't' in message && message.t === 'q' - && 'm' in message && message.m === 'run' && 'i' in message && typeof message.i === 'string') runId = message.i - messages.push(message) - receive(message) - }) - const emit = (message: unknown): void => { - const write = peer.send(message).catch(() => {}).finally(() => { writes.delete(write) }) - writes.add(write) - } - const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable').mockResolvedValue(process.execPath) - const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(handle) - onTestFinished(async () => { - peer.close() - control.destroy() - stdout.destroy() - stderr.destroy() - direct.resolve({ exitCode: 0, signal: null }) - await Promise.all(writes) - }) - const start = (input: PtcRunRequest = request, first: unknown = { t: 'q', m: 'ready', a: [] }) => { - const result = runtime.run(runtime.resolve(input)) - if (first !== NO_INITIAL_FRAME) queueMicrotask(() => { emit(first) }) - return result - } - const onBoot = (callback: () => void): void => { - receive = (message) => { - if (typeof message === 'object' && message !== null && 'm' in message && message.m === 'run') callback() - } - } - return { - ctx, fiber, runtime, handle, terminate, waitForExit, direct, stdout, stderr, control, peer, messages, - spawn, resolveExecutable, emit, start, onBoot, - complete: (result: unknown) => { emit({ t: 's', i: runId, r: result }) }, - receive: (callback: typeof receive) => { receive = callback }, - } + return { ctx, fiber, runtime, ...fakeChild(ctx, runtime, request) } } function call(id = 1, args: unknown = encodePtcJsonWire(null)) { return { t: 'q', i: String(id), m: 'invoke', a: ['tools', 'test', args] } } From 94dae61fb5fcf6839c8fe0eba6bbf47161bbee61 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Wed, 21 Oct 2026 10:30:31 +0800 Subject: [PATCH 6/8] fix(ptc-runtime-node): keep per-call binding waits pending when the host closes Closing the control pipe after the result rejected outstanding birpc calls; an unawaited binding promise then crashed the child and its trace reached the result logs. Once the result is returned or the pipe closes, binding calls now never settle, including those the closure rejects. --- .../ptc-runtime-node/src/process.ts | 9 +++++++-- .../tests/process-main.spec.ts | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/ptc-runtime/ptc-runtime-node/src/process.ts b/packages/ptc-runtime/ptc-runtime-node/src/process.ts index 31ba28f962..4315ab4ba8 100644 --- a/packages/ptc-runtime/ptc-runtime-node/src/process.ts +++ b/packages/ptc-runtime/ptc-runtime-node/src/process.ts @@ -25,12 +25,17 @@ export async function runNodeMain(stream: Duplex, maxMessageBytes: number, proce if (!Number.isSafeInteger(maxMessageBytes) || maxMessageBytes <= 0) throw new Error('invalid control message limit') clearProgramEnvironment(processState) const hostClosed = Promise.withResolvers() - // Set once the result is returned or the endpoint closes; later calls stay pending and output is dropped. + // Set once the result is returned or the endpoint closes; binding calls then never settle and output is dropped. let ended = false + const unsettled = new Promise(() => {}) const connection = createProcessRpc(stream, maxMessageBytes, { async run(_cwd, data) { const result = await runProgram({ - invoke: (global, name, args) => ended ? new Promise(() => {}) : connection.invoke(global, name, args), + invoke: (global, name, args) => ended ? unsettled : connection.invoke(global, name, args).catch((error: unknown) => { + // Closure rejects outstanding calls; an unawaited one would crash the child into the result logs. + if (ended) return unsettled + throw error + }), log: (text) => { if (!ended) void connection.log(text) }, outputLimit: () => { if (!ended) void connection.outputLimit() }, }, data, processState) 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 113d549bdb..e30834c651 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 @@ -69,6 +69,25 @@ it('keeps unfinished calls pending and suppresses later logs while awaiting host } finally { release.resolve(encodePtcJsonWire(null)) } }) +it('rejects a binding call that the host fails during the run', async () => { + const h = await setup() + h.methods.invoke = async () => { throw new Error('denied by host') } + const result = await h.run('try { await tools.echo({}) } catch (error) { return error.message }') + expect(decodePtcJsonWire(result.value)).toBe('denied by host') +}) + +it('keeps an unawaited call pending when the host closes after the result', async () => { + const h = await setup() + const pending = Promise.withResolvers>() + onTestFinished(() => { pending.resolve(encodePtcJsonWire(null)) }) + h.methods.invoke = () => pending.promise + expect(decodePtcJsonWire((await h.run('void tools.echo({}); return 42')).value)).toBe(42) + h.peer.$close() + await h.main + await nextTurn() + expect(h.state.exitCode).toBeUndefined() +}) + it('reports exhausted output accounting during the run', async () => { const h = await setup() const limited = vi.fn() From b9d049d0418ba36c902d089ef4aa37d686eb2c91 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 11:28:26 +0800 Subject: [PATCH 7/8] fix(plugins): use sentence case for built-in plugin English copy Refs #6790 --- apps/web/tests/agent-team-panel.e2e.ts | 12 +++---- apps/web/tests/cua-permissions.e2e.ts | 8 ++--- .../cua-permissions/missing.expected.md | 10 +++--- .../shared-inspector/metadata-en.expected.md | 10 +++--- apps/web/tests/inspector-late-enable.e2e.ts | 2 +- apps/web/tests/plugin-manager.e2e.ts | 8 ++--- apps/web/tests/preview-boot.e2e.ts | 2 +- apps/web/tests/session-inspector.e2e.ts | 14 ++++---- apps/web/tests/shared-inspector.e2e.ts | 10 +++--- .../agent-team-panel/resize.expected.md | 2 +- .../agent-team-panel/task.expected.md | 2 +- apps/web/tests/worktree-opt-in.e2e.ts | 8 ++--- .../ui-plugin-manager/src/client/locales.ts | 2 +- .../agent-team-profile/docs/DETAIL.md | 6 ++-- .../agent-team-profile/locale/en.json | 2 +- .../experimental/agent-team/locale/en.json | 2 +- .../experimental/auto-review/locale/en.json | 2 +- .../locale/en.json | 2 +- .../browser-use-playwright-mcp/locale/en.json | 2 +- .../client-ui-agent-team/locale/en.json | 2 +- .../src/client/locales.ts | 6 ++-- .../tests/browser-plugin.client.spec.ts | 2 +- .../src/client/locales.ts | 6 ++-- .../locale/en.json | 2 +- .../locale/en.json | 2 +- .../cot-translation-bundle/docs/DETAIL.md | 2 +- .../experimental/cua-bundle/locale/en.json | 4 +-- .../cua-macos/src/native-build.ts | 6 ++-- packages/experimental/cua/README.i18n.yaml | 2 +- packages/experimental/cua/README.md | 2 +- .../cua/src/application-access.ts | 4 +-- .../experimental/cua/src/client/locales.ts | 12 +++---- .../inspector-profile/README.i18n.yaml | 2 +- .../experimental/inspector-profile/README.md | 6 ++-- .../inspector-profile/docs/DETAIL.md | 8 ++--- .../inspector-profile/locale/en.json | 2 +- .../experimental/inspector/README.i18n.yaml | 2 +- packages/experimental/inspector/README.md | 2 +- .../experimental/inspector/locale/en.json | 2 +- .../inspector/src/client/bottom/locales.ts | 8 ++--- .../inspector/tests/bottom.client.spec.tsx | 18 +++++------ .../session-inspector/README.i18n.yaml | 4 +-- .../experimental/session-inspector/README.md | 4 +-- .../session-inspector/locale/en.json | 2 +- .../session-inspector/src/client/locales.ts | 32 +++++++++---------- .../tests/object-tree.client.spec.tsx | 4 +-- .../session-search/README.i18n.yaml | 2 +- .../experimental/session-search/README.md | 2 +- .../session-search/locale/en.json | 2 +- .../experimental/session-search/package.json | 2 +- .../tool-agent-team/locale/en.json | 2 +- .../tool-worktree/README.i18n.yaml | 2 +- packages/experimental/tool-worktree/README.md | 2 +- .../experimental/tool-worktree/locale/en.json | 2 +- .../templates/decoration/locale/en.json | 2 +- .../templates/mcp/locale/en.json | 2 +- .../web/session-inspector/log.expected.md | 2 +- 57 files changed, 138 insertions(+), 138 deletions(-) diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index bd7dcff720..c42a3385bf 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -86,7 +86,7 @@ describe('web e2e: Agent Teams panel', () => { const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') onTestFinished(() => { projectionReads.mockRestore() }) await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() - const action = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const action = page.getByRole('dialog', { name: 'Agent team', exact: true }) await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor() await action.getByText('lead').waitFor() expect(projectionReads).not.toHaveBeenCalled() @@ -119,7 +119,7 @@ describe('web e2e: Agent Teams panel', () => { it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard')) - const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }) const viewport = page.viewportSize()! await trigger.click() @@ -162,8 +162,8 @@ describe('web e2e: Agent Teams panel', () => { }) it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => { - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) await trigger.hover() await panel.waitFor() await panel.hover() @@ -182,8 +182,8 @@ describe('web e2e: Agent Teams panel', () => { writeScopes: [], }) const viewport = page.viewportSize()! - const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true }) - const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) + const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) const card = panel.locator('article').filter({ hasText: 'Responsive task' }) await trigger.click() await card.waitFor() diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index e02b076eb6..eb72fd016e 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -73,15 +73,15 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr expect(await card.getByRole('button', { name: /Accessibility permission|Screen Recording permission/ }).count()).toBe(0) await page.setViewportSize({ width: 1680, height: 600 }) const plugins = page.locator('[data-plugin-panel]') - const openDetails = page.getByRole('button', { name: 'View Computer Use', exact: true }) + const openDetails = page.getByRole('button', { name: 'View Computer use', exact: true }) await openDetails.scrollIntoViewIfNeeded() expect(await plugins.evaluate(element => element.scrollTop)).toBeGreaterThan(0) await openDetails.click() const panel = page.locator('[data-cua-settings]') const accessibility = panel.getByRole('group', { name: 'Accessibility', exact: true }) await accessibility.getByRole('button', { name: 'Request permission', exact: true }).waitFor() - expect(await panel.getByRole('region', { name: 'Authorize Computer Use', exact: true }).getByRole('group').count()).toBe(4) - expect(await panel.getByRole('region', { name: 'Manage applications Computer Use can access', exact: true }).count()).toBe(1) + expect(await panel.getByRole('region', { name: 'Authorize computer use', exact: true }).getByRole('group').count()).toBe(4) + expect(await panel.getByRole('region', { name: 'Manage applications computer use can access', exact: true }).count()).toBe(1) await expect.poll(() => panel.locator('li img').evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1) expect(await panel.getByText('/fixture/app', { exact: true }).count()).toBe(0) expect(await accessibility.getByText('Visual Studio Code.app', { exact: true }).count()).toBe(1) @@ -177,7 +177,7 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } } }) try { - const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true }) + const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer use', exact: true }) for (const width of [1680, 390]) { await page.setViewportSize({ width, height: 1000 }) if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' }) diff --git a/apps/web/tests/expected/cua-permissions/missing.expected.md b/apps/web/tests/expected/cua-permissions/missing.expected.md index 8dfba492a2..cb4ade57ac 100644 --- a/apps/web/tests/expected/cua-permissions/missing.expected.md +++ b/apps/web/tests/expected/cua-permissions/missing.expected.md @@ -1,8 +1,8 @@ -- region "Authorize Computer Use": - - heading "Authorize Computer Use" [level=3] +- region "Authorize computer use": + - heading "Authorize computer use" [level=3] - group "Default mode for new sessions": - strong: Default mode for new sessions - - paragraph: Computer Use is available only in PTC mode. + - paragraph: Computer use is available only in PTC mode. - button "Set to PTC" - group "PTC execution mode": - strong: PTC execution mode @@ -17,8 +17,8 @@ - paragraph: In System Settings, allow Visual Studio Code.app to capture application windows. - button "Request permission" - paragraph: Manage permissions in System Settings for the application named by macOS. -- region "Manage applications Computer Use can access": - - heading "Manage applications Computer Use can access" [level=3] +- region "Manage applications computer use can access": + - heading "Manage applications computer use can access" [level=3] - heading "Always-allowed applications" [level=4] - list: - listitem: diff --git a/apps/web/tests/expected/shared-inspector/metadata-en.expected.md b/apps/web/tests/expected/shared-inspector/metadata-en.expected.md index 4953cbfc60..8a700d7178 100644 --- a/apps/web/tests/expected/shared-inspector/metadata-en.expected.md +++ b/apps/web/tests/expected/shared-inspector/metadata-en.expected.md @@ -1,9 +1,9 @@ -- bundle: Developer Tools +- bundle: Developer tools description: Inspect and debug raw session data, grouped chat messages, and the NodeJS backend. -- component: NodeJS Inspector +- component: NodeJS inspector description: Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging. -- component: Session Log +- component: Session log description: Enable analysis of the current session's raw data and grouped chat messages in the session sidebar. -- session sidebar: Session Log +- session sidebar: Session log description: Analyze raw logs and grouped chat messages for the current session in the sidebar. -- NodeJS bottom: NodeJS Inspector +- NodeJS bottom: NodeJS inspector diff --git a/apps/web/tests/inspector-late-enable.e2e.ts b/apps/web/tests/inspector-late-enable.e2e.ts index 9a7cda3eee..c8c7e6fff3 100644 --- a/apps/web/tests/inspector-late-enable.e2e.ts +++ b/apps/web/tests/inspector-late-enable.e2e.ts @@ -35,7 +35,7 @@ it('opens bottom DevTools after the Developer Tools bundle is enabled on an alre await page.getByRole('navigation', { name: 'Global panels' }).getByRole('button', { name: 'Plugins', exact: true }).click() const plugins = page.locator('[data-plugin-panel]') - const toggle = plugins.getByRole('switch', { name: 'Enable Developer Tools', exact: true }) + const toggle = plugins.getByRole('switch', { name: 'Enable Developer tools', exact: true }) await toggle.waitFor() expect(await toggle.getAttribute('aria-checked')).toBe('false') const panel = page.locator('[data-inspector-panel]') diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index fce715522b..a14d789d00 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -921,14 +921,14 @@ describe('web e2e: plugin manager', () => { expect(await child.getByText('为智能体提供成员协调、消息通信和共享任务管理工具。', { exact: true }).count()).toBe(1) try { await setLanguage('en') - await panel.getByRole('heading', { name: 'Agent Teams', exact: true }).waitFor() + await panel.getByRole('heading', { name: 'Agent teams', exact: true }).waitFor() expect(await packageName.textContent()).toBe('@deepseek-ai/dsh-experimental-agent-team-profile') expect(await panel.getByText('Enable team collaboration, team tools, the member roster, and the shared task board.').count()).toBe(1) - await child.getByText('Team Tools', { exact: true }).waitFor() + await child.getByText('Team tools', { exact: true }).waitFor() expect(await child.getByText('Give agents tools to coordinate members, exchange messages, and manage shared tasks.', { exact: true }).count()).toBe(1) await panel.getByRole('button', { name: 'Back to plugins' }).click() - await panel.getByRole('button', { name: 'View Agent Teams', exact: true }).waitFor() - expect(await panel.getByRole('switch', { name: 'Enable Agent Teams', exact: true }).count()).toBe(1) + await panel.getByRole('button', { name: 'View Agent teams', exact: true }).waitFor() + expect(await panel.getByRole('switch', { name: 'Enable Agent teams', exact: true }).count()).toBe(1) // Schedule ships in the delivered composition, so no bundle card carries it in either language. expect(await panel.getByRole('button', { name: 'View Automation tasks', exact: true }).count()).toBe(0) // The official configuration pages follow the language too, from their own dictionary. diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index d4d502e388..4cb8a8946f 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -776,7 +776,7 @@ async function openSessionInspector(page: Page): Promise { // The showcase opens with a file tab in the right sidebar; a new tab shows the guide entries. await page.getByRole('button', { name: 'New tab', exact: true }).click() await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click({ timeout: 30_000 }) - await page.getByRole('table', { name: 'Raw Log', exact: true }).waitFor({ timeout: 15_000 }) + await page.getByRole('table', { name: 'Raw log', exact: true }).waitFor({ timeout: 15_000 }) expect(await setBundle(page, INSPECTOR_BUNDLE, false)).toBe('applied') } diff --git a/apps/web/tests/session-inspector.e2e.ts b/apps/web/tests/session-inspector.e2e.ts index 2df97eeaa9..cba16b1571 100644 --- a/apps/web/tests/session-inspector.e2e.ts +++ b/apps/web/tests/session-inspector.e2e.ts @@ -46,12 +46,12 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click() const mode = page.getByRole('combobox', { name: 'Presentation', exact: true }) expect(await mode.inputValue()).toBe('session-log') - const raw = page.getByRole('table', { name: 'Raw Log', exact: true }) + const raw = page.getByRole('table', { name: 'Raw log', exact: true }) await raw.getByRole('button', { name: 'Filter types', exact: true }).click() await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('user/message') await page.getByRole('option', { name: 'user/message', exact: true }).click() await raw.getByRole('button', { name: 'user/message', exact: true }).first().click() - const panel = page.locator('section[aria-label="Raw Log"]') + const panel = page.locator('section[aria-label="Raw log"]') const details = panel.getByRole('separator').locator('..').locator('ul').first() expect(await details.innerText()).toContain(prompts[0]) expect(await details.locator('details:not([open])').count()).toBe(0) @@ -64,14 +64,14 @@ it.skipIf(webSnapshotMode() === 'record')('inspects a replayed Session log and C const replacements = [...new Set(times)].filter(value => /^\d{2}:\d{2}:\d{2}\.\d{3}$/.test(value)) .map(value => [value, '{{time}}'] as const) await compareOrRefreshGolden(join(directory, 'log.expected.md'), - await captureStableAria(page, 'section[aria-label="Raw Log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode()) + await captureStableAria(page, 'section[aria-label="Raw log"] table', scaffold.workspaceCwd, { replacements }), webSnapshotMode()) await mode.selectOption('chat-node') - const groups = page.getByRole('table', { name: 'Chat Group', exact: true }) + const groups = page.getByRole('table', { name: 'Chat group', exact: true }) expect(await groups.getByRole('columnheader', { name: 'Time (UTC)' }).count()).toBe(0) await groups.getByRole('button', { name: 'assistant-step', exact: true }).first().click() await page.getByRole('navigation', { name: 'Raw data navigation', exact: true }).waitFor() - const groupPanel = page.locator('section[aria-label="Chat Group"]') - await groupPanel.locator('summary').filter({ hasText: 'Node Data' }).click() + const groupPanel = page.locator('section[aria-label="Chat group"]') + await groupPanel.locator('summary').filter({ hasText: 'Node data' }).click() const blocks = groupPanel.getByText('blocks:', { exact: true }).locator('..') await blocks.click() await blocks.locator('..').getByText('Other properties:', { exact: true }).waitFor() @@ -104,7 +104,7 @@ it.skipIf(webSnapshotMode() === 'record')('highlights nearby Chat content when t await page.getByText('DONE', { exact: true }).waitFor() await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() await page.locator('[data-sidebar-right-guide-entry="session-inspector-log"]').click() - const table = page.getByRole('table', { name: 'Raw Log', exact: true }) + const table = page.getByRole('table', { name: 'Raw log', exact: true }) await table.getByRole('button', { name: 'Filter types', exact: true }).click() await page.getByRole('combobox', { name: 'Type keyword', exact: true }).fill('tool/call') await page.getByRole('button', { name: 'Apply filter', exact: true }).click() diff --git a/apps/web/tests/shared-inspector.e2e.ts b/apps/web/tests/shared-inspector.e2e.ts index 74a65d498b..d72ff60197 100644 --- a/apps/web/tests/shared-inspector.e2e.ts +++ b/apps/web/tests/shared-inspector.e2e.ts @@ -42,7 +42,7 @@ it('toggles bottom DevTools from the page and iframe without a selected Session' const close = panel.getByRole('button', { name: 'Collapse', exact: true }) expect(await close.textContent()).toBe('') expect(await close.locator('svg').count()).toBe(1) - const divider = panel.getByRole('separator', { name: 'Resize NodeJS Inspector panel' }) + const divider = panel.getByRole('separator', { name: 'Resize NodeJS inspector panel' }) const drag = async (deltaY: number): Promise => { const box = (await divider.boundingBox())! const x = box.x + box.width / 2 @@ -93,10 +93,10 @@ it.each([ openSidebar: '打开右侧边栏', sidebarDescription: '在侧边栏分析当前会话原始日志和聊天分组数据', }, { - locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer Tools', bundle: 'Developer Tools', + locale: 'en-US', file: 'en', plugins: 'Plugins', open: 'View Developer tools', bundle: 'Developer tools', description: 'Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.', - inspector: 'NodeJS Inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.', - session: 'Session Log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.", + inspector: 'NodeJS inspector', inspectorDescription: 'Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging.', + session: 'Session log', sessionDescription: "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar.", openSidebar: 'Open right sidebar', sidebarDescription: 'Analyze raw logs and grouped chat messages for the current session in the sidebar.', }, ])('shows the Inspector bundle and component copy in $locale', async (copy) => { @@ -150,7 +150,7 @@ it.each([ ` description: ${await sessionGuide.getByText(copy.sidebarDescription, { exact: true }).innerText()}`, `- NodeJS bottom: ${await bottomTitle.innerText()}`) await sessionGuide.click() - await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw Log', exact: true }).waitFor() + await page.getByRole('table', { name: copy.locale === 'zh-CN' ? '原始数据' : 'Raw log', exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL(`./expected/shared-inspector/metadata-${copy.file}.expected.md`, import.meta.url)), snapshot.join('\n'), webSnapshotMode()) }) diff --git a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md index 19f90be9a3..57fbab1f84 100644 --- a/apps/web/tests/snapshots/agent-team-panel/resize.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/resize.expected.md @@ -1,4 +1,4 @@ -- dialog "Agent Team": +- dialog "Agent team": - heading "Members" [level=3] - button "lead Current chat Inactive" [disabled] - heading "Shared tasks 2" [level=3] diff --git a/apps/web/tests/snapshots/agent-team-panel/task.expected.md b/apps/web/tests/snapshots/agent-team-panel/task.expected.md index 9e6f63532e..d5593a069b 100644 --- a/apps/web/tests/snapshots/agent-team-panel/task.expected.md +++ b/apps/web/tests/snapshots/agent-team-panel/task.expected.md @@ -1,4 +1,4 @@ -- dialog "Agent Team": +- dialog "Agent team": - heading "Members" [level=3] - button "lead Current chat Inactive" [disabled] - heading "Shared tasks 1" [level=3] diff --git a/apps/web/tests/worktree-opt-in.e2e.ts b/apps/web/tests/worktree-opt-in.e2e.ts index 40d4e58857..37b94a16ec 100644 --- a/apps/web/tests/worktree-opt-in.e2e.ts +++ b/apps/web/tests/worktree-opt-in.e2e.ts @@ -70,9 +70,9 @@ it('persists the Git Worktrees switch and removes its capability while retaining } let panel = await openPlugins() let card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - await card.getByRole('button', { name: 'View Git Worktrees', exact: true }).waitFor() + await card.getByRole('button', { name: 'View Git worktrees', exact: true }).waitFor() expect(await card.getByText('Experimental', { exact: true }).count()).toBe(1) - let toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) + let toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await selectedBundles()).not.toContain(BUNDLE) expect(rows()).toEqual([]) @@ -92,7 +92,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() card = panel.locator(`[data-plugin-group="more"] [data-plugin-package="${BUNDLE}"]`) - toggle = card.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) + toggle = card.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true') expect(tools()).toContain('create_worktree') await toggle.click() @@ -108,7 +108,7 @@ it('persists the Git Worktrees switch and removes its capability while retaining await page.reload({ waitUntil: 'load' }) panel = await openPlugins() - await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git Worktrees', exact: true }) + await expect.poll(() => panel.getByRole('switch', { name: 'Enable Git worktrees', exact: true }) .getAttribute('aria-checked')).toBe('false') expect(tools()).not.toContain('create_worktree') expect(await selectedBundles()).not.toContain(BUNDLE) diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index e820c21322..fb4ec04104 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -380,7 +380,7 @@ export const en = { installGuideFill: 'Use example', installGuideFillAria: 'Use the example {example}', installGuideSafety: 'Install only plugins you trust: they run with your permissions and can damage DeepSeek Harness or leak your data.', - installUpgradeNotice: 'On-demand Official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', + installUpgradeNotice: 'On-demand official plugins offer Update when their version differs from DSH. To update other plugins, uninstall and reinstall them.', registryToggle: 'Registry', registryLegend: 'The npm registry the plugin is downloaded from', registryDefault: 'Default registry', diff --git a/packages/experimental/agent-team-profile/docs/DETAIL.md b/packages/experimental/agent-team-profile/docs/DETAIL.md index 2c45e46b94..62dd975f03 100644 --- a/packages/experimental/agent-team-profile/docs/DETAIL.md +++ b/packages/experimental/agent-team-profile/docs/DETAIL.md @@ -1,11 +1,11 @@ -![A conversation titled Add rate limiting with its Agent Team button, and the panel it opens: two members and two shared tasks](preview.svg) +![A conversation titled Add rate limiting with its Agent team button, and the panel it opens: two members and two shared tasks](preview.svg) Ask the agent you are talking to, the lead, to split a job. It creates named teammates, puts the work on a shared task board, waits for their results, and reports back. ## How to use -- Ask in plain words, for example: "Use Agent Teams to split this task between two teammates, wait, and summarize." -- Click **Agent Team** next to the conversation title to see **Members** and **Shared tasks**. Each task shows its status, **Owner**, **Write scopes**, and the tasks it is **Blocked by**. +- Ask in plain words, for example: "Use agent teams to split this task between two teammates, wait, and summarize." +- Click **Agent team** next to the conversation title to see **Members** and **Shared tasks**. Each task shows its status, **Owner**, **Write scopes**, and the tasks it is **Blocked by**. - Click a teammate to open its conversation and talk to it there. - The panel is read-only. To change tasks or teammates, ask the lead. diff --git a/packages/experimental/agent-team-profile/locale/en.json b/packages/experimental/agent-team-profile/locale/en.json index 7d7c505d0e..7086787e61 100644 --- a/packages/experimental/agent-team-profile/locale/en.json +++ b/packages/experimental/agent-team-profile/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Agent Teams", + "title": "Agent teams", "description": "Enable team collaboration, team tools, the member roster, and the shared task board.", "detail": "docs/DETAIL.md" } diff --git a/packages/experimental/agent-team/locale/en.json b/packages/experimental/agent-team/locale/en.json index 5d93c64811..5706ed2cc6 100644 --- a/packages/experimental/agent-team/locale/en.json +++ b/packages/experimental/agent-team/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Team Collaboration", + "title": "Team collaboration", "description": "Manage team members, member messages, and shared tasks." } } diff --git a/packages/experimental/auto-review/locale/en.json b/packages/experimental/auto-review/locale/en.json index ba43ff62fe..c051b5bcfc 100644 --- a/packages/experimental/auto-review/locale/en.json +++ b/packages/experimental/auto-review/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Auto Authorization Review", + "title": "Auto authorization review", "description": "Add an Auto review permission mode that uses the model to assess authorization before each tool call.", "detail": "docs/DETAIL.md" } diff --git a/packages/experimental/browser-use-chrome-devtools-mcp/locale/en.json b/packages/experimental/browser-use-chrome-devtools-mcp/locale/en.json index a2f98e1846..035febc4b7 100644 --- a/packages/experimental/browser-use-chrome-devtools-mcp/locale/en.json +++ b/packages/experimental/browser-use-chrome-devtools-mcp/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Browser Use (Chrome DevTools)", + "title": "Browser use (Chrome DevTools)", "description": "Let the model inspect and operate web pages through Chrome DevTools MCP in a launched Chrome." } } diff --git a/packages/experimental/browser-use-playwright-mcp/locale/en.json b/packages/experimental/browser-use-playwright-mcp/locale/en.json index ffc27dd81c..4b3c3aa18d 100644 --- a/packages/experimental/browser-use-playwright-mcp/locale/en.json +++ b/packages/experimental/browser-use-playwright-mcp/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Browser Use (Playwright)", + "title": "Browser use (Playwright)", "description": "Let the model browse and operate web pages through Playwright MCP in a launched Chromium." } } diff --git a/packages/experimental/client-ui-agent-team/locale/en.json b/packages/experimental/client-ui-agent-team/locale/en.json index ad6f1665e5..81e33d8236 100644 --- a/packages/experimental/client-ui-agent-team/locale/en.json +++ b/packages/experimental/client-ui-agent-team/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Team Panel", + "title": "Team panel", "description": "View team members and shared tasks, and navigate to member conversations." } } diff --git a/packages/experimental/client-ui-agent-team/src/client/locales.ts b/packages/experimental/client-ui-agent-team/src/client/locales.ts index fe4b853095..cc559c2a2a 100644 --- a/packages/experimental/client-ui-agent-team/src/client/locales.ts +++ b/packages/experimental/client-ui-agent-team/src/client/locales.ts @@ -37,10 +37,10 @@ export type TeamKey = keyof typeof zh /** English dictionary checked against the Chinese key set. */ export const en = { - trigger: 'Agent Team', - loading: 'Loading Team…', + trigger: 'Agent team', + loading: 'Loading team…', unavailable: 'Team is unavailable', - failure: 'Invalid persisted Team record: {message}', + failure: 'Invalid persisted team record: {message}', empty: 'No shared tasks yet. Create them through the conversation.', roster: 'Members', tasks: 'Shared tasks', diff --git a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts index 23c5844747..22c0ca04bd 100644 --- a/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts +++ b/packages/experimental/client-ui-agent-team/tests/browser-plugin.client.spec.ts @@ -82,7 +82,7 @@ describe('ui-team browser plugin', () => { locale: 'agent-team', }) const t = b.ctx.locale.bind('agent-team') - expect(t('trigger')).toBe('Agent Team') + expect(t('trigger')).toBe('Agent team') expect(b.navigation).toEqual([]) diff --git a/packages/experimental/client-ui-cot-translation/src/client/locales.ts b/packages/experimental/client-ui-cot-translation/src/client/locales.ts index 88cc907e32..adfb1c629c 100644 --- a/packages/experimental/client-ui-cot-translation/src/client/locales.ts +++ b/packages/experimental/client-ui-cot-translation/src/client/locales.ts @@ -7,10 +7,10 @@ export const NS = 'cotTranslation' /** English copy and key source. */ export const en = { provider: 'Translation service', google: 'Google', bing: 'Bing', - deepseekAccount: 'DeepSeek Flash — Account (paid)', deepseekOfficial: 'DeepSeek Flash — Official API (paid)', + deepseekAccount: 'DeepSeek Flash — account (paid)', deepseekOfficial: 'DeepSeek Flash — official API (paid)', providerUnavailableOption: '{provider} — unavailable', - providerUnavailable: 'This provider is unavailable. Configure your DeepSeek account or Official API provider, or choose an available service.', - paidNotice: 'Each uncached fragment sends a separate paid DeepSeek Flash request through the selected account or Official API credentials. Reopening reasoning reuses saved results; changing the provider, language, source text, or request settings may send a new paid request.', + providerUnavailable: 'This provider is unavailable. Configure your DeepSeek account or official API provider, or choose an available service.', + paidNotice: 'Each uncached fragment sends a separate paid DeepSeek Flash request through the selected account or official API credentials. Reopening reasoning reuses saved results; changing the provider, language, source text, or request settings may send a new paid request.', anonymousNotice: 'Bing and Google need no translation login or API key. Their anonymous endpoints may reject or limit requests.', targetLanguage: 'Target language', targetLanguageHint: 'auto follows the UI language; language codes such as zh, en, and ja also work.', privacy: 'Expanded reasoning is sent to the selected service. It may contain private code or conversation details.', diff --git a/packages/experimental/computer-use-cua-driver-mcp/locale/en.json b/packages/experimental/computer-use-cua-driver-mcp/locale/en.json index 9ae3ab4ed1..99a93828da 100644 --- a/packages/experimental/computer-use-cua-driver-mcp/locale/en.json +++ b/packages/experimental/computer-use-cua-driver-mcp/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Computer Use (Cua Driver MCP)", + "title": "Computer use (Cua Driver MCP)", "description": "Let the model operate the desktop through a separately installed cua-driver executable." } } diff --git a/packages/experimental/computer-use-cua-driver-native/locale/en.json b/packages/experimental/computer-use-cua-driver-native/locale/en.json index 009e83af75..fa0b9a4d40 100644 --- a/packages/experimental/computer-use-cua-driver-native/locale/en.json +++ b/packages/experimental/computer-use-cua-driver-native/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Computer Use (Cua Driver)", + "title": "Computer use (Cua Driver)", "description": "Let the model operate the desktop with the bundled Cua Driver runtime; needs desktop permissions for the app that launches dsh." } } diff --git a/packages/experimental/cot-translation-bundle/docs/DETAIL.md b/packages/experimental/cot-translation-bundle/docs/DETAIL.md index b2e6a9696c..6128db4b72 100644 --- a/packages/experimental/cot-translation-bundle/docs/DETAIL.md +++ b/packages/experimental/cot-translation-bundle/docs/DETAIL.md @@ -12,7 +12,7 @@ Expand a **Think** row to read its reasoning in your interface language. Paragra - The reasoning you expand can contain your code and conversation details. - Bing is the default; Google is also available. Neither needs an account or API key, but neither guarantees availability or a free quota. -- With a DeepSeek account or Official API set up, **DeepSeek Flash — Account (paid)** and **DeepSeek Flash — Official API (paid)** also appear. Each text piece without a saved translation is then a paid request. +- With a DeepSeek account or official API set up, **DeepSeek Flash — account (paid)** and **DeepSeek Flash — official API (paid)** also appear. Each text piece without a saved translation is then a paid request. - Translations are saved in the session and reused when you reopen it. The original reasoning and the model's input stay unchanged. - Collapsed summaries stay in the original language. Translation can change Markdown formatting or code. - Turning the plugin off removes its buttons and cancels pending translations; saved translations stay in the session. diff --git a/packages/experimental/cua-bundle/locale/en.json b/packages/experimental/cua-bundle/locale/en.json index 5893fa41d8..3508df9450 100644 --- a/packages/experimental/cua-bundle/locale/en.json +++ b/packages/experimental/cua-bundle/locale/en.json @@ -1,7 +1,7 @@ { "meta": { - "title": "Computer Use", - "description": "Currently supports macOS; Windows support is in development. Loads Computer Use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.", + "title": "Computer use", + "description": "Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.", "detail": "docs/DETAIL.md" } } diff --git a/packages/experimental/cua-macos/src/native-build.ts b/packages/experimental/cua-macos/src/native-build.ts index 1d79c4b402..ab804bb0eb 100644 --- a/packages/experimental/cua-macos/src/native-build.ts +++ b/packages/experimental/cua-macos/src/native-build.ts @@ -17,7 +17,7 @@ export type NativeArchitecture = 'arm64' | 'x64' * @throws When the current host has no native implementation. */ export function nativeHostArchitecture(platform: string, architecture: string): NativeArchitecture { - if (platform !== 'darwin' || (architecture !== 'arm64' && architecture !== 'x64')) throw new Error('Computer Use currently requires macOS on Apple silicon or Intel. Windows support is in development') + if (platform !== 'darwin' || (architecture !== 'arm64' && architecture !== 'x64')) throw new Error('Computer use currently requires macOS on Apple silicon or Intel. Windows support is in development') return architecture } @@ -91,7 +91,7 @@ async function compileNative(spec: NativeBuildSpec, signal: AbortSignal, executa signal.throwIfAborted() if (outcome.exitCode !== 0 || outcome.signal !== null) { const diagnostics = `${handle.collected.stdout?.readFrom(0).text ?? ''}${handle.collected.stderr?.readFrom(0).text ?? ''}` - throw new Error(`Computer Use native preparation failed (${String(outcome.exitCode)}, ${String(outcome.signal)}): ${diagnostics}`) + throw new Error(`Computer use native preparation failed (${String(outcome.exitCode)}, ${String(outcome.signal)}): ${diagnostics}`) } const built = await readFile(join(scratch, 'release', 'dsh-cua-native')) let previous: Buffer | undefined @@ -107,7 +107,7 @@ async function compileNative(spec: NativeBuildSpec, signal: AbortSignal, executa try { retained = await readFile(runtime) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error } - if (retained && !retained.equals(built)) throw new Error('Computer Use retained executable does not match its content digest') + if (retained && !retained.equals(built)) throw new Error('Computer use retained executable does not match its content digest') signal.throwIfAborted() if (!retained) await writeFileAtomic(runtime, built, { mode: 0o755 }) await access(runtime, constants.X_OK) diff --git a/packages/experimental/cua/README.i18n.yaml b/packages/experimental/cua/README.i18n.yaml index 420cc1ff3d..5c780cf24a 100644 --- a/packages/experimental/cua/README.i18n.yaml +++ b/packages/experimental/cua/README.i18n.yaml @@ -18,7 +18,7 @@ en: ceb54d964dc355b6 zh: 0892066fbceef7b9 /deepseek-ai-dsh-cua/use-this-package/permission-controls: - en: be53792edc381ea1 + en: 7315ac8d68cf5d02 zh: 037407055d9f34a0 /deepseek-ai-dsh-cua/understand-the-implementation: en: a32743fc5b545ab3 diff --git a/packages/experimental/cua/README.md b/packages/experimental/cua/README.md index 1f6de18e43..531cf4f21b 100644 --- a/packages/experimental/cua/README.md +++ b/packages/experimental/cua/README.md @@ -43,7 +43,7 @@ Actions return `dispatched:true, verified:false`; read fresh state to verify the ### Permission controls -The [Computer Use bundle](../cua-bundle/README.md) mounts this page with its native provider. **Authorize Computer Use** groups the new-session default, PTC execution mode, Accessibility, and Screen Recording inside one border, with dividers between the rows. Each system-permission row shows the system-reported responsible application’s `.app` bundle filename and icon together in a bordered label. **Manage applications Computer Use can access** places the application lists inside a separate bordered group. Choose **Request permission**, find that application in the macOS permission list, and enable its switch; if macOS names another application, follow that prompt. With no detected source, the page directs the user to the application named by macOS. Each system-permission row has one control: **Request permission** when access is missing, or an outlined **Allowed** button with a settings icon after authorization. Clicking it opens the corresponding privacy pane, where the user can revoke access; hovering shows **Open System Settings**. Unknown status uses the same button with an **Unknown** label; pending initial observations retain a single placeholder. The page refreshes on entry, when the window regains focus, and after permission actions. A failed query offers **Retry**. Follow any macOS quit-and-reopen prompt and restart DSH as needed. +The [Computer Use bundle](../cua-bundle/README.md) mounts this page with its native provider. **Authorize computer use** groups the new-session default, PTC execution mode, Accessibility, and Screen Recording inside one border, with dividers between the rows. Each system-permission row shows the system-reported responsible application’s `.app` bundle filename and icon together in a bordered label. **Manage applications computer use can access** places the application lists inside a separate bordered group. Choose **Request permission**, find that application in the macOS permission list, and enable its switch; if macOS names another application, follow that prompt. With no detected source, the page directs the user to the application named by macOS. Each system-permission row has one control: **Request permission** when access is missing, or an outlined **Allowed** button with a settings icon after authorization. Clicking it opens the corresponding privacy pane, where the user can revoke access; hovering shows **Open System Settings**. Unknown status uses the same button with an **Unknown** label; pending initial observations retain a single placeholder. The page refreshes on entry, when the window regains focus, and after permission actions. A failed query offers **Retry**. Follow any macOS quit-and-reopen prompt and restart DSH as needed. System-permission operations affect the computer running DSH. When accessing DSH remotely, complete authorization on that computer. diff --git a/packages/experimental/cua/src/application-access.ts b/packages/experimental/cua/src/application-access.ts index 4b34223012..4fac44b927 100644 --- a/packages/experimental/cua/src/application-access.ts +++ b/packages/experimental/cua/src/application-access.ts @@ -145,8 +145,8 @@ export class ApplicationAccess { let result: AskUserQuestionAnswer try { result = await questions.ask({ agent, signal, questions: [{ id: 'cua-app-access', - question: `Allow Computer Use to launch if needed, read, capture, and control ${identity.name}?`, - detail: 'Session access lasts until DSH restarts or Computer Use is disabled.', + question: `Allow computer use to launch if needed, read, capture, and control ${identity.name}?`, + detail: 'Session access lasts until DSH restarts or computer use is disabled.', options }] }) } catch (error) { if (error instanceof UserQuestionError && error.code === 'ASK_CANCELLED') { diff --git a/packages/experimental/cua/src/client/locales.ts b/packages/experimental/cua/src/client/locales.ts index 7b4062f707..7e23488b28 100644 --- a/packages/experimental/cua/src/client/locales.ts +++ b/packages/experimental/cua/src/client/locales.ts @@ -2,13 +2,13 @@ export const NS = 'cuaPermissions' /** English permission-page dictionary. */ export const en = { - title: 'Authorize Computer Use', - applicationsTitle: 'Manage applications Computer Use can access', - setupTitle: 'Set up Computer Use before use', - setupBody: 'Computer Use is enabled. Before using it, switch to PTC mode and grant Accessibility and Screen Recording permissions. Open the plugin details to complete setup.', - setupQueryFailed: 'Computer Use is enabled, but system permissions could not be checked. Open the plugin details to see the error and retry.', + title: 'Authorize computer use', + applicationsTitle: 'Manage applications computer use can access', + setupTitle: 'Set up computer use before use', + setupBody: 'Computer use is enabled. Before using it, switch to PTC mode and grant Accessibility and Screen Recording permissions. Open the plugin details to complete setup.', + setupQueryFailed: 'Computer use is enabled, but system permissions could not be checked. Open the plugin details to see the error and retry.', defaultTitle: 'Default mode for new sessions', - defaultBody: 'Computer Use is available only in PTC mode.', + defaultBody: 'Computer use is available only in PTC mode.', defaultConfirm: 'Set to PTC', defaultSet: 'PTC is the default', defaultUnavailable: 'Could not read the default mode. Check it in Settings.', defaultReadOnly: 'Default-mode settings are read-only.', diff --git a/packages/experimental/inspector-profile/README.i18n.yaml b/packages/experimental/inspector-profile/README.i18n.yaml index 5173e0b524..e8c03b3fb7 100644 --- a/packages/experimental/inspector-profile/README.i18n.yaml +++ b/packages/experimental/inspector-profile/README.i18n.yaml @@ -15,7 +15,7 @@ en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-experimental-inspector-profile/use-this-package: - en: 919c4baad08a10a6 + en: 01ff66653aa262e7 zh: 0f0c82abda2f3630 /deepseek-ai-dsh-experimental-inspector-profile/understand-the-implementation: en: c9afee7b01eff431 diff --git a/packages/experimental/inspector-profile/README.md b/packages/experimental/inspector-profile/README.md index 1478378369..0dfa699728 100644 --- a/packages/experimental/inspector-profile/README.md +++ b/packages/experimental/inspector-profile/README.md @@ -24,11 +24,11 @@ Enable this optional bundle to inspect Session data in the Sidebar and open Node ## Use this package -Enable **Developer Tools** in Plugin Manager's official group. This selects `@deepseek-ai/dsh-experimental-inspector-profile` for the current Web profile. +Enable **Developer tools** in Plugin Manager's official group. This selects `@deepseek-ai/dsh-experimental-inspector-profile` for the current Web profile. -Open **Session Log** from the Sidebar's new-tab menu or guide. [Session Inspector](../session-inspector/README.md) provides one view with selectable Raw Log and Chat Group presentations; Raw Log is selected initially. +Open **Session log** from the Sidebar's new-tab menu or guide. [Session Inspector](../session-inspector/README.md) provides one view with selectable Raw Log and Chat Group presentations; Raw Log is selected initially. -Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS Inspector** in the bottom panel with the packaged DevTools frontend. Enabling this bundle starts the local debugging backend and unredacted Host fetch capture; no user override or debugging argument is required. +Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS inspector** in the bottom panel with the packaged DevTools frontend. Enabling this bundle starts the local debugging backend and unredacted Host fetch capture; no user override or debugging argument is required. ----- diff --git a/packages/experimental/inspector-profile/docs/DETAIL.md b/packages/experimental/inspector-profile/docs/DETAIL.md index 7f66b5acb9..7fe5bbc402 100644 --- a/packages/experimental/inspector-profile/docs/DETAIL.md +++ b/packages/experimental/inspector-profile/docs/DETAIL.md @@ -1,13 +1,13 @@ -![Session Log table in the sidebar and the NodeJS Inspector console](preview.svg) +![Session log table in the sidebar and the NodeJS inspector console](preview.svg) Use it when troubleshooting: check exactly what DSH recorded for a session, or debug DSH itself in Chrome DevTools. It does not change how the agent works. ## How to use -- In the right sidebar, click **+** (**New tab**), then choose **Session Log** on the **Start** page that opens. -- **Raw Log** lists the session's events grouped by turn and step; click a row to see its full data. Switch the selector to **Chat Group** to see the chat messages and their groups instead. +- In the right sidebar, click **+** (**New tab**), then choose **Session log** on the **Start** page that opens. +- **Raw log** lists the session's events grouped by turn and step; click a row to see its full data. Switch the selector to **Chat group** to see the chat messages and their groups instead. - Click the crosshair button next to the selector, then click a message in the chat to find its record. Press Esc to cancel. -- Press **Ctrl+Shift+.** (**Cmd+Shift+.** on macOS) to open or collapse **NodeJS Inspector** at the bottom of the window: Chrome DevTools, in English, connected to the DSH backend. +- Press **Ctrl+Shift+.** (**Cmd+Shift+.** on macOS) to open or collapse **NodeJS inspector** at the bottom of the window: Chrome DevTools, in English, connected to the DSH backend. ## Good to know diff --git a/packages/experimental/inspector-profile/locale/en.json b/packages/experimental/inspector-profile/locale/en.json index 1d0807525f..05afa6bc75 100644 --- a/packages/experimental/inspector-profile/locale/en.json +++ b/packages/experimental/inspector-profile/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Developer Tools", + "title": "Developer tools", "description": "Inspect and debug raw session data, grouped chat messages, and the NodeJS backend.", "detail": "docs/DETAIL.md" } diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 0b5af98a06..5902a25cb2 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -15,7 +15,7 @@ en: 331f844c32631a82 zh: a9bc365f7d61d9ed /deepseek-ai-dsh-experimental-inspector/use-this-package: - en: 6d290aabc543f878 + en: 7d4d718b1a6f92ae zh: 7ad283b159ae2c29 /deepseek-ai-dsh-experimental-inspector/runtime-layout: en: 7db0c435e07d5005 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 3b958daa73..e5d498e64d 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -35,7 +35,7 @@ For trusted local development, `pnpm run demo:inspector` explicitly mounts the s The bundle sets `experimental-inspector` to `disabled: false` with `captureFetch: true`. The Host's optional `--inspect` argument hook only opens an external Chrome window; it does not control whether the Inspector runs. -When this component is enabled, **Ctrl/Cmd+Shift+.** toggles **NodeJS Inspector** in the full-width bottom panel without requiring a selected Session. Drag its top edge to resize it, taking space from the content above; the selected height is retained across collapse and navigation. The focused divider also accepts Up/Down, Home and End. Its iframe loads on the first open and remains mounted while collapsed and across Session or main-panel navigation; unloading the component resets it. The X button and shortcut restore the opening control's focus when that control remains connected. The iframe automatically connects to this Host and opens Console; the manual Connection panel is omitted. Each embedded frontend shows Host and only its embedding page's Client in Console, Sources and the Cordis tree. The printed `devtools://` URL shows all Clients. +When this component is enabled, **Ctrl/Cmd+Shift+.** toggles **NodeJS inspector** in the full-width bottom panel without requiring a selected Session. Drag its top edge to resize it, taking space from the content above; the selected height is retained across collapse and navigation. The focused divider also accepts Up/Down, Home and End. Its iframe loads on the first open and remains mounted while collapsed and across Session or main-panel navigation; unloading the component resets it. The X button and shortcut restore the opening control's focus when that control remains connected. The iframe automatically connects to this Host and opens Console; the manual Connection panel is omitted. Each embedded frontend shows Host and only its embedding page's Client in Console, Sources and the Cordis tree. The printed `devtools://` URL shows all Clients. The Host serves the mirrored DevTools frontend at the application-relative `inspector/devtools/devtools_app.html` path, using the existing Web login. The `inspector/devtools` prefix redirects to that entry; GET and HEAD serve files, missing resources return 404, and other methods return 405. Its `cdp` WebSocket route authenticates the same browser session and forwards only to this Inspector's Worker page target. The bottom panel supplies its claimed `clientSourceId` to the entry and automatic WebSocket endpoint; the selection follows that logical Client across reconnects. An absent selector includes all Clients, an unavailable selected Client leaves Host visible, and empty or repeated selectors reject the upgrade with 400. Explicit `ws` or `wss` query parameters override the automatic endpoint, including its selection. diff --git a/packages/experimental/inspector/locale/en.json b/packages/experimental/inspector/locale/en.json index 51f965efc6..a41eb0a6ba 100644 --- a/packages/experimental/inspector/locale/en.json +++ b/packages/experimental/inspector/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "NodeJS Inspector", + "title": "NodeJS inspector", "description": "Press Ctrl/Cmd+Shift+. to open Chrome DevTools in the bottom panel for development and debugging." } } diff --git a/packages/experimental/inspector/src/client/bottom/locales.ts b/packages/experimental/inspector/src/client/bottom/locales.ts index e6d6c05711..dbc4116dbf 100644 --- a/packages/experimental/inspector/src/client/bottom/locales.ts +++ b/packages/experimental/inspector/src/client/bottom/locales.ts @@ -9,11 +9,11 @@ export const zh = { /** English labels checked against the bottom-panel dictionary. */ export const en: Record = { - title: 'NodeJS Inspector', - toggle: 'Toggle NodeJS Inspector', + title: 'NodeJS inspector', + toggle: 'Toggle NodeJS inspector', close: 'Collapse', - resize: 'Resize NodeJS Inspector panel', - frameTitle: 'NodeJS Inspector', + resize: 'Resize NodeJS inspector panel', + frameTitle: 'NodeJS inspector', } declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/packages/experimental/inspector/tests/bottom.client.spec.tsx b/packages/experimental/inspector/tests/bottom.client.spec.tsx index da27215e2a..5900a7f790 100644 --- a/packages/experimental/inspector/tests/bottom.client.spec.tsx +++ b/packages/experimental/inspector/tests/bottom.client.spec.tsx @@ -50,7 +50,7 @@ it('opens on Mod+Shift+Period without a Session or Sidebar and retains the ifram expect(press(window, { ctrlKey: false }).defaultPrevented).toBe(false) expect(body.container.querySelector('iframe')).toBeNull() expect(press().defaultPrevented).toBe(true) - const frame = body.view.getByTitle('NodeJS Inspector') + const frame = body.view.getByTitle('NodeJS inspector') expect(frame.getAttribute('src')).toBe('inspector/devtools/devtools_app.html?disableLocaleInfoBar=true&clientSourceId=client-page') expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer') expect(frame.getAttribute('name')).toBe('dsh-nodejs-inspector') @@ -62,7 +62,7 @@ it('opens on Mod+Shift+Period without a Session or Sidebar and retains the ifram press() expect(body.container.querySelector('section')?.hidden).toBe(true) press() - expect(body.view.getByTitle('NodeJS Inspector')).toBe(frame) + expect(body.view.getByTitle('NodeJS inspector')).toBe(frame) expect(body.container.querySelector('section')?.hidden).toBe(false) }) @@ -70,8 +70,8 @@ it('resizes with the owned pointer, bounds the height, and retains it across col const { body } = await setup() press() const panel = body.container.querySelector('section')! - const frame = body.view.getByTitle('NodeJS Inspector') - const divider = body.view.getByRole('separator', { name: 'Resize NodeJS Inspector panel' }) + const frame = body.view.getByTitle('NodeJS inspector') + const divider = body.view.getByRole('separator', { name: 'Resize NodeJS inspector panel' }) let captured: number | undefined Object.assign(divider, { setPointerCapture: (id: number) => { captured = id }, @@ -113,7 +113,7 @@ it('resizes with the owned pointer, bounds the height, and retains it across col press() press() expect(height()).toBe(20) - expect(body.view.getByTitle('NodeJS Inspector')).toBe(frame) + expect(body.view.getByTitle('NodeJS inspector')).toBe(frame) }) it('resizes the horizontal divider with arrow keys and Home or End', async () => { @@ -137,7 +137,7 @@ it('routes frame input through the effective binding and restores opener focus', try { opener.focus() press(opener) - const frame = body.view.getByTitle('NodeJS Inspector') + const frame = body.view.getByTitle('NodeJS inspector') const document = frame.contentDocument! frame.focus() expect(press(document).defaultPrevented).toBe(true) @@ -168,7 +168,7 @@ it('keeps modal arbitration and updates locale without replacing the iframe', as expect(body.container.querySelector('iframe')).toBeNull() } finally { modal.remove() } press() - const frame = body.view.getByTitle('NodeJS Inspector') + const frame = body.view.getByTitle('NodeJS inspector') act(() => { locale.setLocale('zh') }) expect(body.view.getByTitle('NodeJS 诊断')).toBe(frame) act(() => { body.view.getByRole('button', { name: '收起' }).click() }) @@ -178,7 +178,7 @@ it('keeps modal arbitration and updates locale without replacing the iframe', as it('removes the command and frame on unload and starts a fresh closed panel on reload', async () => { const { runtime, locale, provider, body, plugin } = await setup() press() - const frame = body.view.getByTitle('NodeJS Inspector') + const frame = body.view.getByTitle('NodeJS inspector') await provider.dispose() expect(runtime.ctx.shortcuts.catalog.getSnapshot().find(row => row.id === ID)).toBeUndefined() expect(frame.isConnected).toBe(false) @@ -186,5 +186,5 @@ it('removes the command and frame on unload and starts a fresh closed panel on r await runtime.mount(plugin) expect(body.container.querySelector('iframe')).toBeNull() press() - expect(body.view.getByTitle('NodeJS Inspector')).not.toBe(frame) + expect(body.view.getByTitle('NodeJS inspector')).not.toBe(frame) }) diff --git a/packages/experimental/session-inspector/README.i18n.yaml b/packages/experimental/session-inspector/README.i18n.yaml index 88bab45bae..58ac094426 100644 --- a/packages/experimental/session-inspector/README.i18n.yaml +++ b/packages/experimental/session-inspector/README.i18n.yaml @@ -9,13 +9,13 @@ en: 151ad1f553a733d7 zh: b2992178863a80c7 /deepseek-ai-dsh-experimental-session-inspector/summary: - en: 9ba1ce1f7f3c36ff + en: 3f0006953eb4adbe zh: a6aaf26eb598a80a /deepseek-ai-dsh-experimental-session-inspector/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-experimental-session-inspector/use-this-package: - en: a54a88121496d7d0 + en: ec05c30394d91d56 zh: 85fb112b26ec0ccb /deepseek-ai-dsh-experimental-session-inspector/understand-the-implementation: en: 6d71dcef4dca024b diff --git a/packages/experimental/session-inspector/README.md b/packages/experimental/session-inspector/README.md index 76a8c719f2..8d6c68b8ff 100644 --- a/packages/experimental/session-inspector/README.md +++ b/packages/experimental/session-inspector/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Inspect raw Session logs and Chat structures through the optional [Developer Tools bundle](../inspector-profile/README.md). Open **Session Log** from the Sidebar's new-tab menu or guide, then choose **Raw Log** or **Chat Group** from its upper-left selector. Follow object references between Nodes, Turns, and Steps, or locate a selected Node in Chat. Stream chunks appear as child rows, and Chat updates briefly highlight their rows. Both presentations support older history and resizable details without a composer. +Inspect raw Session logs and Chat structures through the optional [Developer Tools bundle](../inspector-profile/README.md). Open **Session log** from the Sidebar's new-tab menu or guide, then choose **Raw log** or **Chat group** from its upper-left selector. Follow object references between Nodes, Turns, and Steps, or locate a selected Node in Chat. Stream chunks appear as child rows, and Chat updates briefly highlight their rows. Both presentations support older history and resizable details without a composer. ## Table of Contents @@ -56,7 +56,7 @@ Chat picking prefers the exact Node and reasoning/response part, then its Group, Session Log uses the same field renderer for parsed JSON, with every nested object and array initially expanded. Containers can be collapsed individually; there are no reference buttons, breadcrumbs, collection-property disclosures, or collection paging. JSON retains `[Circular]` markers and readable bigint strings. Inspection failures appear locally without disabling the table. Preview filtering never removes fields from either detail presentation. -Ancestor rows remain sticky beneath the table header, one nesting level below another, with an opaque tinted background. Each level follows the first row not covered by the header or earlier sticky levels. Automatic block closure keeps the visible row or surviving parent at its screen position and leaves unused space only below the data; new rows fill that space before normal tail following resumes. A manual disclosure keeps the clicked header at its measured viewport position and pauses following, retaining only the trailing space needed to keep it visible. **Follow Latest** clears reserved space and resumes following. Scrolling upward or choosing **Load earlier** also pauses following. Raw Log shows UTC timestamps with the server's millisecond precision; Chat Group has no time column. +Ancestor rows remain sticky beneath the table header, one nesting level below another, with an opaque tinted background. Each level follows the first row not covered by the header or earlier sticky levels. Automatic block closure keeps the visible row or surviving parent at its screen position and leaves unused space only below the data; new rows fill that space before normal tail following resumes. A manual disclosure keeps the clicked header at its measured viewport position and pauses following, retaining only the trailing space needed to keep it visible. **Follow latest** clears reserved space and resumes following. Scrolling upward or choosing **Load earlier** also pauses following. Raw Log shows UTC timestamps with the server's millisecond precision; Chat Group has no time column. ----- diff --git a/packages/experimental/session-inspector/locale/en.json b/packages/experimental/session-inspector/locale/en.json index c121fc58ba..601be55e25 100644 --- a/packages/experimental/session-inspector/locale/en.json +++ b/packages/experimental/session-inspector/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Session Log", + "title": "Session log", "description": "Enable analysis of the current session's raw data and grouped chat messages in the session sidebar." } } diff --git a/packages/experimental/session-inspector/src/client/locales.ts b/packages/experimental/session-inspector/src/client/locales.ts index 5a02c89b79..7149689a9a 100644 --- a/packages/experimental/session-inspector/src/client/locales.ts +++ b/packages/experimental/session-inspector/src/client/locales.ts @@ -44,13 +44,13 @@ export const zh = { 'table.collapse': '收起子行', 'table.removed': '该记录已移除或结算,请选择当前记录。', 'object.node': 'Node', - 'object.nodeData': 'Node Data', + 'object.nodeData': 'Node data', 'object.group': 'Group', - 'object.groupData': 'Group Data', + 'object.groupData': 'Group data', 'object.turn': 'Turn', - 'object.turnData': 'Turn Data', + 'object.turnData': 'Turn data', 'object.step': 'Step', - 'object.stepData': 'Step Data', + 'object.stepData': 'Step data', 'object.accessor': '[Getter / Setter,未执行]', 'object.empty': '空', 'object.absent': '[空槽]', @@ -71,16 +71,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English labels, checked against the Chinese key set. */ export const en: Record = { - 'tab.title': 'Session Log', + 'tab.title': 'Session log', 'tab.description': 'Analyze raw logs and grouped chat messages for the current session in the sidebar.', 'view.presentation': 'Presentation', - 'view.sessionLog': 'Raw Log', - 'view.chatNode': 'Chat Group', - 'picker.pick': 'Pick an element in Chat View', + 'view.sessionLog': 'Raw log', + 'view.chatNode': 'Chat group', + 'picker.pick': 'Pick an element in the chat view', 'picker.cancel': 'Cancel picking (Esc)', - 'picker.instructions': 'Hover in the main Chat to preview, then click to select. Esc cancels.', + 'picker.instructions': 'Hover in the main chat to preview, then click to select. Esc cancels.', 'picker.noMatch': 'No matching record in this table. Pick another element or press Esc to cancel.', - 'picker.noChat': 'Open this Session in the main Chat before picking.', + 'picker.noChat': 'Open this session in the main chat before picking.', 'table.id': 'Index', 'table.type': 'Type', 'filter.title': 'Filter types', @@ -100,7 +100,7 @@ export const en: Record = { 'table.data': 'Data', 'table.older': 'Load earlier', 'table.loading': 'Loading…', - 'table.latest': 'Follow Latest', + 'table.latest': 'Follow latest', 'table.empty': 'No records', 'table.raw': 'Raw data', 'table.rawError': 'This record could not be serialized. Select another record to continue.', @@ -110,14 +110,14 @@ export const en: Record = { 'table.collapse': 'Collapse children', 'table.removed': 'This record was removed or settled. Select a current record.', 'object.node': 'Node', - 'object.nodeData': 'Node Data', + 'object.nodeData': 'Node data', 'object.group': 'Group', - 'object.groupData': 'Group Data', + 'object.groupData': 'Group data', 'object.turn': 'Turn', - 'object.turnData': 'Turn Data', + 'object.turnData': 'Turn data', 'object.step': 'Step', - 'object.stepData': 'Step Data', - 'object.accessor': '[Getter / Setter, not evaluated]', + 'object.stepData': 'Step data', + 'object.accessor': '[Getter / setter, not evaluated]', 'object.empty': 'Empty', 'object.absent': '[Empty slot]', 'object.properties': 'Other properties', diff --git a/packages/experimental/session-inspector/tests/object-tree.client.spec.tsx b/packages/experimental/session-inspector/tests/object-tree.client.spec.tsx index c97e8f2899..b3d3e05d63 100644 --- a/packages/experimental/session-inspector/tests/object-tree.client.spec.tsx +++ b/packages/experimental/session-inspector/tests/object-tree.client.spec.tsx @@ -143,7 +143,7 @@ it('expands Node Data inline but keeps its title navigable and cyclic references target: { nodeKey: 'node-1' }, read: () => data } props.index.set(data, reference) const ui = render() - const link = ui.getByRole('button', { name: '↗ Node Data · 1' }) + const link = ui.getByRole('button', { name: '↗ Node data · 1' }) const branch = link.closest('details')! expect(branch.open).toBe(false) expect(ui.queryByText('"inline node data"')).toBeNull() @@ -151,7 +151,7 @@ it('expands Node Data inline but keeps its title navigable and cyclic references fireEvent(branch, new Event('toggle')) expect(ui.getByText('"inline node data"')).toBeTruthy() expect(props.navigate).not.toHaveBeenCalled() - expect(ui.getAllByRole('button', { name: '↗ Node Data · 1' })).toHaveLength(2) + expect(ui.getAllByRole('button', { name: '↗ Node data · 1' })).toHaveLength(2) expect(ui.container.querySelectorAll('details')).toHaveLength(2) fireEvent.click(link) expect(props.navigate).toHaveBeenCalledExactlyOnceWith(reference) diff --git a/packages/experimental/session-search/README.i18n.yaml b/packages/experimental/session-search/README.i18n.yaml index 9e76d6fea8..dac818a47a 100644 --- a/packages/experimental/session-search/README.i18n.yaml +++ b/packages/experimental/session-search/README.i18n.yaml @@ -3,7 +3,7 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/session-search/README.md /: - en: ac0fb71830f58337 + en: c8e3f38d9f1a4e2f zh: 0ece950cf48261a1 /deepseek-ai-dsh-experimental-session-search: en: f4f9c83d1dd36580 diff --git a/packages/experimental/session-search/README.md b/packages/experimental/session-search/README.md index 1feee8ec67..5ca11a0a81 100644 --- a/packages/experimental/session-search/README.md +++ b/packages/experimental/session-search/README.md @@ -1,5 +1,5 @@ --- -description: "Give Agents tools to search and read earlier sessions from the current workspace." +description: "Give agents tools to search and read earlier sessions from the current workspace." kind: "package-bundle" --- diff --git a/packages/experimental/session-search/locale/en.json b/packages/experimental/session-search/locale/en.json index 062c34fca3..712a4d1697 100644 --- a/packages/experimental/session-search/locale/en.json +++ b/packages/experimental/session-search/locale/en.json @@ -1,7 +1,7 @@ { "meta": { "title": "Session search", - "description": "Give Agents tools to search and read earlier sessions from the current workspace.", + "description": "Give agents tools to search and read earlier sessions from the current workspace.", "detail": "docs/DETAIL.md" } } diff --git a/packages/experimental/session-search/package.json b/packages/experimental/session-search/package.json index c4ebf0a594..02be68de4c 100644 --- a/packages/experimental/session-search/package.json +++ b/packages/experimental/session-search/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-experimental-session-search", - "description": "Give Agents tools to search and read earlier sessions from the current workspace.", + "description": "Give agents tools to search and read earlier sessions from the current workspace.", "version": "0.2.1-rc.2", "icon": "./icon.svg", "publishConfig": { diff --git a/packages/experimental/tool-agent-team/locale/en.json b/packages/experimental/tool-agent-team/locale/en.json index f517900c98..80aaa02ab8 100644 --- a/packages/experimental/tool-agent-team/locale/en.json +++ b/packages/experimental/tool-agent-team/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Team Tools", + "title": "Team tools", "description": "Give agents tools to coordinate members, exchange messages, and manage shared tasks." } } diff --git a/packages/experimental/tool-worktree/README.i18n.yaml b/packages/experimental/tool-worktree/README.i18n.yaml index 8920ad53de..26d8aaa27e 100644 --- a/packages/experimental/tool-worktree/README.i18n.yaml +++ b/packages/experimental/tool-worktree/README.i18n.yaml @@ -15,7 +15,7 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-tool-worktree/use-this-package: - en: f69d3fdeed949ff5 + en: 06074f9f6989911b zh: 05c9a85b7cdcfbad /deepseek-ai-dsh-experimental-tool-worktree/understand-the-implementation: en: c03f875211ab69a8 diff --git a/packages/experimental/tool-worktree/README.md b/packages/experimental/tool-worktree/README.md index 9ce2da182f..9c93f59f08 100644 --- a/packages/experimental/tool-worktree/README.md +++ b/packages/experimental/tool-worktree/README.md @@ -25,7 +25,7 @@ Add Git Worktrees from the GUI plugin manager to let a model create and enter a ## Use this package -In the GUI, open **Plugins → Official → Git Worktrees** and turn on the switch. The bundle loads the [worktree runtime](../worktree/README.md) and this tool together; it is off in every shipped profile. Turning it off removes `create_worktree` without deleting existing checkouts or branches. The built-in `working_directory` tool remains available. +In the GUI, open **Plugins → Official → Git worktrees** and turn on the switch. The bundle loads the [worktree runtime](../worktree/README.md) and this tool together; it is off in every shipped profile. Turning it off removes `create_worktree` without deleting existing checkouts or branches. The built-in `working_directory` tool remains available. For an explicit composition, mount this function plugin after the worktree runtime and tool registry. diff --git a/packages/experimental/tool-worktree/locale/en.json b/packages/experimental/tool-worktree/locale/en.json index 4d2ed41bc0..5d58b91ae1 100644 --- a/packages/experimental/tool-worktree/locale/en.json +++ b/packages/experimental/tool-worktree/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Git Worktrees", + "title": "Git worktrees", "description": "Let the agent create a new Git branch and worktree, then continue there.", "detail": "docs/DETAIL.md" } diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/locale/en.json b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/locale/en.json index 28dfb853d7..3a3e18d25c 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/locale/en.json +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/decoration/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Composer Decoration", + "title": "Composer decoration", "description": "Displays a blue circle below the message composer." } } diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/locale/en.json b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/locale/en.json index 1498e59800..9fd546fa2a 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/locale/en.json +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/templates/mcp/locale/en.json @@ -1,6 +1,6 @@ { "meta": { - "title": "Demo MCP Connection", + "title": "Demo MCP connection", "description": "Connects a remote MCP server to make its tools available to the agent." } } diff --git a/snapshots/web/session-inspector/log.expected.md b/snapshots/web/session-inspector/log.expected.md index 6f82049736..4621741cb4 100644 --- a/snapshots/web/session-inspector/log.expected.md +++ b/snapshots/web/session-inspector/log.expected.md @@ -1,4 +1,4 @@ -- table "Raw Log": +- table "Raw log": - rowgroup: - row "Index Type Filter types T/S Time (UTC) Data": - columnheader "Index" From 472f0c8258fe8f3a2cb111b308be8a7cc13ee0a2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 21 Oct 2026 12:06:49 +0800 Subject: [PATCH 8/8] docs: formalize sentence case for English UI copy Record the sentence-case rule for English UI copy and plugin titles, sync remaining README label references, and keep the session-inspector Chinese dictionary unchanged. Refs #6790 Co-authored-by: Magolor <25860372+Magolor@users.noreply.github.com> --- .agents/skills/dsh-prose-standard/SKILL.md | 2 +- AGENTS.md | 2 +- apps/web/tests/agent-team-panel.e2e.ts | 4 ++-- docs/cookbook/adding-a-package.i18n.yaml | 4 ++-- docs/cookbook/adding-a-package.md | 2 +- docs/cookbook/adding-a-package.zh.md | 2 +- .../inspector-profile/README.i18n.yaml | 6 ++--- .../experimental/inspector-profile/README.md | 6 ++--- .../experimental/inspector/README.i18n.yaml | 2 +- packages/experimental/inspector/README.md | 2 +- .../inspector/src/client/bottom/locales.ts | 2 +- .../session-inspector/README.i18n.yaml | 6 ++--- .../experimental/session-inspector/README.md | 22 +++++++++---------- .../session-inspector/src/client/locales.ts | 8 +++---- .../src/client/views/InspectorObjectTree.tsx | 4 ++-- .../tool-worktree/README.i18n.yaml | 2 +- packages/experimental/tool-worktree/README.md | 2 +- .../references/host-plugin.md | 4 ++-- 18 files changed, 41 insertions(+), 41 deletions(-) diff --git a/.agents/skills/dsh-prose-standard/SKILL.md b/.agents/skills/dsh-prose-standard/SKILL.md index bb8347823b..338ff29a21 100644 --- a/.agents/skills/dsh-prose-standard/SKILL.md +++ b/.agents/skills/dsh-prose-standard/SKILL.md @@ -55,7 +55,7 @@ This is not a one-way shortening pass. Add or restore prose when code, types, an - **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality. - **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth. - **Examples and configuration comments:** explain access limits, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows. -- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. +- **Prompts and visible strings:** treat wording as behavior. Client UI copy belongs in typed locale dictionaries and reaches Cordis-free primitives as explicit localized props; inspect text, accessibility names, tooltips, placeholders, and format templates together, then run `verify-client-ui-i18n`. English UI copy, including plugin `meta.title` and `meta.description`, uses sentence case: capitalize only the first word, proper nouns, and product names such as `Chrome DevTools`, `Git`, or `MCP` (`Git worktrees`, not `Git Worktrees`). Chinese copy keeps its own conventions. Update the owning runnable snapshot for model-visible text and repository-required GUI evidence. If the authorized scope has no owning scenario, leave the wording unchanged and report the deferral; do not silently fold it into a prose-only edit. - **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration. Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only. diff --git a/AGENTS.md b/AGENTS.md index 1f88aa1293..69dbd7d69e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -155,7 +155,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Tests describe behavior, not correctness.** Change obsolete behavior with its tests; explain why in the PR. - **Create Agent Notes only for durable decision rationale;** mechanical/local edits are exempt, including local UI changes ([scope](.agents/notes/README.md#when-to-write-one)). Archived notes are frozen: never edit or treat them as current authority ([archive policy](.agents/notes/README.md#archiving-and-deletion)). - **Official plugin artwork:** use [dsh-official-plugin-artwork](.agents/skills/dsh-official-plugin-artwork/SKILL.md) for official and official experimental plugin icons; obtain designer approval before landing new glyphs. -- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Client UI copy is locale-owned.** Route product text through typed dictionaries and `t` or localized primitive props; `verify-client-ui-i18n` rejects hardcoded copy ([decision](.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). English copy, including plugin titles, uses sentence case ([rule](.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible change updates a keyless recorded-session snapshot; [snapshot ownership](snapshots/AGENTS.md) reserves the top-level tree for session-driven cases and keeps other expected output owner-local. Fixtures replay on macOS/Linux; fix fixtures, not normalizers. - **Design each tool's UI presentation up front.** Host presenters stay pure; Web cards derive from raw events and persisted result metadata ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for capability seams, lifecycle paths, and transcript output; include missing snapshot-harness support in the same change. diff --git a/apps/web/tests/agent-team-panel.e2e.ts b/apps/web/tests/agent-team-panel.e2e.ts index c42a3385bf..d5f699a288 100644 --- a/apps/web/tests/agent-team-panel.e2e.ts +++ b/apps/web/tests/agent-team-panel.e2e.ts @@ -85,7 +85,7 @@ describe('web e2e: Agent Teams panel', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel')) const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections') onTestFinished(() => { projectionReads.mockRestore() }) - await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click() + await page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }).click() const action = page.getByRole('dialog', { name: 'Agent team', exact: true }) await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor() await action.getByText('lead').waitFor() @@ -120,7 +120,7 @@ describe('web e2e: Agent Teams panel', () => { it('keeps the panel inside the viewport and closes on outside click or Escape', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel-keyboard')) const panel = page.getByRole('dialog', { name: 'Agent team', exact: true }) - const trigger = page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }) + const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent team', exact: true }) const viewport = page.viewportSize()! await trigger.click() await panel.getByText('lead', { exact: true }).waitFor() diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 2fb8b9bd67..de5eb1d67b 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -21,8 +21,8 @@ en: 0894bd9f35430753 zh: 2d28090e71134edd /cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: - en: ed501a72ab8ba6e7 - zh: 8ad775b735887a10 + en: cb799f5d561dd917 + zh: 7739af36ef8ffb3f /cookbook-adding-a-workspace-package/6-verify: en: 80c7fb90b1175aae zh: 2ba37962dbc57299 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 1f86f1b9bd..d5dc6454a8 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -136,7 +136,7 @@ Merge these entries into `package.json`, retaining existing runtime exports and } ``` -Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. +Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. English `meta.title` and `meta.description` use sentence case: capitalize only the first word, proper nouns, and product names, as in `Git worktrees` ([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. Fields fall back independently before view-specific name formatting, using the existing locale language chain first: diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 3e13580fe9..4ef55c443a 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -136,7 +136,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex } ``` -语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 +语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。英文 `meta.title` 和 `meta.description` 使用 sentence case:只大写首个单词、专有名词和产品名,例如 `Git worktrees`([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location))。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链: diff --git a/packages/experimental/inspector-profile/README.i18n.yaml b/packages/experimental/inspector-profile/README.i18n.yaml index e8c03b3fb7..583ef4608e 100644 --- a/packages/experimental/inspector-profile/README.i18n.yaml +++ b/packages/experimental/inspector-profile/README.i18n.yaml @@ -9,16 +9,16 @@ en: a83fef9afaa2d5c3 zh: 95ff381e7be3afe6 /deepseek-ai-dsh-experimental-inspector-profile/summary: - en: 959f9bb18e5e99ad + en: 42e5984090888b60 zh: f0b00539b5ab40fd /deepseek-ai-dsh-experimental-inspector-profile/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-experimental-inspector-profile/use-this-package: - en: 01ff66653aa262e7 + en: 25cfe986a0ff91e1 zh: 0f0c82abda2f3630 /deepseek-ai-dsh-experimental-inspector-profile/understand-the-implementation: - en: c9afee7b01eff431 + en: cc0adef84da59698 zh: c2d1209c14a42088 /deepseek-ai-dsh-experimental-inspector-profile/model-experience: en: 315bc90149039d2d diff --git a/packages/experimental/inspector-profile/README.md b/packages/experimental/inspector-profile/README.md index 0dfa699728..443718d9fb 100644 --- a/packages/experimental/inspector-profile/README.md +++ b/packages/experimental/inspector-profile/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Enable this optional bundle to inspect Session data in the Sidebar and open NodeJS Inspector in the bottom panel. It enables both inspectors and Host fetch capture; the bundle itself is off by default in Plugin Manager. +Enable this optional bundle to inspect Session data in the Sidebar and open NodeJS inspector in the bottom panel. It enables both inspectors and Host fetch capture; the bundle itself is off by default in Plugin Manager. ## Table of Contents @@ -26,7 +26,7 @@ Enable this optional bundle to inspect Session data in the Sidebar and open Node Enable **Developer tools** in Plugin Manager's official group. This selects `@deepseek-ai/dsh-experimental-inspector-profile` for the current Web profile. -Open **Session log** from the Sidebar's new-tab menu or guide. [Session Inspector](../session-inspector/README.md) provides one view with selectable Raw Log and Chat Group presentations; Raw Log is selected initially. +Open **Session log** from the Sidebar's new-tab menu or guide. [Session Inspector](../session-inspector/README.md) provides one view with selectable Raw log and Chat group presentations; Raw log is selected initially. Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS inspector** in the bottom panel with the packaged DevTools frontend. Enabling this bundle starts the local debugging backend and unredacted Host fetch capture; no user override or debugging argument is required. @@ -38,7 +38,7 @@ Press **Ctrl/Cmd+Shift+.** to open or collapse **NodeJS inspector** in the botto
Implementation internals — click to expand -[`cordis.patch.yml`](cordis.patch.yml) enables Session Inspector and the NodeJS Inspector backend with `captureFetch: true`, independently of debugging flags. See the Inspector [README](../inspector/README.md) for debugging and capture behavior. Each plugin owns its behavior and lifetime. +[`cordis.patch.yml`](cordis.patch.yml) enables Session Inspector and the NodeJS inspector backend with `captureFetch: true`, independently of debugging flags. See the Inspector [README](../inspector/README.md) for debugging and capture behavior. Each plugin owns its behavior and lifetime.
diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 5902a25cb2..32a171be97 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -9,7 +9,7 @@ en: 1060bee3ef37134d zh: 7a7b0096714f4279 /deepseek-ai-dsh-experimental-inspector/summary: - en: e2a633d32f00aa99 + en: 4a540c3df0359524 zh: 1d48f445800e3a35 /deepseek-ai-dsh-experimental-inspector/table-of-contents: en: 331f844c32631a82 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index e5d498e64d..b4b78ff4da 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -11,7 +11,7 @@ English | [中文](README.zh.md) Inspect one running dsh Host and its browser Clients in Chrome DevTools: Host and Client Console contexts, Host Sources and debugging, captured Host fetches, and a shared Cordis tree, with all CDP state in a Worker. -The optional [Developer Tools bundle](../inspector-profile/README.md) enables both NodeJS Inspector and Session Log, including Host fetch capture. Web startup does not require or accept `--inspect`. +The optional [Developer tools bundle](../inspector-profile/README.md) enables both NodeJS inspector and Session log, including Host fetch capture. Web startup does not require or accept `--inspect`. ## Table of Contents diff --git a/packages/experimental/inspector/src/client/bottom/locales.ts b/packages/experimental/inspector/src/client/bottom/locales.ts index dbc4116dbf..9387c60c96 100644 --- a/packages/experimental/inspector/src/client/bottom/locales.ts +++ b/packages/experimental/inspector/src/client/bottom/locales.ts @@ -1,4 +1,4 @@ -/** Localized labels for the NodeJS Inspector bottom panel and command. */ +/** Localized labels for the NodeJS inspector bottom panel and command. */ export const zh = { title: 'NodeJS 诊断', toggle: '展开或收起 NodeJS 诊断', diff --git a/packages/experimental/session-inspector/README.i18n.yaml b/packages/experimental/session-inspector/README.i18n.yaml index 58ac094426..c30efa655b 100644 --- a/packages/experimental/session-inspector/README.i18n.yaml +++ b/packages/experimental/session-inspector/README.i18n.yaml @@ -9,13 +9,13 @@ en: 151ad1f553a733d7 zh: b2992178863a80c7 /deepseek-ai-dsh-experimental-session-inspector/summary: - en: 3f0006953eb4adbe + en: da1f12910d37aa6e zh: a6aaf26eb598a80a /deepseek-ai-dsh-experimental-session-inspector/table-of-contents: en: 76368b6b1da79c80 zh: 52384aee991ac542 /deepseek-ai-dsh-experimental-session-inspector/use-this-package: - en: ec05c30394d91d56 + en: f8ee16e89f8b1d07 zh: 85fb112b26ec0ccb /deepseek-ai-dsh-experimental-session-inspector/understand-the-implementation: en: 6d71dcef4dca024b @@ -27,7 +27,7 @@ en: 258d6659c6cb3789 zh: a6517f274c596a7a /deepseek-ai-dsh-experimental-session-inspector/known-limitations-and-deferred-work: - en: bf2b68c4be0d9d6f + en: 042b2cc65c722176 zh: 1530b5fa68f2baa1 /deepseek-ai-dsh-experimental-session-inspector/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 diff --git a/packages/experimental/session-inspector/README.md b/packages/experimental/session-inspector/README.md index 8d6c68b8ff..932e3916a3 100644 --- a/packages/experimental/session-inspector/README.md +++ b/packages/experimental/session-inspector/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Inspect raw Session logs and Chat structures through the optional [Developer Tools bundle](../inspector-profile/README.md). Open **Session log** from the Sidebar's new-tab menu or guide, then choose **Raw log** or **Chat group** from its upper-left selector. Follow object references between Nodes, Turns, and Steps, or locate a selected Node in Chat. Stream chunks appear as child rows, and Chat updates briefly highlight their rows. Both presentations support older history and resizable details without a composer. +Inspect raw Session logs and Chat structures through the optional [Developer tools bundle](../inspector-profile/README.md). Open **Session log** from the Sidebar's new-tab menu or guide, then choose **Raw log** or **Chat group** from its upper-left selector. Follow object references between Nodes, Turns, and Steps, or locate a selected Node in Chat. Stream chunks appear as child rows, and Chat updates briefly highlight their rows. Both presentations support older history and resizable details without a composer. ## Table of Contents @@ -24,39 +24,39 @@ Inspect raw Session logs and Chat structures through the optional [Developer Too ## Use this package -The Developer Tools bundle mounts this plugin as `session-inspector`. It registers the `session-inspector-log` Sidebar page for the Sidebar's current Session, without a Conversation View or header label. The selector initially shows Session Log, with the current row count alongside it. Changing presentation resets local selection, type filtering, and scroll state. This plugin requires no debugging argument, accepts no configuration, and changes no Session data. +The Developer tools bundle mounts this plugin as `session-inspector`. It registers the `session-inspector-log` Sidebar page for the Sidebar's current Session, without a Conversation View or header label. The selector initially shows Raw log, with the current row count alongside it. Changing presentation resets local selection, type filtering, and scroll state. This plugin requires no debugging argument, accepts no configuration, and changes no Session data. Click the filter icon in the Type header to enter a type fragment. Suggestions update asynchronously from all loaded records. Enter or **Apply filter** confirms the fragment; clicking a suggestion or selecting it with arrow keys and Enter confirms that type. Matching ignores case and uses containment: `delta` and `*delta*` both match reasoning and tool-call deltas. Editing alone does not change the table. Escape, Cancel, or clicking outside discards the draft; clearing and confirming restores all types. Filtered tables retain matching rows and their ancestor context, initially expanding folded ancestors. Context-only types appear muted and do not count toward the matching-row total. New and paginated rows use the confirmed filter. Confirming resets selection, disclosure choices, and reserved trailing space, and returns to the top. Following an object reference or picking a Chat element clears the filter if its target row is excluded, then reveals that row. -Session Log groups records between `turn/start` and `turn/end`, with nested `step/start`–`step/end` ranges. The start record is the sticky group header; the end remains a raw child at the group's tail. Turn and Step groups stay expanded after completion and can be folded manually. Records between Steps belong to the Turn; records outside a loaded range remain ungrouped. Missing starts are not synthesized: loading an earlier start adds ancestry to the existing rows. Assistant streams retain their own nesting inside the Step. +Raw log groups records between `turn/start` and `turn/end`, with nested `step/start`–`step/end` ranges. The start record is the sticky group header; the end remains a raw child at the group's tail. Turn and Step groups stay expanded after completion and can be folded manually. Records between Steps belong to the Turn; records outside a loaded range remain ungrouped. Missing starts are not synthesized: loading an earlier start adds ancestry to the existing rows. Assistant streams retain their own nesting inside the Step. -Assistant logs group original timed chunks beneath their matching `block-start`, including text, reasoning, and tool-call argument deltas. Unfinished blocks expand by default; `block-end` automatically collapses its block, which can then be reopened manually. Historical closed blocks start collapsed. Live chunks share an attempt row until settlement replaces them with the durable event. Chat Group nests actual group members below their group. Hidden or unlisted materialized Nodes remain root rows, inserted by event anchor between the existing roots instead of appended after the last Turn; the Inspector does not invent group membership. Inserts and updates briefly highlight unless the system requests reduced motion. +Assistant logs group original timed chunks beneath their matching `block-start`, including text, reasoning, and tool-call argument deltas. Unfinished blocks expand by default; `block-end` automatically collapses its block, which can then be reopened manually. Historical closed blocks start collapsed. Live chunks share an attempt row until settlement replaces them with the durable event. Chat group nests actual group members below their group. Hidden or unlisted materialized Nodes remain root rows, inserted by event anchor between the existing roots instead of appended after the last Turn; the Inspector does not invent group membership. Inserts and updates briefly highlight unless the system requests reduced motion. Use the disclosure arrow to fold children and select a row's type or data to inspect raw details. Horizontal data previews omit `type`, `seq`, and `sequence`, plus top-level `time`. Chat Node also omits top-level `key` and `kind`, which already have their own columns. When one field remains, only its value appears. Tool-call delta previews show only the argument fragment; reasoning delta previews show only the text. Preview filtering does not remove fields from raw details. Drag the detail panel's upper separator, or use its Up/Down keys, to resize it; selecting another row retains its height. A folded reasoning `block-start` omits `index` and shows `blockType: reasoning` followed by its joined reasoning deltas, within the normal preview length. Interleaved blocks keep separate summaries. Expanding the block restores its ordinary fields and original child rows; raw details retain the original block-start without synthesized text. -Chat details show a lazy object tree rather than JSON conversion. Maps retain object keys and values, Sets and arrays show members, and class instances retain their names and own fields. Collections expand in pages; getters are labelled without execution, and WeakMap/WeakSet contents remain opaque. The inspected root expands inline; unregistered cycles show their ancestor path. Expanded fields are cached until a Conversation publication changes, so mutable Turn/Step Data refreshes without resetting disclosure state or repeating enumeration for UI-only renders. +Chat details show a lazy object tree rather than JSON conversion. Maps retain object keys and values, Sets and arrays show members, and class instances retain their names and own fields. Collections expand in pages; getters are labelled without execution, and WeakMap/WeakSet contents remain opaque. The inspected root expands inline; unregistered cycles show their ancestor path. Expanded fields are cached until a Conversation publication changes, so mutable Turn/Step data refreshes without resetting disclosure state or repeating enumeration for UI-only renders. Arrays and TypedArrays read only the disclosed index prefix, including sparse slots marked separately from explicit `undefined`. **Other properties** opens their non-index and symbol fields without invoking getters. The initial numeric page does not enumerate the whole collection; opening Other properties explicitly scans its own keys. -Nested Node, Group, Turn, Step, and Data objects have reference buttons. Activating a reference opens its current loaded value and adds a breadcrumb; selecting an earlier breadcrumb returns to that level and discards the later path. The first breadcrumb retains the original row even when reference navigation selects another table row. A reference with a row unfolds its enclosing Inspector group, scrolls to the row, and flashes it. Node Data also has an inline disclosure arrow: expanding it stays in the current tree, while clicking its title navigates. Repeated Node Data ancestors remain links rather than recursively expanding. Selecting a table row or a picked Chat element starts a new breadcrumb path; Session Log keeps JSON details without reference navigation. +Nested Node, Group, Turn, Step, and Data objects have reference buttons. Activating a reference opens its current loaded value and adds a breadcrumb; selecting an earlier breadcrumb returns to that level and discards the later path. The first breadcrumb retains the original row even when reference navigation selects another table row. A reference with a row unfolds its enclosing Inspector group, scrolls to the row, and flashes it. Node data also has an inline disclosure arrow: expanding it stays in the current tree, while clicking its title navigates. Repeated Node data ancestors remain links rather than recursively expanding. Selecting a table row or a picked Chat element starts a new breadcrumb path; Raw log keeps JSON details without reference navigation. Selecting a Chat row or following a reference uses the Inspector's [DOM matcher](src/client/views/chat-node/dom.ts) to find existing Node, call, Group, or Turn attributes in the same Session's displayed main Chat. Inspector dispatches the existing `beforematch` behavior to searchable folded ancestors, then uses native scrolling and a body-level highlight overlay without adding elements to Chat content. A visible target only flashes; an offscreen target scrolls smoothly before flashing, with immediate scrolling and no flash for reduced motion. ID columns remain narrow; hover or focus an ID to read its complete value in a tooltip. Missing, empty, or still-hidden occurrences do not stop navigation. When event coordinates are available, Inspector tries nearby loaded content in the same Step, then the same Turn, then other loaded Turns, ordered by event distance. Existing Group and Turn occurrences also provide fallback positions. If no candidate can render, Inspector highlights the visible Chat column without moving it. -Selecting a Session Log row passes the [original log coordinates](src/client/views/session-log/chat-target.ts) through the same DOM lookup while retaining the log presentation and JSON details. Inspector reads the existing Chat snapshot and prefers tool call identity, exact event anchors, and the owning Assistant step before applying the same fallback selection. Stream children inherit their owning event and prefer reasoning or response parts when available. Missing targets do not load history or switch the active View; an empty Chat has nothing to highlight. +Selecting a Raw log row passes the [original log coordinates](src/client/views/session-log/chat-target.ts) through the same DOM lookup while retaining the log presentation and JSON details. Inspector reads the existing Chat snapshot and prefers tool call identity, exact event anchors, and the owning Assistant step before applying the same fallback selection. Stream children inherit their owning event and prefer reasoning or response parts when available. Missing targets do not load history or switch the active View; an empty Chat has nothing to highlight. The crosshair beside the presentation selector installs a transparent click-catching mask over the displayed main Chat. Inspector uses `elementsFromPoint()` to look below its mask and preview, then matches existing data attributes. A click selects that record in the current Inspector presentation, reveals its table ancestors, and opens its details without activating the underlying Chat control. Wheel movement is forwarded to the underlying Chat scrollport. Escape, the crosshair button, changing presentation or Session, hiding or closing the Inspector, or leaving the window cancels picking. Each plugin instance permits one active picker; separate instances do not cancel one another. Plugin unload releases its picker without calling panel callbacks and prevents old panels from restarting it. -Chat picking prefers the exact Node and reasoning/response part, then its Group, Step, or Turn. Session Log picking prefers a matching tool call or Node log anchor, then its Assistant, Step, or Turn record; a reasoning part selects its first reasoning block when available. Picking stays active with a notice if the loaded table has no match. Text similarity is not used. The [element and Chat-row matchers](src/client/views/chat-node/pick-match.ts) and the [log model's picker](src/client/views/session-log/model.ts) own these adjustable rules; nested tool calls may resolve to their root Node or log anchor. +Chat picking prefers the exact Node and reasoning/response part, then its Group, Step, or Turn. Raw log picking prefers a matching tool call or Node log anchor, then its Assistant, Step, or Turn record; a reasoning part selects its first reasoning block when available. Picking stays active with a notice if the loaded table has no match. Text similarity is not used. The [element and Chat-row matchers](src/client/views/chat-node/pick-match.ts) and the [log model's picker](src/client/views/session-log/model.ts) own these adjustable rules; nested tool calls may resolve to their root Node or log anchor. -Session Log uses the same field renderer for parsed JSON, with every nested object and array initially expanded. Containers can be collapsed individually; there are no reference buttons, breadcrumbs, collection-property disclosures, or collection paging. JSON retains `[Circular]` markers and readable bigint strings. Inspection failures appear locally without disabling the table. Preview filtering never removes fields from either detail presentation. +Raw log uses the same field renderer for parsed JSON, with every nested object and array initially expanded. Containers can be collapsed individually; there are no reference buttons, breadcrumbs, collection-property disclosures, or collection paging. JSON retains `[Circular]` markers and readable bigint strings. Inspection failures appear locally without disabling the table. Preview filtering never removes fields from either detail presentation. -Ancestor rows remain sticky beneath the table header, one nesting level below another, with an opaque tinted background. Each level follows the first row not covered by the header or earlier sticky levels. Automatic block closure keeps the visible row or surviving parent at its screen position and leaves unused space only below the data; new rows fill that space before normal tail following resumes. A manual disclosure keeps the clicked header at its measured viewport position and pauses following, retaining only the trailing space needed to keep it visible. **Follow latest** clears reserved space and resumes following. Scrolling upward or choosing **Load earlier** also pauses following. Raw Log shows UTC timestamps with the server's millisecond precision; Chat Group has no time column. +Ancestor rows remain sticky beneath the table header, one nesting level below another, with an opaque tinted background. Each level follows the first row not covered by the header or earlier sticky levels. Automatic block closure keeps the visible row or surviving parent at its screen position and leaves unused space only below the data; new rows fill that space before normal tail following resumes. A manual disclosure keeps the clicked header at its measured viewport position and pauses following, retaining only the trailing space needed to keep it visible. **Follow latest** clears reserved space and resumes following. Scrolling upward or choosing **Load earlier** also pauses following. Raw log shows UTC timestamps with the server's millisecond precision; Chat group has no time column. ----- @@ -88,7 +88,7 @@ None; the package does not modify model requests. - **Loaded history** — tables inspect the Session's loaded window; older records require pagination. Live-to-durable row identities persist across window rebuilds and are released when their rows leave the window. -- **Projection controls** — Session Log does not display projection control-stream updates. +- **Projection controls** — Session log does not display projection control-stream updates. - **Publication cadence** — highlighting follows published Node changes, not every internal mutation between publications. Raw data is unredacted. - **Large windows** — each publication still copies the immutable row-reference array; filtering and table layout also scan the loaded rows. Appends do not regroup or recreate historical rows. Opening ordinary-object fields or Other properties scans all own keys. - **Live suggestions** — refreshing type candidates resets keyboard selection, even for an unchanged candidate list; reselect the candidate or click it before confirming. diff --git a/packages/experimental/session-inspector/src/client/locales.ts b/packages/experimental/session-inspector/src/client/locales.ts index 7149689a9a..8a07ea5e95 100644 --- a/packages/experimental/session-inspector/src/client/locales.ts +++ b/packages/experimental/session-inspector/src/client/locales.ts @@ -44,13 +44,13 @@ export const zh = { 'table.collapse': '收起子行', 'table.removed': '该记录已移除或结算,请选择当前记录。', 'object.node': 'Node', - 'object.nodeData': 'Node data', + 'object.nodeData': 'Node Data', 'object.group': 'Group', - 'object.groupData': 'Group data', + 'object.groupData': 'Group Data', 'object.turn': 'Turn', - 'object.turnData': 'Turn data', + 'object.turnData': 'Turn Data', 'object.step': 'Step', - 'object.stepData': 'Step data', + 'object.stepData': 'Step Data', 'object.accessor': '[Getter / Setter,未执行]', 'object.empty': '空', 'object.absent': '[空槽]', diff --git a/packages/experimental/session-inspector/src/client/views/InspectorObjectTree.tsx b/packages/experimental/session-inspector/src/client/views/InspectorObjectTree.tsx index 7417df14b0..9665cc83bd 100644 --- a/packages/experimental/session-inspector/src/client/views/InspectorObjectTree.tsx +++ b/packages/experimental/session-inspector/src/client/views/InspectorObjectTree.tsx @@ -106,8 +106,8 @@ export function InspectorJsonTree({ text, t }: PropsLocale<'session-inspector'> } /** - * Inspect the root and nested Node Data inline; other registered objects remain navigation links. - * Node Data titles also navigate, while repeated ancestors never expand recursively. + * Inspect the root and nested Node data inline; other registered objects remain navigation links. + * Node data titles also navigate, while repeated ancestors never expand recursively. * Expanded fields are cached until their value or Conversation publication changes. * @param props - Original object, weak identity lookup, locale, and reference activation. * @returns A lazily expanded, typed tree that retains Map keys and collection identities. diff --git a/packages/experimental/tool-worktree/README.i18n.yaml b/packages/experimental/tool-worktree/README.i18n.yaml index 26d8aaa27e..33ca340c2f 100644 --- a/packages/experimental/tool-worktree/README.i18n.yaml +++ b/packages/experimental/tool-worktree/README.i18n.yaml @@ -9,7 +9,7 @@ en: db61f6b6ad671f45 zh: 9f3083d7f5605cbb /deepseek-ai-dsh-experimental-tool-worktree/summary: - en: f0df470fd16d8d29 + en: addc1cb3414df6be zh: 5f2d371d844b0568 /deepseek-ai-dsh-experimental-tool-worktree/table-of-contents: en: d152484eb41ac6b4 diff --git a/packages/experimental/tool-worktree/README.md b/packages/experimental/tool-worktree/README.md index 9c93f59f08..b614aa6a8e 100644 --- a/packages/experimental/tool-worktree/README.md +++ b/packages/experimental/tool-worktree/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Add Git Worktrees from the GUI plugin manager to let a model create and enter a new Git worktree with one call. This optional bundle is installed with Harness and stays off until selected. Native tool calls and PTC programs receive the same typed result, and the existing file policy governs creation. +Add Git worktrees from the GUI plugin manager to let a model create and enter a new Git worktree with one call. This optional bundle is installed with Harness and stays off until selected. Native tool calls and PTC programs receive the same typed result, and the existing file policy governs creation. ## Table of Contents diff --git a/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md b/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md index fe88761ae4..b2de52dc8e 100644 --- a/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md +++ b/packages/preset/agent-preset/skills/cordis-plugin-development/references/host-plugin.md @@ -30,7 +30,7 @@ A Host-only bundle needs no dependencies, install scripts, or build tool: Plugin Manager and Settings read `meta.title` and `meta.description` from exported locale JSON and an icon from exported resources without activating plugins. Complete this checklist before installation: -- Write a recognizable title and a description of the capability in `locale/en.json` under `meta.title` and `meta.description`. Add the same fields for the user's language and other supported locales, such as `locale/zh.json`; do not leave template copy unrelated to the delivered plugin. +- Write a recognizable title and a description of the capability in `locale/en.json` under `meta.title` and `meta.description`. Add the same fields for the user's language and other supported locales, such as `locale/zh.json`; do not leave template copy unrelated to the delivered plugin. Write English titles and other UI labels in sentence case: capitalize only the first word and proper nouns, as in `Git worktrees`. - Create an original icon or use artwork whose license permits redistribution, retaining any required attribution. - Export `./locale/*.json` and `./icon`; retain existing runtime exports. Keep the patch, locales, icon, and every runtime file in `files`, updating it whenever you add modules or assets. For a packed or published bundle, verify the actual packed file list. Local directory installation links the checkout instead, so check the files directly; `files` does not filter a linked directory. - Check that the icon is a valid image of the declared format and satisfies the path and size limits below. An accepted filename alone does not establish that its bytes render. @@ -38,7 +38,7 @@ Plugin Manager and Settings read `meta.title` and `meta.description` from export For example, the locale file contains: ```json -{ "meta": { "title": "My Decoration", "description": "Draws a badge under the composer." } } +{ "meta": { "title": "My decoration", "description": "Draws a badge under the composer." } } ``` ```json